diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 899fe66..5ecc2b0 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -27,7 +27,7 @@ jobs:
- name: Restore + Build solution
shell: pwsh
run: |
- dotnet build EverythingToJpeg.slnx -c Release --nologo
+ dotnet build Everything2Everything.slnx -c Release --nologo
- name: Build MSIX
shell: pwsh
@@ -38,6 +38,6 @@ jobs:
- name: Upload MSIX artifact
uses: actions/upload-artifact@v4
with:
- name: EverythingToJpeg-x64-msix-${{ github.sha }}
- path: packaging/dist/EverythingToJpeg-x64.msix
+ name: Everything2Everything-x64-msix-${{ github.sha }}
+ path: packaging/dist/Everything2Everything-x64.msix
retention-days: 14
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3066cb1..f4fdbad 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -64,32 +64,32 @@ jobs:
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
- name: EverythingToJpeg-x64-msix
- path: packaging/dist/EverythingToJpeg-x64.msix
+ name: Everything2Everything-x64-msix
+ path: packaging/dist/Everything2Everything-x64.msix
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
- files: packaging/dist/EverythingToJpeg-x64.msix
+ files: packaging/dist/Everything2Everything-x64.msix
generate_release_notes: true
body: |
- ## EverythingToJpeg
+ ## Everything2Everything
모든 파일을 우클릭 한 번으로 JPEG 로 변환합니다.
### 설치 방법
- 1. 아래 `EverythingToJpeg-x64.msix` 다운로드.
+ 1. 아래 `Everything2Everything-x64.msix` 다운로드.
2. 관리자 PowerShell:
```powershell
# 자체 서명 인증서를 신뢰 저장소에 등록 (PFX 별도 보유 필요)
Import-PfxCertificate -CertStoreLocation Cert:\LocalMachine\TrustedPeople `
- -FilePath .\EverythingToJpeg-DevCert.pfx `
- -Password (ConvertTo-SecureString 'EverythingToJpegDev' -AsPlainText -Force)
+ -FilePath .\Everything2Everything-DevCert.pfx `
+ -Password (ConvertTo-SecureString 'Everything2EverythingDev' -AsPlainText -Force)
# MSIX 사이드로드
- Add-AppxPackage -Path .\EverythingToJpeg-x64.msix -ForceApplicationShutdown
+ Add-AppxPackage -Path .\Everything2Everything-x64.msix -ForceApplicationShutdown
```
3. PNG/HEIC/PDF 등 파일 우클릭 → "JPEG로 빠른 변환" 또는 "JPEG로 변환…"
diff --git a/.gitignore b/.gitignore
index 5221139..09eb868 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,12 +30,12 @@ packaging/Layout/
packaging/dist/
# C++ project intermediate
-src/EverythingToJpeg.Shell/x64/
-src/EverythingToJpeg.Shell/Win32/
-src/EverythingToJpeg.Shell/Debug/
-src/EverythingToJpeg.Shell/Release/
-src/EverythingToJpeg.Shell/.vs/
-src/EverythingToJpeg.Shell/Everythi*/
+src/Everything2Everything.Shell/x64/
+src/Everything2Everything.Shell/Win32/
+src/Everything2Everything.Shell/Debug/
+src/Everything2Everything.Shell/Release/
+src/Everything2Everything.Shell/.vs/
+src/Everything2Everything.Shell/Everythi*/
*.tlog
*.obj
*.pch
diff --git a/Everything2Everything.slnx b/Everything2Everything.slnx
new file mode 100644
index 0000000..1224e0b
--- /dev/null
+++ b/Everything2Everything.slnx
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/EverythingToJpeg.slnx b/EverythingToJpeg.slnx
deleted file mode 100644
index b50fab5..0000000
--- a/EverythingToJpeg.slnx
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/README.md b/README.md
index 4c9d5f0..d6a68a6 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,27 @@
-# EverythingToJpeg
+# Everything2Everything
-Windows 우클릭 컨텍스트 메뉴에서 한 방에 JPEG로. PNG · GIF · BMP · TIFF · WebP · AVIF · HEIC · RAW · PSD · PDF · DOCX 를 지원합니다.
+Windows 우클릭 한 방에 **모든 것을 모든 것으로** 양방향 변환. 이미지 ↔ 이미지, 이미지 ↔ PDF, 문서 ↔ PDF/이미지, HTML → PDF/이미지를 통합 매트릭스로 처리합니다.
-- **빠른 변환** — 다이얼로그 없이 원본 폴더의 `<원본명>_jpeg/` 하위에 즉시 저장
-- **변환…** — 옵션 다이얼로그(품질, 출력 위치, 이름 충돌, 크기 제한, PDF DPI)
-- 진행 상황 + 썸네일 + 드래그 & 드롭 (메인 창)
+> 이 프로젝트는 단방향 *EverythingToJpeg* 에서 양방향 *Everything2Everything* 으로 피보팅된 결과물입니다.
+
+- **카스케이드 컨텍스트 메뉴** — 입력 파일에 따라 가능한 출력 형식만 자동 노출
+- **양방향 이미지 매트릭스** — PNG ↔ JPEG ↔ WebP ↔ AVIF ↔ TIFF ↔ BMP ↔ GIF
+- **HTML → PDF**, **DOCX/HWPX ↔ PDF** 같은 문서 변환 포함
+- 출력 형식별 인코딩 옵션(JPEG quality, WebP lossless, AVIF speed 등) 분리
+- 진행 상황 + 썸네일 + 드래그 & 드롭 메인 창
+
+## 변환 매트릭스
+
+| Provider | 입력 | 출력 |
+|---|---|---|
+| **MagickProvider** | PNG · JPEG · WebP · AVIF · BMP · TIFF · GIF · PSD · RAW (NEF/CR2/CR3/ARW/DNG/RAF/ORF/RW2/SRW/PEF) | PNG · JPEG · WebP · AVIF · BMP · TIFF · GIF |
+| **HeicProvider** | HEIC · HEIF | PNG · JPEG · WebP · AVIF · BMP · TIFF · GIF |
+| **PdfProvider** | PDF | PNG · JPEG · WebP · AVIF · BMP · TIFF |
+| **HtmlProvider** | HTML · HTM | PNG · JPEG · WebP · AVIF · BMP · TIFF · **PDF** |
+| **DocxProvider** | DOCX · DOC | **PDF** + 이미지 7종 |
+| **HwpxProvider** | HWP · HWPX | **PDF** + 이미지 7종 |
+
+총 200+ 변환 쌍 지원. 알파 채널은 출력 포맷이 지원할 때 자동 보존, 미지원이면 흰색(또는 설정된 배경색)으로 평탄화합니다.
## 빠른 시작
@@ -12,65 +29,100 @@ Windows 우클릭 컨텍스트 메뉴에서 한 방에 JPEG로. PNG · GIF · BM
```powershell
# 솔루션 빌드
-dotnet build EverythingToJpeg.slnx -c Release
+dotnet build Everything2Everything.slnx -c Release
# 단일 폴더 publish (framework-dependent, .NET 9 Desktop Runtime 필요)
-dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj `
+dotnet publish src\Everything2Everything.App\Everything2Everything.App.csproj `
-c Release -r win-x64 --self-contained false -o publish
# .NET 런타임 동봉 (단일 사용자 배포가 편함)
-dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj `
+dotnet publish src\Everything2Everything.App\Everything2Everything.App.csproj `
-c Release -r win-x64 --self-contained true `
-p:PublishSingleFile=false -o publish-self
```
-산출물: `publish\EverythingToJpeg.exe`
+산출물: `publish\Everything2Everything.exe`
### 2) 컨텍스트 메뉴 등록
-`EverythingToJpeg.exe`를 한 번 실행 → "컨텍스트 메뉴 등록" 클릭. 또는 CLI:
-
```powershell
-.\EverythingToJpeg.exe register
-.\EverythingToJpeg.exe unregister
+.\Everything2Everything.exe register
+.\Everything2Everything.exe unregister
```
-> Windows 11 메인 우클릭 메뉴가 아니라 "추가 옵션 표시(Shift+우클릭)" 메뉴에 노출됩니다. 메인 메뉴 노출은 Phase 2에서 IExplorerCommand + MSIX 로 추가 예정.
+> Windows 11 기본 우클릭에서는 **추가 옵션 표시(Shift+우클릭)** 안에 노출됩니다. 메인 메뉴 노출은 MSIX + IExplorerCommand DLL이 필요합니다 — `packaging/BuildAndSign.ps1` 참고.
### 3) 사용
-- 파일 우클릭 → "추가 옵션 표시" → **JPEG로 빠른 변환** 또는 **JPEG로 변환…**
-- 또는 메인 창에 파일/폴더를 끌어다 놓기
+#### 우클릭 카스케이드
+파일 우클릭 → **Everything2Everything으로 변환** → 서브메뉴에서 출력 형식 선택. 각 입력의 매트릭스에 따라 가능한 출력만 표시됩니다.
-## 지원 현황
+| 입력 → 노출되는 서브메뉴 항목 |
+|---|
+| `.png` → JPEG · WebP · AVIF · GIF · TIFF · BMP · 변환… |
+| `.heic` → JPEG · PNG · WebP · AVIF · GIF · TIFF · BMP · 변환… |
+| `.pdf` → JPEG · PNG · WebP · AVIF · TIFF · BMP · 변환… |
+| `.docx` → PDF · JPEG · PNG · WebP · AVIF · TIFF · BMP · 변환… |
+| `.html` → PDF · JPEG · PNG · WebP · AVIF · TIFF · BMP · 변환… |
-| 형식 | 상태 | 참고 |
-|---|---|---|
-| PNG · BMP · JPEG · WebP · AVIF · PSD · TIFF · GIF · RAW(NEF/CR2/CR3/ARW/DNG/RAF/ORF/RW2/SRW/PEF) | ✅ 준비됨 | Magick.NET 14.x |
-| HEIC · HEIF | ✅ 준비됨 | PhotoSauce + libheif 디코드 |
-| PDF | ✅ 준비됨 | PDFtoImage(PDFium) |
-| HTML · HTM | ✅ 준비됨 | WebView2 헤드리스 + CDP `Page.captureScreenshot` 풀페이지 |
-| DOCX · DOC | ⚙ 외부 도구 필요 | Microsoft Word 또는 LibreOffice 자동 감지 |
-| HWP · HWPX | ⚙ 외부 도구 필요 | LibreOffice + [H2Orestart](https://github.com/ebandal/H2Orestart) 확장 |
+"변환…" 항목은 메인 창을 띄워 사이드바에서 출력 형식 ComboBox로 직접 선택할 수 있습니다.
+
+#### 메인 창
+파일을 드래그 & 드롭하거나 Ctrl+O. 사이드바의 **TARGET FORMAT** ComboBox는 **큐의 모든 파일이 변환 가능한 출력의 교집합**만 보여줍니다 (이종 입력을 섞으면 자동 필터링).
+
+#### CLI
+
+```powershell
+# 로 변환 (배치 가능)
+.\Everything2Everything.exe to png photo.heic shot.jpg banner.webp
+.\Everything2Everything.exe to pdf doc.docx report.html
+.\Everything2Everything.exe to webp *.png
+
+# 빠른 변환 (기본 출력 .jpg, 컨텍스트 메뉴 호환)
+.\Everything2Everything.exe quick photo.heic
+
+# 메인 창에서 출력 형식 선택
+.\Everything2Everything.exe dialog photo.heic
+
+# 진단
+.\Everything2Everything.exe diagnose
+```
+
+## 외부 도구 의존성
+
+| 변환 | 필요 도구 |
+|---|---|
+| 모든 이미지 ↔ 이미지, RAW/PSD 디코딩 | (내장) Magick.NET 14 |
+| HEIC/HEIF 디코딩 | (내장) PhotoSauce + libheif |
+| PDF ↔ 이미지 | (내장) PDFtoImage(PDFium) |
+| HTML → 이미지/PDF | Microsoft Edge **WebView2 Runtime** (Win11 기본 포함) |
+| DOCX/DOC → PDF/이미지 | **Microsoft Word** 또는 **LibreOffice** (자동 감지) |
+| HWP/HWPX → PDF/이미지 | **LibreOffice** + [**H2Orestart**](https://github.com/ebandal/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
+- UI: **WPF-UI 4.3** (Win11 Fluent 2)
+- 변환 엔진: Magick.NET, PDFtoImage, PhotoSauce.MagicScaler + Libheif, WebView2 (CDP)
-## 로드맵
+## 아키텍처
-| 단계 | 상태 | 내용 |
-|---|---|---|
-| Phase 1 | ✅ | 레지스트리 컨텍스트 메뉴 (Win11 "추가 옵션 표시"), 핵심 변환, Fluent UI |
-| Phase 2 | ✅ | C++ IExplorerCommand DLL + MSIX Sparse Package |
-| Phase 3 | ✅ | HTML(WebView2), HWP/HWPX(LibreOffice + H2Orestart) 실구현 |
-| Phase 4 | ✅ | **자체 서명 MSIX 자동화** — `packaging/BuildAndSign.ps1` 한 방으로 인증서 생성 + 서명 + 패키지 |
-| Phase 5 | ✅ | GitHub 리모트 + Actions 릴리즈 워크플로 |
-| Phase 6 | ✅ | Past Results 영구 저장 (`%LocalAppData%\EverythingToJpeg\history.jsonl`) |
-| Phase 7 | ✅ | 단축키 (Ctrl+O 추가, Ctrl+Enter 변환, Esc 닫기, F5 새로고침), 코드 정리 |
-| Phase 8 | ✅ | 시작 시 Provider 가용성 자동 체크, 사이드바에 외부 도구 필요 안내 |
+```
+입력 파일 → ProviderRegistry.TryGet(input ext, output ext)
+ ↓
+ (input, output) → IConverterProvider 매트릭스 인덱스
+ ↓
+ provider.ConvertAsync(source, outDir, outExt, options, ...)
+```
+
+핵심 추상화:
+- `ConversionPair(InputExtension, OutputExtension)` — 단방향 쌍
+- `ProviderCapability.SupportedConversions: IReadOnlyList`
+- `ProviderRegistry` — `Dictionary<(input, output), Provider>` 매트릭스 인덱싱 + `OutputsForFile()` 쿼리
+- `ConversionEngine.ConvertOneAsync(source, outputExt, options)` — 동일 입출력 자동 skip + 출력 ext 기반 서브폴더 suffix(`_png/`, `_pdf/`)
+- `ConvertOptions` — 형식별 sub-record (`Jpeg.Quality`, `Webp.Lossless`, `Avif.Speed`, `PdfRender.Dpi`, `HtmlRender.FullPage` 등)
## 키보드 단축키
@@ -80,51 +132,54 @@ dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj `
| Ctrl+Enter | Process Queue (변환 시작) |
| Esc | 창 닫기 |
| F5 | 통계 새로고침 |
-| Active Queue 행 클릭 | 우측 Preview에 즉시 표시 |
-| Past Results 행 클릭 | 원본 파일이 있으면 Preview 표시 |
## 두 가지 사용 방식
-### A) Portable EXE — 가장 가벼움 (Phase 1)
+### A) Portable EXE — 가장 가벼움
- `dotnet publish` 산출물 그대로 사용
-- 우클릭 → **추가 옵션 표시** → "JPEG로 빠른 변환" / "JPEG로 변환…"
+- 우클릭 → **추가 옵션 표시** → "Everything2Everything으로 변환" → 카스케이드 서브메뉴
- 인증서·서명 불필요
-### B) MSIX 패키지 — Win11 메인 메뉴 노출 (Phase 2)
-- `packaging/BuildMsix.ps1` 로 MSIX 빌드
+### B) MSIX 패키지 — Win11 메인 메뉴 노출
+- `packaging/BuildMsix.ps1`로 MSIX 빌드
- 자체 서명 인증서를 `LocalMachine\TrustedPeople`에 임포트 후 사이드로드
-- 우클릭 → 바로 메인 메뉴에 항목 노출
+- 우클릭 → 메인 메뉴 바로 노출 (IExplorerCommand DLL 사용)
- 자세한 절차는 [packaging/README.md](packaging/README.md)
## 프로젝트 구조
```
-everythingToJpeg/
-├── EverythingToJpeg.slnx
+Everything2Everything/
+├── Everything2Everything.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 화면
+ ├── Everything2Everything.Core/ — 변환 엔진, Provider 추상화
+ │ ├── Providers/
+ │ │ ├── ConversionPair.cs — (입력ext, 출력ext) 단방향 쌍
+ │ │ ├── ProviderCapability.cs — SupportedConversions + 입출력 헬퍼
+ │ │ ├── ProviderRegistry.cs — N×M 매트릭스 인덱싱
+ │ │ └── IConverterProvider.cs
+ │ ├── Converters/ — Magick / Heic / Pdf / Docx / Html / Hwpx
+ │ ├── ConversionEngine.cs — outputExt 라우팅 + 서브폴더 suffix
+ │ ├── ConvertOptions.cs — 형식별 sub-options
+ │ └── Everything2EverythingBootstrap.cs
+ └── Everything2Everything.App/ — WPF + CLI 통합 진입점
+ ├── App.xaml(.cs)
+ ├── Cli/CliRouter.cs — verb: to / quick / dialog / register / diagnose
+ ├── Shell/ContextMenuRegistrar.cs — HKCU 카스케이드 등록
+ └── Views/ — Fluent UI 화면
```
-## Provider 전략 (확장 포인트)
+## Provider 확장
-새 형식을 지원하려면 `IConverterProvider`를 구현하고 `EverythingToJpegBootstrap.CreateDefault()`에 등록합니다. `ProviderCapability`에 다음을 명시하세요:
+새 변환 쌍을 추가하려면 `IConverterProvider`를 구현하고 `Everything2EverythingBootstrap.CreateDefault()`에 등록합니다. `ProviderCapability.SupportedConversions`에 (입력ext, 출력ext) 쌍 리스트를 정의하면 `ProviderRegistry`가 매트릭스 인덱스에 자동 편입하며, 컨텍스트 메뉴 카스케이드 + 메인 창 ComboBox에 자동 반영됩니다.
-- `Status` — `Available` / `Preview` / `RequiresExternal` / `ComingSoon` / `Disabled`
-- `Extensions` — 자동 라우팅 + 컨텍스트 메뉴 등록 키
-- `ExternalDependencies` — UI에 자동 노출되는 외부 도구
-- `RoadmapNote` — 사용자에게 보여줄 향후 계획
-
-`ComingSoon` 상태는 메인 창의 "지원 형식" 섹션에 자동 노출되지만 컨텍스트 메뉴 등록에서는 자동 제외됩니다.
+```csharp
+// 매트릭스 헬퍼 — 모든 입력 × 모든 출력 카르테시안 곱
+SupportedConversions: ProviderCapability.PairsFromMatrix(
+ new[] { ".png", ".jpg", ".webp" },
+ new[] { ".png", ".jpg", ".pdf" }),
+```
## 라이선스
-MIT (예정).
+MIT.
diff --git a/packaging/BuildAndSign.ps1 b/packaging/BuildAndSign.ps1
index 2440418..ec84b78 100644
--- a/packaging/BuildAndSign.ps1
+++ b/packaging/BuildAndSign.ps1
@@ -1,20 +1,20 @@
#Requires -Version 5.1
# 한방에 빌드+자체서명: 인증서 자동 생성 → MSIX 빌드 → 서명까지 일관 처리.
# 산출:
-# - packaging/dist/EverythingToJpeg-x64.msix (서명됨)
-# - packaging/EverythingToJpeg-DevCert.pfx (5대 PC 신뢰 등록용)
+# - packaging/dist/Everything2Everything-x64.msix (서명됨)
+# - packaging/Everything2Everything-DevCert.pfx (5대 PC 신뢰 등록용)
[CmdletBinding()]
param(
- [string]$Subject = 'CN=EverythingToJpegDev',
- [string]$Password = 'EverythingToJpegDev',
+ [string]$Subject = 'CN=Everything2EverythingDev',
+ [string]$Password = 'Everything2EverythingDev',
[string]$Configuration = 'Release',
[string]$Platform = 'x64'
)
$ErrorActionPreference = 'Stop'
$packagingDir = $PSScriptRoot
-$pfxPath = Join-Path $packagingDir 'EverythingToJpeg-DevCert.pfx'
+$pfxPath = Join-Path $packagingDir 'Everything2Everything-DevCert.pfx'
$securePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force
# ---- 1) 인증서 ----
@@ -36,7 +36,7 @@ if (-not $existing) {
-CertStoreLocation 'Cert:\CurrentUser\My' `
-HashAlgorithm SHA256 `
-NotAfter (Get-Date).AddYears(5) `
- -FriendlyName 'EverythingToJpeg Dev'
+ -FriendlyName 'Everything2Everything Dev'
}
else {
Write-Host "[1/3] 기존 인증서 재사용 (Thumbprint $($existing.Thumbprint))"
@@ -62,7 +62,7 @@ Write-Host ' 1. PFX 파일을 5대 PC 각각에 복사:'
Write-Host " $pfxPath"
Write-Host ' 2. 각 PC에서 관리자 PowerShell:'
Write-Host ' cd packaging'
-Write-Host " .\Install-EverythingToJpeg.ps1 -PfxPath .\EverythingToJpeg-DevCert.pfx -MsixPath .\dist\EverythingToJpeg-x64.msix"
+Write-Host " .\Install-Everything2Everything.ps1 -PfxPath .\Everything2Everything-DevCert.pfx -MsixPath .\dist\Everything2Everything-x64.msix"
Write-Host ' PFX 비밀번호:' $Password
Write-Host ''
Write-Host ' 3. 우클릭 → JPEG로 빠른 변환 / JPEG로 변환… 이 메인 메뉴에 노출됨.'
diff --git a/packaging/BuildMsix.ps1 b/packaging/BuildMsix.ps1
index 14dd699..2d51377 100644
--- a/packaging/BuildMsix.ps1
+++ b/packaging/BuildMsix.ps1
@@ -25,8 +25,8 @@ $distDir = Join-Path $packagingDir 'dist'
$manifestPath = Join-Path $packagingDir 'Package.appxmanifest'
$assetsSrc = Join-Path $packagingDir 'Assets'
-$appProj = Join-Path $repoRoot 'src\EverythingToJpeg.App\EverythingToJpeg.App.csproj'
-$shellProj = Join-Path $repoRoot 'src\EverythingToJpeg.Shell\EverythingToJpeg.Shell.vcxproj'
+$appProj = Join-Path $repoRoot 'src\Everything2Everything.App\Everything2Everything.App.csproj'
+$shellProj = Join-Path $repoRoot 'src\Everything2Everything.Shell\Everything2Everything.Shell.vcxproj'
function Find-WindowsSdkTool {
param([string]$ToolName)
@@ -91,12 +91,12 @@ if (-not $nuget) {
}
$packagesDir = Join-Path $repoRoot 'packages'
-& $nugetCmd restore (Join-Path $repoRoot 'src\EverythingToJpeg.Shell\packages.config') -PackagesDirectory $packagesDir | Out-Host
+& $nugetCmd restore (Join-Path $repoRoot 'src\Everything2Everything.Shell\packages.config') -PackagesDirectory $packagesDir | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'NuGet restore 실패' }
& $msbuild $shellProj /p:Configuration=$Configuration /p:Platform=$Platform /m /v:minimal | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'Shell build 실패' }
-$shellDll = Join-Path $repoRoot ("src\EverythingToJpeg.Shell\$Platform\$Configuration\EverythingToJpeg.Shell.dll")
+$shellDll = Join-Path $repoRoot ("src\Everything2Everything.Shell\$Platform\$Configuration\Everything2Everything.Shell.dll")
if (-not (Test-Path $shellDll)) { throw "Shell DLL 산출물 없음: $shellDll" }
# ---- 3) Layout 디렉토리 ----
@@ -118,7 +118,7 @@ Copy-Item -Path $manifestPath -Destination (Join-Path $layoutDir 'AppxManifest.x
Write-Host ''
Write-Host '[4/5] makeappx pack'
if (-not (Test-Path $distDir)) { New-Item -ItemType Directory -Path $distDir | Out-Null }
-$msixPath = Join-Path $distDir ("EverythingToJpeg-$($Platform.ToLower()).msix")
+$msixPath = Join-Path $distDir ("Everything2Everything-$($Platform.ToLower()).msix")
if (Test-Path $msixPath) { Remove-Item $msixPath -Force }
& $makeappx pack /d $layoutDir /p $msixPath /o | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'makeappx pack 실패' }
diff --git a/packaging/CreateDevCert.ps1 b/packaging/CreateDevCert.ps1
index 9889cc2..7ca7e52 100644
--- a/packaging/CreateDevCert.ps1
+++ b/packaging/CreateDevCert.ps1
@@ -4,8 +4,8 @@
[CmdletBinding()]
param(
- [string]$Subject = 'CN=EverythingToJpegDev',
- [string]$OutputPfx = (Join-Path $PSScriptRoot 'EverythingToJpeg-DevCert.pfx'),
+ [string]$Subject = 'CN=Everything2EverythingDev',
+ [string]$OutputPfx = (Join-Path $PSScriptRoot 'Everything2Everything-DevCert.pfx'),
[securestring]$Password
)
@@ -28,7 +28,7 @@ $cert = New-SelfSignedCertificate `
-CertStoreLocation 'Cert:\CurrentUser\My' `
-HashAlgorithm SHA256 `
-NotAfter (Get-Date).AddYears(5) `
- -FriendlyName 'EverythingToJpeg Dev'
+ -FriendlyName 'Everything2Everything Dev'
Write-Host "Thumbprint: $($cert.Thumbprint)"
Write-Host "Exporting PFX: $OutputPfx"
diff --git a/packaging/GenerateAssets.ps1 b/packaging/GenerateAssets.ps1
index 6813a8f..1ed5d21 100644
--- a/packaging/GenerateAssets.ps1
+++ b/packaging/GenerateAssets.ps1
@@ -49,8 +49,8 @@ function New-LogoPng {
}
Write-Host 'Generating placeholder logos…'
-New-LogoPng -Width 50 -Height 50 -Path (Join-Path $assetsDir 'StoreLogo.png') -Label 'E2J'
-New-LogoPng -Width 44 -Height 44 -Path (Join-Path $assetsDir 'Square44x44Logo.png') -Label 'E2J'
-New-LogoPng -Width 150 -Height 150 -Path (Join-Path $assetsDir 'Square150x150Logo.png')-Label 'E2J'
-New-LogoPng -Width 310 -Height 150 -Path (Join-Path $assetsDir 'Wide310x150Logo.png') -Label 'EverythingToJpeg'
+New-LogoPng -Width 50 -Height 50 -Path (Join-Path $assetsDir 'StoreLogo.png') -Label 'E2E'
+New-LogoPng -Width 44 -Height 44 -Path (Join-Path $assetsDir 'Square44x44Logo.png') -Label 'E2E'
+New-LogoPng -Width 150 -Height 150 -Path (Join-Path $assetsDir 'Square150x150Logo.png')-Label 'E2E'
+New-LogoPng -Width 310 -Height 150 -Path (Join-Path $assetsDir 'Wide310x150Logo.png') -Label 'Everything2Everything'
Write-Host 'Done.'
diff --git a/packaging/Install-EverythingToJpeg.ps1 b/packaging/Install-Everything2Everything.ps1
similarity index 88%
rename from packaging/Install-EverythingToJpeg.ps1
rename to packaging/Install-Everything2Everything.ps1
index df2e633..df01884 100644
--- a/packaging/Install-EverythingToJpeg.ps1
+++ b/packaging/Install-Everything2Everything.ps1
@@ -2,14 +2,14 @@
#Requires -RunAsAdministrator
# 5대 PC에서 MSIX 사이드로드 설치 — 1회 셋업 스크립트.
# 사용법:
-# PowerShell (관리자) > .\Install-EverythingToJpeg.ps1 -PfxPath .\EverythingToJpeg-DevCert.pfx -MsixPath .\EverythingToJpeg.msix
+# PowerShell (관리자) > .\Install-Everything2Everything.ps1 -PfxPath .\Everything2Everything-DevCert.pfx -MsixPath .\Everything2Everything.msix
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$PfxPath,
[Parameter(Mandatory)] [string]$MsixPath,
[securestring]$PfxPassword,
- [string]$Password = 'EverythingToJpegDev'
+ [string]$Password = 'Everything2EverythingDev'
)
$ErrorActionPreference = 'Stop'
diff --git a/packaging/Package.appxmanifest b/packaging/Package.appxmanifest
index f79706a..67e4b52 100644
--- a/packaging/Package.appxmanifest
+++ b/packaging/Package.appxmanifest
@@ -10,13 +10,13 @@
IgnorableNamespaces="uap rescap desktop desktop4 desktop5 com">
- EverythingToJpeg
+ Everything2Everything
YunChan
Assets\StoreLogo.png
@@ -31,12 +31,12 @@
-
@@ -47,12 +47,12 @@
-
+
diff --git a/packaging/README.md b/packaging/README.md
index 92b9e26..af6a6b4 100644
--- a/packaging/README.md
+++ b/packaging/README.md
@@ -1,6 +1,6 @@
-# Phase 2 — MSIX 패키징
+# MSIX 패키징
-Win11 메인 우클릭 메뉴에 "JPEG로 빠른 변환" / "JPEG로 변환…"을 띄우는 정공법.
+Win11 메인 우클릭 메뉴에 "Everything2Everything: 빠른 변환 (JPEG)" / "Everything2Everything: 변환…"을 띄우는 정공법. 레지스트리 기반 카스케이드 메뉴는 [Portable EXE 방식](../README.md#a-portable-exe--가장-가벼움)을 참고하세요.
## 구성
@@ -11,18 +11,18 @@ packaging/
├── GenerateAssets.ps1 — placeholder PNG 일괄 생성
├── CreateDevCert.ps1 — 자체 서명 코드사이닝 인증서 생성 + PFX export
├── BuildMsix.ps1 — .NET publish + C++ DLL 빌드 + makeappx + (선택) signtool
-└── Install-EverythingToJpeg.ps1 — 5대 PC 1회 설치 스크립트
+└── Install-Everything2Everything.ps1 — 5대 PC 1회 설치 스크립트
```
-C++ Shell DLL은 `src/EverythingToJpeg.Shell/` 에 있고 `BuildMsix.ps1` 안에서 자동 빌드됩니다.
+C++ Shell DLL은 `src/Everything2Everything.Shell/` 에 있고 `BuildMsix.ps1` 안에서 자동 빌드됩니다.
## 1회: 자체 서명 인증서 만들기
```powershell
cd packaging
.\CreateDevCert.ps1
-# Subject 기본값: CN=EverythingToJpegDev (Package.appxmanifest의 Publisher와 일치)
-# 비밀번호 입력 → EverythingToJpeg-DevCert.pfx 생성
+# Subject 기본값: CN=Everything2EverythingDev (Package.appxmanifest의 Publisher와 일치)
+# 비밀번호 입력 → Everything2Everything-DevCert.pfx 생성
```
출력된 Thumbprint를 `BuildMsix.ps1 -CertThumbprint <값>` 으로 사용하거나, PFX 파일을 5대 PC에 복사해서 설치 시 사용합니다.
@@ -32,13 +32,13 @@ cd packaging
### 미서명 (Phase 1 그대로 사용 가능, 메인 메뉴 노출은 안 됨)
```powershell
.\BuildMsix.ps1
-# 산출: packaging/dist/EverythingToJpeg-x64.msix
+# 산출: packaging/dist/Everything2Everything-x64.msix
```
### 서명
```powershell
# 방법 1: PFX 사용
-.\BuildMsix.ps1 -Sign -PfxPath .\EverythingToJpeg-DevCert.pfx
+.\BuildMsix.ps1 -Sign -PfxPath .\Everything2Everything-DevCert.pfx
# 방법 2: 인증서 저장소의 Thumbprint
.\BuildMsix.ps1 -Sign -CertThumbprint AABBCCDD...
@@ -47,9 +47,9 @@ cd packaging
## 5대 PC 설치 (관리자 PowerShell)
```powershell
-.\Install-EverythingToJpeg.ps1 `
- -PfxPath .\EverythingToJpeg-DevCert.pfx `
- -MsixPath .\EverythingToJpeg-x64.msix
+.\Install-Everything2Everything.ps1 `
+ -PfxPath .\Everything2Everything-DevCert.pfx `
+ -MsixPath .\Everything2Everything-x64.msix
```
스크립트가 자동으로:
@@ -64,7 +64,7 @@ cd packaging
자체 서명 만들기조차 귀찮을 때:
```powershell
# 개발자 모드 켜기: 설정 → 개인 정보 및 보안 → 개발자용 → 켜기
-Add-AppxPackage -AllowUnsigned -Path .\EverythingToJpeg-x64.msix
+Add-AppxPackage -AllowUnsigned -Path .\Everything2Everything-x64.msix
```
> Win11 24H2부터 `-AllowUnsigned` 지원. 이전 버전은 자체 서명 권장.
diff --git a/src/EverythingToJpeg.App/App.xaml b/src/Everything2Everything.App/App.xaml
similarity index 96%
rename from src/EverythingToJpeg.App/App.xaml
rename to src/Everything2Everything.App/App.xaml
index 7516c02..9ab7715 100644
--- a/src/EverythingToJpeg.App/App.xaml
+++ b/src/Everything2Everything.App/App.xaml
@@ -1,8 +1,8 @@
-
diff --git a/src/EverythingToJpeg.App/App.xaml.cs b/src/Everything2Everything.App/App.xaml.cs
similarity index 85%
rename from src/EverythingToJpeg.App/App.xaml.cs
rename to src/Everything2Everything.App/App.xaml.cs
index 6ed2c32..6fb04b5 100644
--- a/src/EverythingToJpeg.App/App.xaml.cs
+++ b/src/Everything2Everything.App/App.xaml.cs
@@ -1,13 +1,13 @@
using System.Windows;
-using EverythingToJpeg.App.Cli;
-using EverythingToJpeg.App.Views;
-using EverythingToJpeg.Core;
+using Everything2Everything.App.Cli;
+using Everything2Everything.App.Views;
+using Everything2Everything.Core;
-namespace EverythingToJpeg.App;
+namespace Everything2Everything.App;
public partial class App : Application
{
- public ConversionEngine Engine { get; } = EverythingToJpegBootstrap.CreateDefault();
+ public ConversionEngine Engine { get; } = Everything2EverythingBootstrap.CreateDefault();
protected override async void OnStartup(StartupEventArgs e)
{
@@ -39,12 +39,12 @@ public partial class App : Application
case CliRouter.Mode.Quick:
if (parsed.Files.Count == 0)
{
- MessageBox.Show("변환할 파일이 없습니다.", "EverythingToJpeg",
+ MessageBox.Show("변환할 파일이 없습니다.", "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Information);
Shutdown(1);
return;
}
- await RunQuickAsync(parsed.Files);
+ await RunQuickAsync(parsed.Files, parsed.OutputExtension ?? ".jpg");
return;
case CliRouter.Mode.Dialog:
@@ -79,11 +79,11 @@ public partial class App : Application
window.Show();
}
- private async Task RunQuickAsync(IReadOnlyList files)
+ private async Task RunQuickAsync(IReadOnlyList files, string outputExtension)
{
- var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_quick.log");
+ var logPath = Path.Combine(Path.GetTempPath(), "Everything2Everything_quick.log");
var log = new System.Text.StringBuilder();
- log.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Quick start, {files.Count} file(s)");
+ log.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Quick start → {outputExtension}, {files.Count} file(s)");
foreach (var f in files) log.AppendLine($" src: {f}");
var progress = new QuickProgressWindow(files.Count);
@@ -93,7 +93,7 @@ public partial class App : Application
{
var options = ConvertOptions.Quick();
var reporter = new Progress(p => progress.Report(p));
- var results = await Engine.ConvertManyAsync(files, options, reporter);
+ var results = await Engine.ConvertManyAsync(files, ".jpg", options, reporter);
foreach (var r in results)
{
@@ -110,7 +110,7 @@ public partial class App : Application
log.AppendLine($" EXCEPTION {ex.GetType().Name}: {ex.Message}");
log.AppendLine(ex.ToString());
try { progress.Close(); } catch { }
- MessageBox.Show($"변환 중 오류: {ex.Message}\n\n로그: {logPath}", "EverythingToJpeg",
+ MessageBox.Show($"변환 중 오류: {ex.Message}\n\n로그: {logPath}", "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
@@ -121,7 +121,7 @@ public partial class App : Application
private static void WireGlobalExceptionLogging()
{
- var path = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_unhandled.log");
+ var path = Path.Combine(Path.GetTempPath(), "Everything2Everything_unhandled.log");
void Append(string source, Exception? ex)
{
@@ -141,7 +141,7 @@ public partial class App : Application
Append("Application.DispatcherUnhandledException", e.Exception);
MessageBox.Show(
"예기치 못한 오류:\n\n" + e.Exception.Message + "\n\n로그: " + path,
- "EverythingToJpeg",
+ "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Error);
e.Handled = true;
};
diff --git a/src/EverythingToJpeg.App/AssemblyInfo.cs b/src/Everything2Everything.App/AssemblyInfo.cs
similarity index 100%
rename from src/EverythingToJpeg.App/AssemblyInfo.cs
rename to src/Everything2Everything.App/AssemblyInfo.cs
diff --git a/src/EverythingToJpeg.App/Cli/CliRouter.cs b/src/Everything2Everything.App/Cli/CliRouter.cs
similarity index 64%
rename from src/EverythingToJpeg.App/Cli/CliRouter.cs
rename to src/Everything2Everything.App/Cli/CliRouter.cs
index 613be16..4f39092 100644
--- a/src/EverythingToJpeg.App/Cli/CliRouter.cs
+++ b/src/Everything2Everything.App/Cli/CliRouter.cs
@@ -1,7 +1,7 @@
-using EverythingToJpeg.App.Shell;
-using EverythingToJpeg.Core;
+using Everything2Everything.App.Shell;
+using Everything2Everything.Core;
-namespace EverythingToJpeg.App.Cli;
+namespace Everything2Everything.App.Cli;
internal static class CliRouter
{
@@ -16,7 +16,7 @@ internal static class CliRouter
Help,
}
- public sealed record ParsedArgs(Mode Mode, IReadOnlyList Files);
+ public sealed record ParsedArgs(Mode Mode, IReadOnlyList Files, string? OutputExtension = null);
public static ParsedArgs Parse(string[] args)
{
@@ -26,9 +26,18 @@ internal static class CliRouter
var verb = args[0].Trim().ToLowerInvariant();
var rest = args.Skip(1).Where(a => !string.IsNullOrWhiteSpace(a)).ToList();
+ if (verb == "to")
+ {
+ if (rest.Count < 2)
+ return new ParsedArgs(Mode.Help, Array.Empty());
+ var outputExt = NormalizeExt(rest[0]);
+ var files = ExpandFiles(rest.Skip(1));
+ return new ParsedArgs(Mode.Quick, files, outputExt);
+ }
+
return verb switch
{
- "quick" => new ParsedArgs(Mode.Quick, ExpandFiles(rest)),
+ "quick" => new ParsedArgs(Mode.Quick, ExpandFiles(rest), ".jpg"),
"dialog" => new ParsedArgs(Mode.Dialog, ExpandFiles(rest)),
"register" => new ParsedArgs(Mode.Register, Array.Empty()),
"unregister" => new ParsedArgs(Mode.Unregister, Array.Empty()),
@@ -39,6 +48,12 @@ internal static class CliRouter
};
}
+ private static string NormalizeExt(string ext)
+ {
+ var trimmed = ext.Trim().ToLowerInvariant();
+ return trimmed.StartsWith('.') ? trimmed : "." + trimmed;
+ }
+
private static IReadOnlyList ExpandFiles(IEnumerable raw)
{
var list = new List();
@@ -61,37 +76,41 @@ internal static class CliRouter
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 engine = Everything2EverythingBootstrap.CreateDefault();
+ var inputs = string.Join(", ",
+ engine.Providers.Implemented.SelectMany(p => p.Capability.InputExtensions).Distinct().OrderBy(e => e));
+ var outputs = string.Join(", ",
+ engine.Providers.Implemented.SelectMany(p => p.Capability.OutputExtensions).Distinct().OrderBy(e => e));
var coming = string.Join(", ",
- engine.Providers.ComingSoon.SelectMany(p => p.Capability.Extensions).Distinct().OrderBy(e => e));
+ engine.Providers.ComingSoon.SelectMany(p => p.Capability.InputExtensions).Distinct().OrderBy(e => e));
return $"""
- EverythingToJpeg — 모든 것을 JPEG로
+ Everything2Everything — 모든 것을 모든 것으로 (양방향 변환)
사용:
- EverythingToJpeg.exe quick <파일들...> 빠른 변환 (다이얼로그 없이 즉시)
- EverythingToJpeg.exe dialog <파일들...> 상세 옵션 다이얼로그 표시
- EverythingToJpeg.exe register 컨텍스트 메뉴 등록 (현재 사용자)
- EverythingToJpeg.exe unregister 컨텍스트 메뉴 해제
- EverythingToJpeg.exe diagnose 지원 형식·외부 도구 진단
- EverythingToJpeg.exe 메인 창 표시
+ Everything2Everything.exe to <파일들...> 로 변환 (예: to png a.jpg b.heic)
+ Everything2Everything.exe quick <파일들...> 빠른 변환 (기본 출력 .jpg)
+ Everything2Everything.exe dialog <파일들...> 메인 창에서 출력 형식 선택
+ Everything2Everything.exe register 컨텍스트 메뉴 등록 (현재 사용자)
+ Everything2Everything.exe unregister 컨텍스트 메뉴 해제
+ Everything2Everything.exe diagnose 지원 매트릭스·외부 도구 진단
+ Everything2Everything.exe 메인 창 표시
- 지원 (지금): {supported}
- 지원 예정 : {coming}
+ 입력 형식: {inputs}
+ 출력 형식: {outputs}
+ 지원 예정: {coming}
""";
}
public static int RunRegister(bool register)
{
- var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_register.log");
+ var logPath = Path.Combine(Path.GetTempPath(), "Everything2Everything_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();
+ var engine = Everything2EverythingBootstrap.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)
diff --git a/src/EverythingToJpeg.App/Cli/ConsoleHelper.cs b/src/Everything2Everything.App/Cli/ConsoleHelper.cs
similarity index 92%
rename from src/EverythingToJpeg.App/Cli/ConsoleHelper.cs
rename to src/Everything2Everything.App/Cli/ConsoleHelper.cs
index 345f187..74d564e 100644
--- a/src/EverythingToJpeg.App/Cli/ConsoleHelper.cs
+++ b/src/Everything2Everything.App/Cli/ConsoleHelper.cs
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
-namespace EverythingToJpeg.App.Cli;
+namespace Everything2Everything.App.Cli;
internal static class ConsoleHelper
{
diff --git a/src/EverythingToJpeg.App/EverythingToJpeg.App.csproj b/src/Everything2Everything.App/Everything2Everything.App.csproj
similarity index 77%
rename from src/EverythingToJpeg.App/EverythingToJpeg.App.csproj
rename to src/Everything2Everything.App/Everything2Everything.App.csproj
index b68e912..8aba88f 100644
--- a/src/EverythingToJpeg.App/EverythingToJpeg.App.csproj
+++ b/src/Everything2Everything.App/Everything2Everything.App.csproj
@@ -6,8 +6,8 @@
enable
enable
true
- EverythingToJpeg
- EverythingToJpeg.App
+ Everything2Everything
+ Everything2Everything.App
app.manifest
10.0.17763.0
10.0.17763.0
@@ -15,7 +15,7 @@
-
+
diff --git a/src/EverythingToJpeg.App/GlobalUsings.cs b/src/Everything2Everything.App/GlobalUsings.cs
similarity index 100%
rename from src/EverythingToJpeg.App/GlobalUsings.cs
rename to src/Everything2Everything.App/GlobalUsings.cs
diff --git a/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs b/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs
new file mode 100644
index 0000000..d63e7b2
--- /dev/null
+++ b/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs
@@ -0,0 +1,177 @@
+using Everything2Everything.Core;
+using Everything2Everything.Core.Providers;
+using Microsoft.Win32;
+
+namespace Everything2Everything.App.Shell;
+
+internal static class ContextMenuRegistrar
+{
+ private const string MainVerb = "Everything2Everything";
+ private const string MainLabel = "Everything2Everything으로 변환";
+
+ private const string SubmenuKeyPrefix = "Everything2Everything.SubMenu.";
+
+ private static readonly (string Ext, string Label, string SortPrefix)[] PopularOutputs =
+ {
+ (".jpg", "JPEG (.jpg)", "01"),
+ (".png", "PNG (.png)", "02"),
+ (".webp", "WebP (.webp)", "03"),
+ (".pdf", "PDF (.pdf)", "04"),
+ (".txt", "텍스트 (.txt) — OCR", "05"),
+ (".docx", "Word (.docx) — OCR", "06"),
+ (".avif", "AVIF (.avif)", "07"),
+ (".gif", "GIF (.gif)", "08"),
+ (".tif", "TIFF (.tif)", "09"),
+ (".bmp", "BMP (.bmp)", "10"),
+ };
+
+ public static void Register(ConversionEngine engine)
+ {
+ var exe = GetAppExecutablePath();
+ var icon = exe + ",0";
+
+ foreach (var ext in CollectInputExtensions(engine))
+ {
+ var outputs = engine.Providers.OutputsForInput(ext)
+ .Select(o => o.ToLowerInvariant())
+ .ToHashSet();
+
+ var availableOutputs = PopularOutputs
+ .Where(p => outputs.Contains(p.Ext) && !string.Equals(p.Ext, ext, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (availableOutputs.Count == 0) continue;
+
+ WriteCascade(ext, exe, icon, availableOutputs);
+ }
+
+ NotifyShell();
+ }
+
+ public static void Unregister(ConversionEngine engine)
+ {
+ foreach (var ext in CollectInputExtensions(engine))
+ {
+ DeleteVerb(ext, MainVerb);
+ DeleteVerb(ext, "Everything2Everything.Quick");
+ DeleteVerb(ext, "Everything2Everything.Dialog");
+ DeleteSubmenuTree(ext);
+ }
+ NotifyShell();
+ }
+
+ private static IEnumerable CollectInputExtensions(ConversionEngine engine)
+ {
+ return engine.Providers.Implemented
+ .Where(p => p.Capability.CanRegisterContextMenu)
+ .SelectMany(p => p.Capability.InputExtensions)
+ .Select(e => e.StartsWith('.') ? e : "." + e)
+ .Select(e => e.ToLowerInvariant())
+ .Distinct();
+ }
+
+ private static void WriteCascade(
+ string ext,
+ string exe,
+ string icon,
+ IReadOnlyList<(string Ext, string Label, string SortPrefix)> availableOutputs)
+ {
+ var submenuKeyName = SubmenuKeyPrefix + ext.TrimStart('.');
+
+ var verbPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{MainVerb}";
+ using (var verbKey = Registry.CurrentUser.CreateSubKey(verbPath, writable: true)
+ ?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {verbPath}"))
+ {
+ verbKey.SetValue(null, MainLabel, RegistryValueKind.String);
+ verbKey.SetValue("MUIVerb", MainLabel, RegistryValueKind.String);
+ verbKey.SetValue("Icon", icon, RegistryValueKind.String);
+ verbKey.SetValue("SubCommands", "", RegistryValueKind.String);
+ verbKey.SetValue("ExtendedSubCommandsKey", submenuKeyName, RegistryValueKind.String);
+ try { verbKey.DeleteSubKeyTree("command", throwOnMissingSubKey: false); } catch { }
+ }
+
+ var submenuShellPath = $@"Software\Classes\{submenuKeyName}\shell";
+ using (var existing = Registry.CurrentUser.OpenSubKey(submenuShellPath, writable: true))
+ {
+ if (existing is not null)
+ {
+ foreach (var name in existing.GetSubKeyNames())
+ {
+ try { existing.DeleteSubKeyTree(name, throwOnMissingSubKey: false); } catch { }
+ }
+ }
+ }
+
+ foreach (var (outExt, outLabel, sortPrefix) in availableOutputs)
+ {
+ var subVerbName = $"{sortPrefix}_{outExt.TrimStart('.')}";
+ var cliExt = outExt.TrimStart('.');
+ WriteSubmenuItem(submenuKeyName, subVerbName, outLabel, icon,
+ $"\"{exe}\" to {cliExt} \"%1\"");
+ }
+
+ WriteSubmenuItem(submenuKeyName, "98_dialog", "변환… (옵션 선택)", icon,
+ $"\"{exe}\" dialog \"%1\"");
+ }
+
+ private static void WriteSubmenuItem(string submenuKeyName, string verbName, string label, string icon, string command)
+ {
+ var path = $@"Software\Classes\{submenuKeyName}\shell\{verbName}";
+ using var key = Registry.CurrentUser.CreateSubKey(path, writable: true)
+ ?? throw new InvalidOperationException($"서브메뉴 키 생성 실패: {path}");
+
+ key.SetValue(null, label, RegistryValueKind.String);
+ key.SetValue("MUIVerb", label, RegistryValueKind.String);
+ key.SetValue("Icon", icon, RegistryValueKind.String);
+
+ using var commandKey = key.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 void DeleteSubmenuTree(string ext)
+ {
+ var submenuKeyName = SubmenuKeyPrefix + ext.TrimStart('.');
+ var path = $@"Software\Classes\{submenuKeyName}";
+ try
+ {
+ Registry.CurrentUser.DeleteSubKeyTree(path, 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 + "Everything2Everything.exe";
+ }
+
+ private static void NotifyShell()
+ {
+ try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); }
+ catch { }
+ }
+
+ private static class NativeMethods
+ {
+ [System.Runtime.InteropServices.DllImport("shell32.dll")]
+ public static extern void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2);
+ }
+}
diff --git a/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml b/src/Everything2Everything.App/Views/DiagnoseWindow.xaml
similarity index 95%
rename from src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml
rename to src/Everything2Everything.App/Views/DiagnoseWindow.xaml
index d5b95ec..29d1e71 100644
--- a/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml
+++ b/src/Everything2Everything.App/Views/DiagnoseWindow.xaml
@@ -1,4 +1,4 @@
-
+
diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml b/src/Everything2Everything.App/Views/MainWindow.xaml
similarity index 96%
rename from src/EverythingToJpeg.App/Views/MainWindow.xaml
rename to src/Everything2Everything.App/Views/MainWindow.xaml
index ed650c5..48f6a48 100644
--- a/src/EverythingToJpeg.App/Views/MainWindow.xaml
+++ b/src/Everything2Everything.App/Views/MainWindow.xaml
@@ -1,4 +1,4 @@
-
-
-
-
+
+
+
+
+
+
-
-
+
+
+
-
+
-
+
+ ToolTip="비워두면 원본 폴더 옆 서브폴더에 저장"/>
-
+ Text="비워두면 원본 옆 _jpg 폴더에 저장됩니다"/>
diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs b/src/Everything2Everything.App/Views/MainWindow.xaml.cs
similarity index 81%
rename from src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
rename to src/Everything2Everything.App/Views/MainWindow.xaml.cs
index 7e88473..26b8f7a 100644
--- a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
+++ b/src/Everything2Everything.App/Views/MainWindow.xaml.cs
@@ -8,10 +8,10 @@ using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
-using EverythingToJpeg.App.Shell;
-using EverythingToJpeg.Core;
+using Everything2Everything.App.Shell;
+using Everything2Everything.Core;
-namespace EverythingToJpeg.App.Views;
+namespace Everything2Everything.App.Views;
public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
{
@@ -20,6 +20,8 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
private CancellationTokenSource? _cts;
private NameCollision _conflictRule = NameCollision.AppendNumber;
+ public string? SelectedOutputExtension { get; set; } = ".jpg";
+
public ICommand AddFilesCommand { get; }
public ICommand ProcessQueueCommand { get; }
public ICommand CloseCommand { get; }
@@ -40,6 +42,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
ActiveQueueList.ItemsSource = _activeQueue;
PastResultsList.ItemsSource = _pastResults;
+ InitializeOutputFormats();
LoadHistory();
UpdateBadges();
UpdateProcessQueueButton();
@@ -66,7 +69,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
var notReady = new List();
foreach (var p in engine.Providers.All)
{
- if (p.Capability.Status == EverythingToJpeg.Core.Providers.ProviderStatus.RequiresExternal)
+ if (p.Capability.Status == Everything2Everything.Core.Providers.ProviderStatus.RequiresExternal)
{
var availability = await p.CheckAvailabilityAsync();
if (!availability.IsReady)
@@ -169,6 +172,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
UpdateBadges();
UpdateProcessQueueButton();
UpdateActiveQueueVisibility();
+ RefreshAvailableOutputFormats();
if (wasEmpty && _activeQueue.Count > 0 && _selectedPreviewItem is null)
{
@@ -185,6 +189,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
UpdateBadges();
UpdateProcessQueueButton();
UpdateActiveQueueVisibility();
+ RefreshAvailableOutputFormats();
}
}
@@ -255,9 +260,11 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
{
var opts = new ConvertOptions
{
- Quality = (int)QualitySlider.Value,
OnCollision = _conflictRule,
};
+ opts.Jpeg.Quality = (int)QualitySlider.Value;
+ opts.Webp.Quality = (int)QualitySlider.Value;
+ opts.Avif.Quality = Math.Clamp((int)QualitySlider.Value - 30, 1, 100);
var custom = OutputPathTextBox.Text?.Trim();
if (!string.IsNullOrEmpty(custom))
@@ -421,7 +428,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
{
if (_pastResults.Count == 0)
{
- MessageBox.Show(this, "저장할 이력이 없습니다.", "EverythingToJpeg",
+ MessageBox.Show(this, "저장할 이력이 없습니다.", "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
@@ -429,7 +436,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
var dlg = new Microsoft.Win32.SaveFileDialog
{
Title = "Export Log",
- FileName = $"EverythingToJpeg-log-{DateTime.Now:yyyyMMdd-HHmmss}.csv",
+ FileName = $"Everything2Everything-log-{DateTime.Now:yyyyMMdd-HHmmss}.csv",
DefaultExt = ".csv",
Filter = "CSV (*.csv)|*.csv|JSON (*.json)|*.json",
};
@@ -442,12 +449,12 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
else
ExportCsv(dlg.FileName);
- MessageBox.Show(this, "저장되었습니다:\n" + dlg.FileName, "EverythingToJpeg",
+ MessageBox.Show(this, "저장되었습니다:\n" + dlg.FileName, "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
- MessageBox.Show(this, "저장 중 오류: " + ex.Message, "EverythingToJpeg",
+ MessageBox.Show(this, "저장 중 오류: " + ex.Message, "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@@ -525,7 +532,8 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
try
{
var sources = snapshot.Select(s => s.SourcePath).ToList();
- var results = await engine.ConvertManyAsync(sources, options, reporter, _cts.Token);
+ var outputExt = SelectedOutputExtension ?? ".jpg";
+ var results = await engine.ConvertManyAsync(sources, outputExt, options, reporter, _cts.Token);
foreach (var (item, result) in snapshot.Zip(results))
{
@@ -552,7 +560,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
catch (OperationCanceledException) { }
catch (Exception ex)
{
- MessageBox.Show(this, "변환 중 오류: " + ex.Message, "EverythingToJpeg",
+ MessageBox.Show(this, "변환 중 오류: " + ex.Message, "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
@@ -674,11 +682,11 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
ContextMenuRegistrar.Register(((App)Application.Current).Engine);
MessageBox.Show(this,
"컨텍스트 메뉴를 등록했습니다.\n파일 우클릭 → \"추가 옵션 표시\" 또는 \"JPEG로 빠른 변환/변환…\".",
- "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Information);
+ "Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
- MessageBox.Show(this, "등록 중 오류: " + ex.Message, "EverythingToJpeg",
+ MessageBox.Show(this, "등록 중 오류: " + ex.Message, "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@@ -694,12 +702,16 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
if (TabActiveBtn.IsChecked == true)
{
_activeQueue.Clear();
+ UpdateBadges();
+ UpdateProcessQueueButton();
+ UpdateActiveQueueVisibility();
+ RefreshAvailableOutputFormats();
}
else if (TabPastBtn.IsChecked == true)
{
var confirm = MessageBox.Show(this,
"Past Results 전체를 삭제하시겠습니까?\n영구 저장된 이력도 함께 삭제됩니다.",
- "EverythingToJpeg",
+ "Everything2Everything",
MessageBoxButton.OKCancel, MessageBoxImage.Question);
if (confirm != MessageBoxResult.OK) return;
@@ -738,6 +750,152 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
while (size >= 1024 && unit < units.Length - 1) { size /= 1024; unit++; }
return $"{size:0.#} {units[unit]}";
}
+
+ // ========================================================================
+ // Output format selection (피보팅: 양방향 매트릭스)
+ // ========================================================================
+
+ private static readonly OutputFormatInfo[] AllFormats =
+ {
+ new(".jpg", "JPEG", "JPG", "FsFmtJpg"),
+ new(".png", "PNG", "PNG", "FsFmtPng"),
+ new(".webp", "WebP", "WEBP", "FsFmtWebp"),
+ new(".avif", "AVIF", "AVIF", "FsFmtAvif"),
+ new(".bmp", "BMP", "BMP", "FsFmtBmp"),
+ new(".tif", "TIFF", "TIF", "FsFmtTiff"),
+ new(".gif", "GIF", "GIF", "FsFmtGif"),
+ new(".pdf", "PDF", "PDF", "FsFmtPdf"),
+ new(".txt", "텍스트 (OCR)", "TXT", "FsFmtOther"),
+ new(".docx", "Word (OCR)", "DOCX", "FsFmtDocx"),
+ };
+
+ private bool _suppressFormatChanged;
+
+ private void InitializeOutputFormats()
+ {
+ RefreshAvailableOutputFormats();
+ }
+
+ private void RefreshAvailableOutputFormats()
+ {
+ if (OutputFormatCombo is null) return;
+
+ var engine = ((App)Application.Current).Engine;
+ IReadOnlyCollection available;
+
+ if (_activeQueue.Count == 0)
+ {
+ available = engine.Providers.AllOutputExtensions;
+ }
+ else
+ {
+ HashSet? intersection = null;
+ foreach (var item in _activeQueue)
+ {
+ var outs = engine.Providers.OutputsForFile(item.SourcePath);
+ var outSet = new HashSet(outs, StringComparer.OrdinalIgnoreCase);
+ if (intersection is null) intersection = outSet;
+ else intersection.IntersectWith(outSet);
+ }
+ available = intersection ?? new HashSet(StringComparer.OrdinalIgnoreCase);
+ }
+
+ var visible = AllFormats.Where(f => available.Contains(f.Extension)).ToList();
+ if (visible.Count == 0)
+ visible = AllFormats.Where(f => f.Extension == ".jpg").ToList();
+
+ var keepExt = SelectedOutputExtension;
+ if (keepExt is null || !visible.Any(v => string.Equals(v.Extension, keepExt, StringComparison.OrdinalIgnoreCase)))
+ keepExt = visible[0].Extension;
+
+ _suppressFormatChanged = true;
+ try
+ {
+ OutputFormatCombo.Items.Clear();
+ foreach (var f in visible)
+ {
+ OutputFormatCombo.Items.Add(new ComboBoxItem
+ {
+ Content = $"{f.DisplayName} ({f.Extension})",
+ Tag = f.Extension,
+ });
+ }
+ for (var i = 0; i < OutputFormatCombo.Items.Count; i++)
+ {
+ if (((ComboBoxItem)OutputFormatCombo.Items[i]!).Tag is string tag
+ && string.Equals(tag, keepExt, StringComparison.OrdinalIgnoreCase))
+ {
+ OutputFormatCombo.SelectedIndex = i;
+ break;
+ }
+ }
+ }
+ finally
+ {
+ _suppressFormatChanged = false;
+ }
+
+ SelectedOutputExtension = keepExt;
+ UpdateOutputFormatBadge(keepExt);
+ UpdateQualityPanelForFormat(keepExt);
+ UpdateOutputDestHint(keepExt);
+
+ if (OutputFormatHint is not null)
+ {
+ OutputFormatHint.Text = _activeQueue.Count == 0
+ ? "큐에 파일을 추가하면 변환 가능한 형식으로 자동 필터링됩니다"
+ : $"큐의 모든 파일이 변환 가능한 형식 ({visible.Count}개)";
+ }
+ }
+
+ private void OnOutputFormatChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_suppressFormatChanged) return;
+ if (OutputFormatCombo.SelectedItem is ComboBoxItem item && item.Tag is string ext)
+ {
+ SelectedOutputExtension = ext;
+ UpdateOutputFormatBadge(ext);
+ UpdateQualityPanelForFormat(ext);
+ UpdateOutputDestHint(ext);
+ }
+ }
+
+ private void UpdateOutputFormatBadge(string? extension)
+ {
+ if (OutputFormatBadge is null || OutputFormatBadgeText is null) return;
+ var info = AllFormats.FirstOrDefault(f =>
+ string.Equals(f.Extension, extension, StringComparison.OrdinalIgnoreCase));
+ if (info is null) return;
+
+ OutputFormatBadgeText.Text = info.BadgeText;
+ var resource = TryFindResource(info.ColorResource);
+ if (resource is System.Windows.Media.Brush brush)
+ OutputFormatBadge.Background = brush;
+ }
+
+ private void UpdateQualityPanelForFormat(string? extension)
+ {
+ if (QualityPanel is null || QualityLabelText is null) return;
+ var ext = extension?.ToLowerInvariant();
+ var supportsQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif";
+ QualityPanel.Visibility = supportsQuality ? Visibility.Visible : Visibility.Collapsed;
+ QualityLabelText.Text = ext switch
+ {
+ ".jpg" or ".jpeg" => "JPEG QUALITY",
+ ".webp" => "WEBP QUALITY",
+ ".avif" => "AVIF QUALITY",
+ _ => "ENCODING QUALITY",
+ };
+ }
+
+ private void UpdateOutputDestHint(string? extension)
+ {
+ if (OutputDestHint is null) return;
+ var folder = (extension ?? ".jpg").TrimStart('.').ToLowerInvariant();
+ OutputDestHint.Text = $"비워두면 원본 옆 _{folder} 폴더에 저장됩니다";
+ }
+
+ private sealed record OutputFormatInfo(string Extension, string DisplayName, string BadgeText, string ColorResource);
}
// ============================================================
diff --git a/src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml b/src/Everything2Everything.App/Views/QuickProgressWindow.xaml
similarity index 96%
rename from src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml
rename to src/Everything2Everything.App/Views/QuickProgressWindow.xaml
index aeee32b..fbfbedf 100644
--- a/src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml
+++ b/src/Everything2Everything.App/Views/QuickProgressWindow.xaml
@@ -1,4 +1,4 @@
- $"• {Path.GetFileName(r.SourcePath)}: {r.Message}"));
MessageBox.Show(this, "일부 파일 변환에 실패했습니다.\n\n" + detail,
- "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Warning);
+ "Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Warning);
}
else if (failed == 0 && skipped == 0 && _firstSuccessOutput is not null)
{
diff --git a/src/EverythingToJpeg.App/app.manifest b/src/Everything2Everything.App/app.manifest
similarity index 94%
rename from src/EverythingToJpeg.App/app.manifest
rename to src/Everything2Everything.App/app.manifest
index 9144896..346505a 100644
--- a/src/EverythingToJpeg.App/app.manifest
+++ b/src/Everything2Everything.App/app.manifest
@@ -1,6 +1,6 @@
-
+
diff --git a/src/EverythingToJpeg.Core/ConversionEngine.cs b/src/Everything2Everything.Core/ConversionEngine.cs
similarity index 64%
rename from src/EverythingToJpeg.Core/ConversionEngine.cs
rename to src/Everything2Everything.Core/ConversionEngine.cs
index a326251..8d2a885 100644
--- a/src/EverythingToJpeg.Core/ConversionEngine.cs
+++ b/src/Everything2Everything.Core/ConversionEngine.cs
@@ -1,6 +1,6 @@
-using EverythingToJpeg.Core.Providers;
+using Everything2Everything.Core.Providers;
-namespace EverythingToJpeg.Core;
+namespace Everything2Everything.Core;
public sealed class ConversionEngine
{
@@ -15,6 +15,7 @@ public sealed class ConversionEngine
public async Task> ConvertManyAsync(
IEnumerable sources,
+ string outputExtension,
ConvertOptions options,
IProgress? progress = null,
CancellationToken cancellationToken = default)
@@ -29,7 +30,7 @@ public sealed class ConversionEngine
var source = sourceList[i];
progress?.Report(new ConvertProgress(i, sourceList.Count, source, 0));
- var result = await ConvertOneAsync(source, options,
+ var result = await ConvertOneAsync(source, outputExtension, options,
new Progress(p => progress?.Report(new ConvertProgress(i, sourceList.Count, source, p))),
cancellationToken).ConfigureAwait(false);
@@ -42,6 +43,7 @@ public sealed class ConversionEngine
public async Task ConvertOneAsync(
string sourcePath,
+ string outputExtension,
ConvertOptions options,
IProgress? progress = null,
CancellationToken cancellationToken = default)
@@ -49,8 +51,20 @@ public sealed class ConversionEngine
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 output = ConversionPair.Normalize(outputExtension);
+ var inputExt = ConversionPair.Normalize(Path.GetExtension(sourcePath));
+
+ if (string.Equals(inputExt, output, StringComparison.OrdinalIgnoreCase))
+ return ConvertResult.Skip(sourcePath, "입력과 출력 형식이 동일해 변환이 필요하지 않습니다.");
+
+ if (!_registry.TryGet(sourcePath, output, out var provider) || provider is null)
+ {
+ var available = _registry.OutputsForFile(sourcePath);
+ var hint = available.Count > 0
+ ? $" 가능한 출력: {string.Join(", ", available)}"
+ : string.Empty;
+ return ConvertResult.Fail(sourcePath, $"{inputExt} → {output} 변환을 지원하지 않습니다.{hint}");
+ }
var availability = await provider.CheckAvailabilityAsync(cancellationToken).ConfigureAwait(false);
if (!availability.IsReady)
@@ -61,12 +75,12 @@ public sealed class ConversionEngine
return ConvertResult.Fail(sourcePath, detail);
}
- var outputDir = ResolveOutputDirectory(sourcePath, options);
+ var outputDir = ResolveOutputDirectory(sourcePath, output, options);
Directory.CreateDirectory(outputDir);
try
{
- return await provider.ConvertAsync(sourcePath, outputDir, options, progress, cancellationToken)
+ return await provider.ConvertAsync(sourcePath, outputDir, output, options, progress, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
@@ -79,7 +93,7 @@ public sealed class ConversionEngine
}
}
- private static string ResolveOutputDirectory(string sourcePath, ConvertOptions options)
+ private static string ResolveOutputDirectory(string sourcePath, string outputExtension, ConvertOptions options)
{
var sourceDir = Path.GetDirectoryName(Path.GetFullPath(sourcePath))
?? throw new InvalidOperationException("소스 경로에서 폴더를 결정할 수 없습니다.");
@@ -91,9 +105,18 @@ public sealed class ConversionEngine
? sourceDir
: options.CustomOutputDirectory!,
_ => Path.Combine(sourceDir,
- Path.GetFileNameWithoutExtension(sourcePath) + options.SubfolderSuffix),
+ Path.GetFileNameWithoutExtension(sourcePath) + ResolveSubfolderSuffix(outputExtension, options)),
};
}
+
+ private static string ResolveSubfolderSuffix(string outputExtension, ConvertOptions options)
+ {
+ if (!string.IsNullOrWhiteSpace(options.SubfolderSuffix) && options.SubfolderSuffix != "_converted")
+ return options.SubfolderSuffix;
+
+ var ext = outputExtension.TrimStart('.').ToLowerInvariant();
+ return string.IsNullOrEmpty(ext) ? "_converted" : "_" + ext;
+ }
}
public sealed record ConvertProgress(int Index, int Total, string CurrentPath, double FileProgress);
diff --git a/src/Everything2Everything.Core/ConvertOptions.cs b/src/Everything2Everything.Core/ConvertOptions.cs
new file mode 100644
index 0000000..8b948d8
--- /dev/null
+++ b/src/Everything2Everything.Core/ConvertOptions.cs
@@ -0,0 +1,124 @@
+namespace Everything2Everything.Core;
+
+public enum OutputLocation
+{
+ SubfolderBesideSource,
+ SameFolderAsSource,
+ Custom
+}
+
+public enum NameCollision
+{
+ AppendNumber,
+ Overwrite,
+ Skip
+}
+
+public sealed class ConvertOptions
+{
+ public OutputLocation OutputLocation { get; set; } = OutputLocation.SubfolderBesideSource;
+
+ public string SubfolderSuffix { get; set; } = "_converted";
+
+ public string? CustomOutputDirectory { get; set; }
+
+ public NameCollision OnCollision { get; set; } = NameCollision.AppendNumber;
+
+ public int? MaxLongEdgePixels { get; set; }
+
+ public bool KeepExifWhenPossible { get; set; } = true;
+
+ public bool FlattenTransparency { get; set; } = false;
+
+ public string TransparencyBackground { get; set; } = "#FFFFFF";
+
+ public JpegEncodingOptions Jpeg { get; set; } = new();
+
+ public PngEncodingOptions Png { get; set; } = new();
+
+ public WebpEncodingOptions Webp { get; set; } = new();
+
+ public AvifEncodingOptions Avif { get; set; } = new();
+
+ public TiffEncodingOptions Tiff { get; set; } = new();
+
+ public BmpEncodingOptions Bmp { get; set; } = new();
+
+ public GifEncodingOptions Gif { get; set; } = new();
+
+ public PdfRenderOptions PdfRender { get; set; } = new();
+
+ public PdfBuildOptions PdfBuild { get; set; } = new();
+
+ public HtmlRenderOptions HtmlRender { get; set; } = new();
+
+ public OcrOptions Ocr { get; set; } = new();
+
+ public static ConvertOptions Quick() => new();
+}
+
+public sealed class JpegEncodingOptions
+{
+ public int Quality { get; set; } = 92;
+ public bool Progressive { get; set; } = false;
+}
+
+public sealed class PngEncodingOptions
+{
+ public int Compression { get; set; } = 7;
+ public bool Interlace { get; set; } = false;
+}
+
+public sealed class WebpEncodingOptions
+{
+ public int Quality { get; set; } = 90;
+ public bool Lossless { get; set; } = false;
+}
+
+public sealed class AvifEncodingOptions
+{
+ public int Quality { get; set; } = 60;
+ public int Speed { get; set; } = 6;
+}
+
+public sealed class TiffEncodingOptions
+{
+ public string Compression { get; set; } = "lzw";
+}
+
+public sealed class BmpEncodingOptions
+{
+}
+
+public sealed class GifEncodingOptions
+{
+}
+
+public sealed class PdfRenderOptions
+{
+ public int Dpi { get; set; } = 200;
+ public bool WithAnnotations { get; set; } = true;
+ public bool WithFormFill { get; set; } = true;
+}
+
+public sealed class PdfBuildOptions
+{
+ public string PageSize { get; set; } = "Auto";
+ public int MarginPoints { get; set; } = 24;
+ public bool FitToPage { get; set; } = true;
+}
+
+public sealed class HtmlRenderOptions
+{
+ public int ViewportWidth { get; set; } = 1280;
+ public int? ViewportHeight { get; set; }
+ public int WaitMilliseconds { get; set; } = 2000;
+ public bool FullPage { get; set; } = true;
+}
+
+public sealed class OcrOptions
+{
+ public string Language { get; set; } = "ko+en";
+ public bool PreserveLayout { get; set; } = true;
+ public string Backend { get; set; } = "auto";
+}
diff --git a/src/EverythingToJpeg.Core/ConvertResult.cs b/src/Everything2Everything.Core/ConvertResult.cs
similarity index 94%
rename from src/EverythingToJpeg.Core/ConvertResult.cs
rename to src/Everything2Everything.Core/ConvertResult.cs
index 5a89b29..86ba5ff 100644
--- a/src/EverythingToJpeg.Core/ConvertResult.cs
+++ b/src/Everything2Everything.Core/ConvertResult.cs
@@ -1,4 +1,4 @@
-namespace EverythingToJpeg.Core;
+namespace Everything2Everything.Core;
public enum ConvertStatus
{
diff --git a/src/EverythingToJpeg.Core/Converters/DocxProvider.cs b/src/Everything2Everything.Core/Converters/DocxProvider.cs
similarity index 80%
rename from src/EverythingToJpeg.Core/Converters/DocxProvider.cs
rename to src/Everything2Everything.Core/Converters/DocxProvider.cs
index 9716027..c3c2959 100644
--- a/src/EverythingToJpeg.Core/Converters/DocxProvider.cs
+++ b/src/Everything2Everything.Core/Converters/DocxProvider.cs
@@ -1,10 +1,15 @@
using System.Diagnostics;
-using EverythingToJpeg.Core.Providers;
+using Everything2Everything.Core.Providers;
-namespace EverythingToJpeg.Core.Converters;
+namespace Everything2Everything.Core.Converters;
public sealed class DocxProvider : IConverterProvider
{
+ private static readonly string[] DocxInputs = { ".docx", ".doc" };
+
+ private static readonly string[] DocxOutputs =
+ { ".pdf", ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff" };
+
private readonly PdfProvider _pdfProvider;
public DocxProvider(PdfProvider pdfProvider)
@@ -15,9 +20,9 @@ public sealed class DocxProvider : IConverterProvider
public ProviderCapability Capability { get; } = new(
Id: "docx",
DisplayName: "Word 문서 (DOCX)",
- Extensions: new[] { ".docx", ".doc" },
+ SupportedConversions: ProviderCapability.PairsFromMatrix(DocxInputs, DocxOutputs),
Status: ProviderStatus.RequiresExternal,
- Summary: "DOCX/DOC 문서를 PDF로 변환한 뒤 페이지별 JPEG로 저장합니다.",
+ Summary: "DOCX/DOC을 PDF로 변환하거나 페이지별 이미지로 렌더링합니다.",
ExternalDependencies: new[]
{
new ExternalDependency(
@@ -43,12 +48,14 @@ public sealed class DocxProvider : IConverterProvider
public async Task ConvertAsync(
string sourcePath,
string outputDirectory,
+ string outputExtension,
ConvertOptions options,
IProgress? progress,
CancellationToken cancellationToken)
{
+ var outExt = ConversionPair.Normalize(outputExtension);
var tempPdf = Path.Combine(Path.GetTempPath(),
- $"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
+ $"e2e_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
try
{
@@ -82,8 +89,19 @@ public sealed class DocxProvider : IConverterProvider
progress?.Report(0.55);
+ if (outExt == ".pdf")
+ {
+ var baseName = Path.GetFileNameWithoutExtension(sourcePath);
+ var finalPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, ".pdf", options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(finalPath, options.OnCollision))
+ return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+ File.Copy(tempPdf, finalPath, overwrite: options.OnCollision == NameCollision.Overwrite);
+ progress?.Report(1.0);
+ return ConvertResult.Ok(sourcePath, new[] { finalPath });
+ }
+
var inner = new Progress(p => progress?.Report(0.55 + p * 0.45));
- return _pdfProvider.ConvertCore(tempPdf, outputDirectory, options, inner, cancellationToken)
+ return _pdfProvider.ConvertCore(tempPdf, outputDirectory, outExt, options, inner, cancellationToken)
with { SourcePath = sourcePath };
}
finally
diff --git a/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs b/src/Everything2Everything.Core/Converters/ExternalToolDetector.cs
similarity index 98%
rename from src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs
rename to src/Everything2Everything.Core/Converters/ExternalToolDetector.cs
index 6f37a01..c3812b9 100644
--- a/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs
+++ b/src/Everything2Everything.Core/Converters/ExternalToolDetector.cs
@@ -1,6 +1,6 @@
using Microsoft.Win32;
-namespace EverythingToJpeg.Core.Converters;
+namespace Everything2Everything.Core.Converters;
internal static class ExternalToolDetector
{
diff --git a/src/EverythingToJpeg.Core/Converters/HeicProvider.cs b/src/Everything2Everything.Core/Converters/HeicProvider.cs
similarity index 73%
rename from src/EverythingToJpeg.Core/Converters/HeicProvider.cs
rename to src/Everything2Everything.Core/Converters/HeicProvider.cs
index 8bb8dbf..2afa237 100644
--- a/src/EverythingToJpeg.Core/Converters/HeicProvider.cs
+++ b/src/Everything2Everything.Core/Converters/HeicProvider.cs
@@ -1,11 +1,16 @@
-using EverythingToJpeg.Core.Providers;
+using Everything2Everything.Core.Providers;
using PhotoSauce.MagicScaler;
using PhotoSauce.NativeCodecs.Libheif;
-namespace EverythingToJpeg.Core.Converters;
+namespace Everything2Everything.Core.Converters;
public sealed class HeicProvider : IConverterProvider
{
+ private static readonly string[] HeicInputs = { ".heic", ".heif" };
+
+ private static readonly string[] PassThroughOutputs =
+ { ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff", ".gif" };
+
private static int _codecConfigured;
private readonly MagickProvider _magickProvider;
@@ -19,9 +24,9 @@ public sealed class HeicProvider : IConverterProvider
public ProviderCapability Capability { get; } = new(
Id: "heic",
DisplayName: "HEIC / HEIF",
- Extensions: new[] { ".heic", ".heif" },
+ SupportedConversions: ProviderCapability.PairsFromMatrix(HeicInputs, PassThroughOutputs),
Status: ProviderStatus.Available,
- Summary: "iPhone 등에서 만든 HEIC·HEIF 사진을 JPEG로 변환합니다.",
+ Summary: "iPhone 등에서 만든 HEIC·HEIF 사진을 PNG/JPEG/WebP/AVIF/BMP/TIFF/GIF로 변환합니다.",
ExternalDependencies: Array.Empty(),
RoadmapNote: null);
@@ -34,6 +39,7 @@ public sealed class HeicProvider : IConverterProvider
public async Task ConvertAsync(
string sourcePath,
string outputDirectory,
+ string outputExtension,
ConvertOptions options,
IProgress? progress,
CancellationToken cancellationToken)
@@ -41,7 +47,7 @@ public sealed class HeicProvider : IConverterProvider
EnsureCodec();
var tempPng = Path.Combine(Path.GetTempPath(),
- $"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.png");
+ $"e2e_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.png");
try
{
@@ -54,7 +60,7 @@ public sealed class HeicProvider : IConverterProvider
var inner = new Progress(p => progress?.Report(0.5 + p * 0.5));
var result = await _magickProvider
- .ConvertAsync(tempPng, outputDirectory, options, inner, cancellationToken)
+ .ConvertAsync(tempPng, outputDirectory, outputExtension, options, inner, cancellationToken)
.ConfigureAwait(false);
return result with { SourcePath = sourcePath };
diff --git a/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs b/src/Everything2Everything.Core/Converters/HtmlProvider.cs
similarity index 51%
rename from src/EverythingToJpeg.Core/Converters/HtmlProvider.cs
rename to src/Everything2Everything.Core/Converters/HtmlProvider.cs
index 66edad1..eb9abc7 100644
--- a/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs
+++ b/src/Everything2Everything.Core/Converters/HtmlProvider.cs
@@ -2,20 +2,25 @@ using System.Text.Json;
using System.Threading;
using System.Windows;
using System.Windows.Threading;
-using EverythingToJpeg.Core.Providers;
+using Everything2Everything.Core.Providers;
using ImageMagick;
using Microsoft.Web.WebView2.Core;
-namespace EverythingToJpeg.Core.Converters;
+namespace Everything2Everything.Core.Converters;
public sealed class HtmlProvider : IConverterProvider
{
+ private static readonly string[] HtmlInputs = { ".html", ".htm" };
+
+ private static readonly string[] HtmlOutputs =
+ { ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff", ".pdf" };
+
public ProviderCapability Capability { get; } = new(
Id: "html",
DisplayName: "HTML / 웹 페이지",
- Extensions: new[] { ".html", ".htm" },
+ SupportedConversions: ProviderCapability.PairsFromMatrix(HtmlInputs, HtmlOutputs),
Status: ProviderStatus.Available,
- Summary: "HTML/HTM 파일을 WebView2로 헤드리스 렌더링하여 풀페이지 JPEG로 캡처합니다.",
+ Summary: "HTML/HTM을 WebView2로 헤드리스 렌더링하여 이미지 또는 PDF로 저장합니다.",
ExternalDependencies: new[]
{
new ExternalDependency(
@@ -48,15 +53,26 @@ public sealed class HtmlProvider : IConverterProvider
public async Task ConvertAsync(
string sourcePath,
string outputDirectory,
+ string outputExtension,
ConvertOptions options,
IProgress? progress,
CancellationToken cancellationToken)
{
+ var outExt = ConversionPair.Normalize(outputExtension);
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
- var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision);
+ var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+ if (outExt == ".pdf")
+ {
+ var pdfBytes = await CapturePdfAsync(sourcePath, options, progress, cancellationToken)
+ .ConfigureAwait(false);
+ await File.WriteAllBytesAsync(path, pdfBytes, cancellationToken).ConfigureAwait(false);
+ progress?.Report(1.0);
+ return ConvertResult.Ok(sourcePath, new[] { path });
+ }
+
var pngBytes = await CapturePngAsync(sourcePath, options, progress, cancellationToken)
.ConfigureAwait(false);
@@ -65,7 +81,8 @@ public sealed class HtmlProvider : IConverterProvider
await Task.Run(() =>
{
using var image = new MagickImage(pngBytes);
- if (options.FlattenTransparency && image.HasAlpha)
+ var alphaCapable = outExt is ".png" or ".webp" or ".avif" or ".tif" or ".tiff";
+ if ((!alphaCapable || options.FlattenTransparency) && image.HasAlpha)
{
image.BackgroundColor = new MagickColor(options.TransparencyBackground);
image.Alpha(AlphaOption.Remove);
@@ -76,8 +93,7 @@ public sealed class HtmlProvider : IConverterProvider
{
image.Resize(new MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false });
}
- image.Quality = (uint)Math.Clamp(options.Quality, 1, 100);
- image.Format = MagickFormat.Jpeg;
+ ApplyEncoding(image, outExt, options);
image.Write(path);
}, cancellationToken).ConfigureAwait(false);
@@ -85,11 +101,61 @@ public sealed class HtmlProvider : IConverterProvider
return ConvertResult.Ok(sourcePath, new[] { path });
}
+ private static void ApplyEncoding(IMagickImage image, string outputExtension, ConvertOptions options)
+ {
+ switch (outputExtension)
+ {
+ case ".jpg":
+ case ".jpeg":
+ image.Quality = (uint)Math.Clamp(options.Jpeg.Quality, 1, 100);
+ image.Format = MagickFormat.Jpeg;
+ break;
+ case ".png":
+ image.Format = MagickFormat.Png;
+ break;
+ case ".webp":
+ image.Quality = (uint)Math.Clamp(options.Webp.Quality, 1, 100);
+ if (options.Webp.Lossless)
+ image.Settings.SetDefine(MagickFormat.WebP, "lossless", "true");
+ image.Format = MagickFormat.WebP;
+ break;
+ case ".avif":
+ image.Quality = (uint)Math.Clamp(options.Avif.Quality, 1, 100);
+ image.Settings.SetDefine(MagickFormat.Avif, "speed", Math.Clamp(options.Avif.Speed, 0, 10).ToString());
+ image.Format = MagickFormat.Avif;
+ break;
+ case ".bmp":
+ image.Format = MagickFormat.Bmp;
+ break;
+ case ".tif":
+ case ".tiff":
+ if (!string.IsNullOrWhiteSpace(options.Tiff.Compression))
+ image.Settings.SetDefine(MagickFormat.Tiff, "compression", options.Tiff.Compression);
+ image.Format = MagickFormat.Tiff;
+ break;
+ }
+ }
+
private static Task CapturePngAsync(
string sourcePath,
ConvertOptions options,
IProgress? progress,
CancellationToken cancellationToken)
+ => RunOnDispatcherThreadAsync((web, p) => CaptureScreenshotAsync(web, options, p), sourcePath, options, progress, cancellationToken);
+
+ private static Task CapturePdfAsync(
+ string sourcePath,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ => RunOnDispatcherThreadAsync((web, p) => PrintToPdfAsync(web, options, p), sourcePath, options, progress, cancellationToken);
+
+ private static Task RunOnDispatcherThreadAsync(
+ Func?, Task> capture,
+ string sourcePath,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -98,7 +164,7 @@ public sealed class HtmlProvider : IConverterProvider
try
{
var dispatcher = Dispatcher.CurrentDispatcher;
- _ = RunCaptureOnDispatcher(dispatcher, sourcePath, options, progress, cancellationToken, tcs);
+ _ = RunCaptureOnDispatcher(capture, dispatcher, sourcePath, options, progress, cancellationToken, tcs);
Dispatcher.Run();
}
catch (Exception ex)
@@ -108,13 +174,14 @@ public sealed class HtmlProvider : IConverterProvider
});
thread.SetApartmentState(ApartmentState.STA);
thread.IsBackground = true;
- thread.Name = "EverythingToJpeg.HtmlCapture";
+ thread.Name = "Everything2Everything.HtmlCapture";
thread.Start();
return tcs.Task;
}
private static async Task RunCaptureOnDispatcher(
+ Func?, Task> capture,
Dispatcher dispatcher,
string sourcePath,
ConvertOptions options,
@@ -127,17 +194,16 @@ public sealed class HtmlProvider : IConverterProvider
{
var userDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "EverythingToJpeg", "WebView2");
+ "Everything2Everything", "WebView2");
Directory.CreateDirectory(userDataFolder);
var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder).ConfigureAwait(true);
progress?.Report(0.15);
- // HWND_MESSAGE = (IntPtr)(-3) → headless message-only parent
controller = await env.CreateCoreWebView2ControllerAsync(new IntPtr(-3)).ConfigureAwait(true);
- int width = options.HtmlViewportWidth > 0 ? options.HtmlViewportWidth : 1280;
- int height = options.HtmlViewportHeight ?? 720;
+ int width = options.HtmlRender.ViewportWidth > 0 ? options.HtmlRender.ViewportWidth : 1280;
+ int height = options.HtmlRender.ViewportHeight ?? 720;
controller.Bounds = new System.Drawing.Rectangle(0, 0, width, height);
controller.IsVisible = false;
@@ -164,27 +230,13 @@ public sealed class HtmlProvider : IConverterProvider
}
progress?.Report(0.5);
- if (options.HtmlWaitMilliseconds > 0)
- await Task.Delay(options.HtmlWaitMilliseconds, cancellationToken).ConfigureAwait(true);
+ if (options.HtmlRender.WaitMilliseconds > 0)
+ await Task.Delay(options.HtmlRender.WaitMilliseconds, cancellationToken).ConfigureAwait(true);
progress?.Report(0.65);
- // Use CDP for full-page screenshot beyond viewport
- var captureParams = options.HtmlFullPage
- ? "{\"captureBeyondViewport\":true,\"format\":\"png\"}"
- : "{\"format\":\"png\"}";
-
- var resultJson = await web
- .CallDevToolsProtocolMethodAsync("Page.captureScreenshot", captureParams)
- .ConfigureAwait(true);
- progress?.Report(0.8);
-
- using var doc = JsonDocument.Parse(resultJson);
- var b64 = doc.RootElement.GetProperty("data").GetString()
- ?? throw new InvalidOperationException("CDP captureScreenshot이 빈 결과를 반환했습니다.");
- var pngBytes = Convert.FromBase64String(b64);
-
- tcs.TrySetResult(pngBytes);
+ var bytes = await capture(web, progress).ConfigureAwait(true);
+ tcs.TrySetResult(bytes);
}
catch (Exception ex)
{
@@ -196,4 +248,34 @@ public sealed class HtmlProvider : IConverterProvider
dispatcher.BeginInvokeShutdown(DispatcherPriority.Background);
}
}
+
+ private static async Task CaptureScreenshotAsync(CoreWebView2 web, ConvertOptions options, IProgress? progress)
+ {
+ var captureParams = options.HtmlRender.FullPage
+ ? "{\"captureBeyondViewport\":true,\"format\":\"png\"}"
+ : "{\"format\":\"png\"}";
+
+ var resultJson = await web
+ .CallDevToolsProtocolMethodAsync("Page.captureScreenshot", captureParams)
+ .ConfigureAwait(true);
+ progress?.Report(0.8);
+
+ using var doc = JsonDocument.Parse(resultJson);
+ var b64 = doc.RootElement.GetProperty("data").GetString()
+ ?? throw new InvalidOperationException("CDP captureScreenshot이 빈 결과를 반환했습니다.");
+ return Convert.FromBase64String(b64);
+ }
+
+ private static async Task PrintToPdfAsync(CoreWebView2 web, ConvertOptions options, IProgress? progress)
+ {
+ var resultJson = await web
+ .CallDevToolsProtocolMethodAsync("Page.printToPDF", "{\"printBackground\":true,\"preferCSSPageSize\":true}")
+ .ConfigureAwait(true);
+ progress?.Report(0.8);
+
+ using var doc = JsonDocument.Parse(resultJson);
+ var b64 = doc.RootElement.GetProperty("data").GetString()
+ ?? throw new InvalidOperationException("CDP printToPDF가 빈 결과를 반환했습니다.");
+ return Convert.FromBase64String(b64);
+ }
}
diff --git a/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs b/src/Everything2Everything.Core/Converters/HwpxProvider.cs
similarity index 78%
rename from src/EverythingToJpeg.Core/Converters/HwpxProvider.cs
rename to src/Everything2Everything.Core/Converters/HwpxProvider.cs
index 954266d..2cd2fb4 100644
--- a/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs
+++ b/src/Everything2Everything.Core/Converters/HwpxProvider.cs
@@ -1,10 +1,15 @@
using System.Diagnostics;
-using EverythingToJpeg.Core.Providers;
+using Everything2Everything.Core.Providers;
-namespace EverythingToJpeg.Core.Converters;
+namespace Everything2Everything.Core.Converters;
public sealed class HwpxProvider : IConverterProvider
{
+ private static readonly string[] HwpInputs = { ".hwp", ".hwpx" };
+
+ private static readonly string[] HwpOutputs =
+ { ".pdf", ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff" };
+
private readonly PdfProvider _pdfProvider;
public HwpxProvider() : this(new PdfProvider()) { }
@@ -17,9 +22,9 @@ public sealed class HwpxProvider : IConverterProvider
public ProviderCapability Capability { get; } = new(
Id: "hwpx",
DisplayName: "한글 문서 (HWP / HWPX)",
- Extensions: new[] { ".hwp", ".hwpx" },
+ SupportedConversions: ProviderCapability.PairsFromMatrix(HwpInputs, HwpOutputs),
Status: ProviderStatus.RequiresExternal,
- Summary: "한글(HWP/HWPX) 문서를 LibreOffice + H2Orestart로 PDF 변환 후 페이지별 JPEG로 저장합니다.",
+ Summary: "한글(HWP/HWPX) 문서를 LibreOffice + H2Orestart로 PDF 변환 후 PDF 또는 페이지별 이미지로 저장합니다.",
ExternalDependencies: new[]
{
new ExternalDependency(
@@ -53,6 +58,7 @@ public sealed class HwpxProvider : IConverterProvider
public async Task ConvertAsync(
string sourcePath,
string outputDirectory,
+ string outputExtension,
ConvertOptions options,
IProgress? progress,
CancellationToken cancellationToken)
@@ -60,8 +66,9 @@ public sealed class HwpxProvider : IConverterProvider
if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out var soffice))
return ConvertResult.Fail(sourcePath, "LibreOffice가 필요합니다.");
+ var outExt = ConversionPair.Normalize(outputExtension);
var tempPdf = Path.Combine(Path.GetTempPath(),
- $"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
+ $"e2e_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
try
{
@@ -76,8 +83,19 @@ public sealed class HwpxProvider : IConverterProvider
progress?.Report(0.55);
+ if (outExt == ".pdf")
+ {
+ var baseName = Path.GetFileNameWithoutExtension(sourcePath);
+ var finalPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, ".pdf", options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(finalPath, options.OnCollision))
+ return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+ File.Copy(tempPdf, finalPath, overwrite: options.OnCollision == NameCollision.Overwrite);
+ progress?.Report(1.0);
+ return ConvertResult.Ok(sourcePath, new[] { finalPath });
+ }
+
var inner = new Progress(p => progress?.Report(0.55 + p * 0.45));
- return _pdfProvider.ConvertCore(tempPdf, outputDirectory, options, inner, cancellationToken)
+ return _pdfProvider.ConvertCore(tempPdf, outputDirectory, outExt, options, inner, cancellationToken)
with { SourcePath = sourcePath };
}
finally
diff --git a/src/Everything2Everything.Core/Converters/MagickProvider.cs b/src/Everything2Everything.Core/Converters/MagickProvider.cs
new file mode 100644
index 0000000..5607c78
--- /dev/null
+++ b/src/Everything2Everything.Core/Converters/MagickProvider.cs
@@ -0,0 +1,254 @@
+using Everything2Everything.Core.Providers;
+using ImageMagick;
+
+namespace Everything2Everything.Core.Converters;
+
+public sealed class MagickProvider : IConverterProvider
+{
+ private static readonly string[] SingleFrameInputs =
+ {
+ ".png", ".bmp", ".jpg", ".jpeg", ".jpe", ".webp", ".avif", ".psd",
+ ".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ".rw2", ".srw", ".pef", ".raw",
+ };
+
+ private static readonly string[] MultiFrameInputs = { ".gif", ".tif", ".tiff" };
+
+ private static readonly string[] AllInputs = SingleFrameInputs.Concat(MultiFrameInputs).ToArray();
+
+ private static readonly string[] WritableOutputs =
+ { ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff", ".gif", ".pdf" };
+
+ private static readonly HashSet AlphaCapableOutputs = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".png", ".webp", ".avif", ".tif", ".tiff", ".gif",
+ };
+
+ public ProviderCapability Capability { get; } = new(
+ Id: "magick",
+ DisplayName: "이미지·RAW·애니메이션",
+ SupportedConversions: ProviderCapability.PairsFromMatrix(AllInputs, WritableOutputs),
+ Status: ProviderStatus.Available,
+ Summary: "PNG/JPEG/WebP/AVIF/BMP/TIFF/GIF/PDF 사이의 양방향 변환 + RAW(NEF/CR2/ARW/DNG…)·PSD 디코딩.",
+ ExternalDependencies: Array.Empty(),
+ RoadmapNote: null);
+
+ public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult(ProviderAvailability.Ready);
+
+ public Task ConvertAsync(
+ string sourcePath,
+ string outputDirectory,
+ string outputExtension,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ return Task.Run(
+ () => ConvertCore(sourcePath, outputDirectory, outputExtension, options, progress, cancellationToken),
+ cancellationToken);
+ }
+
+ private static ConvertResult ConvertCore(
+ string sourcePath,
+ string outputDirectory,
+ string outputExtension,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ var inputExt = Path.GetExtension(sourcePath).ToLowerInvariant();
+ var outExt = ConversionPair.Normalize(outputExtension);
+ var baseName = Path.GetFileNameWithoutExtension(sourcePath);
+ var isMultiFrameInput = MultiFrameInputs.Contains(inputExt);
+ var format = ResolveFormat(outExt);
+ var isMultiFrameOutput = outExt is ".gif" or ".tif" or ".tiff" or ".pdf";
+
+ if (isMultiFrameInput)
+ {
+ using var collection = new MagickImageCollection(sourcePath);
+ if (collection.Count == 0)
+ return ConvertResult.Fail(sourcePath, "이미지 프레임을 읽지 못했습니다.");
+
+ if (collection.Count == 1)
+ {
+ var single = collection[0];
+ ApplyCommonTransforms(single, outExt, options);
+ var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
+ return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+ WriteSingle(single, path, format, outExt, options);
+ progress?.Report(1.0);
+ return ConvertResult.Ok(sourcePath, new[] { path });
+ }
+
+ collection.Coalesce();
+
+ if (isMultiFrameOutput)
+ {
+ foreach (var frame in collection)
+ ApplyCommonTransforms(frame, outExt, options);
+ var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
+ return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+ ApplyCollectionEncoding(collection, format, outExt, options);
+ collection.Write(path);
+ progress?.Report(1.0);
+ return ConvertResult.Ok(sourcePath, new[] { path });
+ }
+
+ var outputs = new List();
+ var width = (int)Math.Ceiling(Math.Log10(collection.Count + 1));
+ for (var i = 0; i < collection.Count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var frame = collection[i];
+ ApplyCommonTransforms(frame, outExt, options);
+ var suffix = $"_{(i + 1).ToString().PadLeft(width, '0')}";
+ var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, outExt, options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue;
+ WriteSingle(frame, path, format, outExt, options);
+ 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, outExt, options);
+ var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
+ return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+ WriteSingle(image, path, format, outExt, options);
+ progress?.Report(1.0);
+ return ConvertResult.Ok(sourcePath, new[] { path });
+ }
+ }
+
+ private static MagickFormat ResolveFormat(string outputExtension) => outputExtension switch
+ {
+ ".png" => MagickFormat.Png,
+ ".jpg" or ".jpeg" => MagickFormat.Jpeg,
+ ".webp" => MagickFormat.WebP,
+ ".avif" => MagickFormat.Avif,
+ ".bmp" => MagickFormat.Bmp,
+ ".tif" or ".tiff" => MagickFormat.Tiff,
+ ".gif" => MagickFormat.Gif,
+ ".pdf" => MagickFormat.Pdf,
+ _ => throw new NotSupportedException($"지원하지 않는 출력 형식: {outputExtension}"),
+ };
+
+ private static void ApplyCommonTransforms(IMagickImage image, string outputExtension, ConvertOptions options)
+ {
+ try { image.AutoOrient(); } catch { }
+
+ var flattenForOutput = !AlphaCapableOutputs.Contains(outputExtension);
+ if ((flattenForOutput || 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);
+ }
+ }
+ }
+
+ private static void WriteSingle(IMagickImage image, string path, MagickFormat format, string outputExtension, ConvertOptions options)
+ {
+ ApplySingleEncoding(image, format, outputExtension, options);
+ image.Write(path);
+ }
+
+ private static void ApplySingleEncoding(IMagickImage image, MagickFormat format, string outputExtension, ConvertOptions options)
+ {
+ image.Format = format;
+ switch (outputExtension)
+ {
+ case ".jpg":
+ case ".jpeg":
+ image.Quality = (uint)Math.Clamp(options.Jpeg.Quality, 1, 100);
+ if (options.Jpeg.Progressive)
+ image.Settings.Interlace = Interlace.Jpeg;
+ break;
+ case ".png":
+ image.Quality = (uint)Math.Clamp((options.Png.Compression * 10) + 5, 1, 100);
+ if (options.Png.Interlace)
+ image.Settings.Interlace = Interlace.Png;
+ break;
+ case ".webp":
+ image.Quality = (uint)Math.Clamp(options.Webp.Quality, 1, 100);
+ if (options.Webp.Lossless)
+ image.Settings.SetDefine(MagickFormat.WebP, "lossless", "true");
+ break;
+ case ".avif":
+ image.Quality = (uint)Math.Clamp(options.Avif.Quality, 1, 100);
+ image.Settings.SetDefine(MagickFormat.Avif, "speed", Math.Clamp(options.Avif.Speed, 0, 10).ToString());
+ break;
+ case ".tif":
+ case ".tiff":
+ if (!string.IsNullOrWhiteSpace(options.Tiff.Compression))
+ image.Settings.SetDefine(MagickFormat.Tiff, "compression", options.Tiff.Compression);
+ break;
+ case ".pdf":
+ ApplyPdfPageSettings(image, options);
+ break;
+ }
+ }
+
+ private static void ApplyPdfPageSettings(IMagickImage image, ConvertOptions options)
+ {
+ var pageSize = options.PdfBuild.PageSize;
+ if (string.Equals(pageSize, "Auto", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(pageSize))
+ {
+ return;
+ }
+
+ if (TryGetPagePoints(pageSize, out var widthPt, out var heightPt))
+ {
+ var marginPt = Math.Max(0, options.PdfBuild.MarginPoints);
+ var contentW = (uint)Math.Max(1, widthPt - marginPt * 2);
+ var contentH = (uint)Math.Max(1, heightPt - marginPt * 2);
+
+ if (options.PdfBuild.FitToPage)
+ {
+ var geom = new MagickGeometry(contentW, contentH) { IgnoreAspectRatio = false };
+ image.Resize(geom);
+ }
+
+ image.Page = new MagickGeometry(
+ (int)marginPt, (int)marginPt,
+ (uint)widthPt, (uint)heightPt);
+ }
+ }
+
+ private static bool TryGetPagePoints(string pageSize, out int widthPt, out int heightPt)
+ {
+ switch (pageSize.ToUpperInvariant())
+ {
+ case "A4": widthPt = 595; heightPt = 842; return true;
+ case "A3": widthPt = 842; heightPt = 1191; return true;
+ case "A5": widthPt = 420; heightPt = 595; return true;
+ case "LETTER": widthPt = 612; heightPt = 792; return true;
+ case "LEGAL": widthPt = 612; heightPt = 1008; return true;
+ default: widthPt = 0; heightPt = 0; return false;
+ }
+ }
+
+ private static void ApplyCollectionEncoding(MagickImageCollection collection, MagickFormat format, string outputExtension, ConvertOptions options)
+ {
+ foreach (var img in collection)
+ ApplySingleEncoding(img, format, outputExtension, options);
+ }
+}
diff --git a/src/Everything2Everything.Core/Converters/OcrProvider.cs b/src/Everything2Everything.Core/Converters/OcrProvider.cs
new file mode 100644
index 0000000..6c0459f
--- /dev/null
+++ b/src/Everything2Everything.Core/Converters/OcrProvider.cs
@@ -0,0 +1,220 @@
+using DocumentFormat.OpenXml;
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Wordprocessing;
+using Everything2Everything.Core.Providers;
+using Windows.Globalization;
+using Windows.Graphics.Imaging;
+using Windows.Media.Ocr;
+using Windows.Storage.Streams;
+
+namespace Everything2Everything.Core.Converters;
+
+public sealed class OcrProvider : IConverterProvider
+{
+ private static readonly string[] OcrInputs =
+ { ".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp", ".gif", ".heic", ".heif", ".pdf" };
+
+ private static readonly string[] OcrOutputs = { ".txt", ".docx" };
+
+ private readonly PdfProvider _pdfProvider;
+
+ public OcrProvider() : this(new PdfProvider()) { }
+
+ public OcrProvider(PdfProvider pdfProvider)
+ {
+ _pdfProvider = pdfProvider;
+ }
+
+ public ProviderCapability Capability { get; } = new(
+ Id: "ocr",
+ DisplayName: "OCR (이미지/PDF → 텍스트·DOCX)",
+ SupportedConversions: ProviderCapability.PairsFromMatrix(OcrInputs, OcrOutputs),
+ Status: ProviderStatus.Available,
+ Summary: "Windows OCR 엔진으로 이미지 또는 PDF 페이지에서 텍스트를 추출해 .txt 또는 .docx로 저장합니다.",
+ ExternalDependencies: Array.Empty(),
+ RoadmapNote: "Windows에 설치된 OCR 언어 팩을 사용 — 한국어/영어는 Windows 11 기본 포함.");
+
+ public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var langs = OcrEngine.AvailableRecognizerLanguages;
+ if (langs is null || langs.Count == 0)
+ return Task.FromResult(ProviderAvailability.NotReady(
+ "Windows OCR 언어 팩이 설치되어 있지 않습니다. 설정 → 시간 및 언어 → 언어에서 OCR 기능을 추가하세요."));
+ return Task.FromResult(ProviderAvailability.Ready);
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(ProviderAvailability.NotReady("Windows OCR 엔진 초기화 실패: " + ex.Message));
+ }
+ }
+
+ public async Task ConvertAsync(
+ string sourcePath,
+ string outputDirectory,
+ string outputExtension,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ var outExt = ConversionPair.Normalize(outputExtension);
+ var inputExt = Path.GetExtension(sourcePath).ToLowerInvariant();
+ var baseName = Path.GetFileNameWithoutExtension(sourcePath);
+
+ var pages = inputExt == ".pdf"
+ ? await ExtractPdfPagesAsync(sourcePath, options, progress, cancellationToken).ConfigureAwait(false)
+ : new List { sourcePath };
+
+ if (pages.Count == 0)
+ return ConvertResult.Fail(sourcePath, "OCR 입력 페이지를 추출하지 못했습니다.");
+
+ try
+ {
+ var engine = ResolveEngine(options.Ocr.Language);
+ if (engine is null)
+ return ConvertResult.Fail(sourcePath,
+ $"요청한 언어({options.Ocr.Language})에 맞는 OCR 엔진을 찾을 수 없습니다. 사용 가능: {string.Join(", ", OcrEngine.AvailableRecognizerLanguages.Select(l => l.LanguageTag))}");
+
+ var pageTexts = new List(pages.Count);
+ for (var i = 0; i < pages.Count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var text = await RecognizeAsync(engine, pages[i], cancellationToken).ConfigureAwait(false);
+ pageTexts.Add(text);
+ progress?.Report((i + 1.0) / pages.Count * 0.9);
+ }
+
+ var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
+ return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+
+ if (outExt == ".txt")
+ {
+ var combined = pages.Count == 1
+ ? pageTexts[0]
+ : string.Join(Environment.NewLine + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine, pageTexts);
+ await File.WriteAllTextAsync(path, combined, System.Text.Encoding.UTF8, cancellationToken).ConfigureAwait(false);
+ }
+ else if (outExt == ".docx")
+ {
+ WriteDocx(path, pageTexts);
+ }
+ else
+ {
+ return ConvertResult.Fail(sourcePath, $"지원하지 않는 출력 형식: {outExt}");
+ }
+
+ progress?.Report(1.0);
+ return ConvertResult.Ok(sourcePath, new[] { path });
+ }
+ finally
+ {
+ if (inputExt == ".pdf")
+ {
+ foreach (var p in pages)
+ {
+ try { if (File.Exists(p)) File.Delete(p); } catch { }
+ }
+ }
+ }
+ }
+
+ private static OcrEngine? ResolveEngine(string requestedLanguage)
+ {
+ if (string.IsNullOrWhiteSpace(requestedLanguage)
+ || string.Equals(requestedLanguage, "auto", StringComparison.OrdinalIgnoreCase))
+ {
+ return OcrEngine.TryCreateFromUserProfileLanguages();
+ }
+
+ var preferences = requestedLanguage.Split(new[] { '+', ',', ';' },
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ var available = OcrEngine.AvailableRecognizerLanguages;
+ foreach (var pref in preferences)
+ {
+ var matched = available.FirstOrDefault(l =>
+ l.LanguageTag.StartsWith(pref, StringComparison.OrdinalIgnoreCase));
+ if (matched is not null)
+ return OcrEngine.TryCreateFromLanguage(matched);
+ }
+
+ return OcrEngine.TryCreateFromUserProfileLanguages();
+ }
+
+ private static async Task RecognizeAsync(OcrEngine engine, string imagePath, CancellationToken cancellationToken)
+ {
+ using var fileStream = File.OpenRead(imagePath);
+ using var memory = new MemoryStream();
+ await fileStream.CopyToAsync(memory, cancellationToken).ConfigureAwait(false);
+ memory.Position = 0;
+
+ using var randomAccess = new InMemoryRandomAccessStream();
+ using (var writer = new DataWriter(randomAccess.GetOutputStreamAt(0)))
+ {
+ writer.WriteBytes(memory.ToArray());
+ await writer.StoreAsync();
+ }
+ randomAccess.Seek(0);
+
+ var decoder = await BitmapDecoder.CreateAsync(randomAccess);
+ using var bitmap = await decoder.GetSoftwareBitmapAsync();
+
+ var ocrResult = await engine.RecognizeAsync(bitmap);
+ return ocrResult?.Text ?? string.Empty;
+ }
+
+ private async Task> ExtractPdfPagesAsync(
+ string pdfPath,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"e2e_ocr_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempDir);
+
+ var renderOptions = new ConvertOptions
+ {
+ OutputLocation = OutputLocation.Custom,
+ CustomOutputDirectory = tempDir,
+ OnCollision = NameCollision.Overwrite,
+ };
+ renderOptions.PdfRender.Dpi = Math.Max(150, options.PdfRender.Dpi);
+
+ var inner = new Progress(p => progress?.Report(p * 0.4));
+ var result = _pdfProvider.ConvertCore(pdfPath, tempDir, ".png", renderOptions, inner, cancellationToken);
+ if (result.Status != ConvertStatus.Success)
+ return new List();
+
+ return result.OutputPaths.ToList();
+ }
+
+ private static void WriteDocx(string path, IReadOnlyList pageTexts)
+ {
+ using var doc = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document);
+ var mainPart = doc.AddMainDocumentPart();
+ mainPart.Document = new Document();
+ var body = mainPart.Document.AppendChild(new Body());
+
+ for (var pageIndex = 0; pageIndex < pageTexts.Count; pageIndex++)
+ {
+ var pageText = pageTexts[pageIndex] ?? string.Empty;
+ foreach (var line in pageText.Split('\n', StringSplitOptions.None))
+ {
+ var paragraph = body.AppendChild(new Paragraph());
+ var run = paragraph.AppendChild(new Run());
+ run.AppendChild(new Text(line.TrimEnd('\r')) { Space = SpaceProcessingModeValues.Preserve });
+ }
+
+ if (pageIndex < pageTexts.Count - 1)
+ {
+ var pageBreakPara = body.AppendChild(new Paragraph());
+ var pageBreakRun = pageBreakPara.AppendChild(new Run());
+ pageBreakRun.AppendChild(new Break { Type = BreakValues.Page });
+ }
+ }
+
+ mainPart.Document.Save();
+ }
+}
diff --git a/src/Everything2Everything.Core/Converters/PdfProvider.cs b/src/Everything2Everything.Core/Converters/PdfProvider.cs
new file mode 100644
index 0000000..fe9e489
--- /dev/null
+++ b/src/Everything2Everything.Core/Converters/PdfProvider.cs
@@ -0,0 +1,150 @@
+using Everything2Everything.Core.Providers;
+using PDFtoImage;
+using SkiaSharp;
+
+namespace Everything2Everything.Core.Converters;
+
+public sealed class PdfProvider : IConverterProvider
+{
+ private static readonly string[] PdfInputs = { ".pdf" };
+
+ private static readonly string[] PdfRenderOutputs =
+ { ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff" };
+
+ public ProviderCapability Capability { get; } = new(
+ Id: "pdf",
+ DisplayName: "PDF",
+ SupportedConversions: ProviderCapability.PairsFromMatrix(PdfInputs, PdfRenderOutputs),
+ Status: ProviderStatus.Available,
+ Summary: "PDF 각 페이지를 PNG/JPEG/WebP/AVIF/BMP/TIFF로 렌더링합니다.",
+ ExternalDependencies: Array.Empty(),
+ RoadmapNote: null);
+
+ public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult(ProviderAvailability.Ready);
+
+ public Task ConvertAsync(
+ string sourcePath,
+ string outputDirectory,
+ string outputExtension,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ return Task.Run(
+ () => ConvertCore(sourcePath, outputDirectory, outputExtension, options, progress, cancellationToken),
+ cancellationToken);
+ }
+
+ internal ConvertResult ConvertCore(
+ string sourcePath,
+ string outputDirectory,
+ string outputExtension,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ var outExt = ConversionPair.Normalize(outputExtension);
+ 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.PdfRender.Dpi,
+ BackgroundColor = SKColors.White,
+ WithAnnotations = options.PdfRender.WithAnnotations,
+ WithFormFill = options.PdfRender.WithFormFill,
+ UseTiling = true,
+ };
+
+ var width = (int)Math.Ceiling(Math.Log10(pageCount + 1));
+ var outputs = new List();
+
+ for (var i = 0; i < pageCount; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var suffix = pageCount == 1 ? null : $"_p{(i + 1).ToString().PadLeft(width, '0')}";
+ var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, outExt, options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue;
+
+ using (var input = File.OpenRead(sourcePath))
+ using (var pngStream = new MemoryStream())
+ {
+ Conversion.SavePng(pngStream, input, page: i, leaveOpen: false, password: null, options: renderOptions);
+ pngStream.Position = 0;
+
+ using var image = new ImageMagick.MagickImage(pngStream);
+ ApplyTransforms(image, outExt, options);
+ ApplyEncoding(image, outExt, options);
+ image.Write(path);
+ }
+
+ outputs.Add(path);
+ progress?.Report((i + 1.0) / pageCount);
+ }
+
+ return outputs.Count > 0
+ ? ConvertResult.Ok(sourcePath, outputs)
+ : ConvertResult.Skip(sourcePath, "모든 페이지가 이미 존재해 건너뜁니다.");
+ }
+
+ private static void ApplyTransforms(ImageMagick.IMagickImage image, string outputExtension, ConvertOptions options)
+ {
+ var alphaCapable = outputExtension is ".png" or ".webp" or ".avif" or ".tif" or ".tiff";
+ if ((!alphaCapable || options.FlattenTransparency) && image.HasAlpha)
+ {
+ image.BackgroundColor = new ImageMagick.MagickColor(options.TransparencyBackground);
+ image.Alpha(ImageMagick.AlphaOption.Remove);
+ image.Alpha(ImageMagick.AlphaOption.Off);
+ }
+
+ if (options.MaxLongEdgePixels is int maxLong && maxLong > 0
+ && (image.Width > (uint)maxLong || image.Height > (uint)maxLong))
+ {
+ image.Resize(new ImageMagick.MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false });
+ }
+ }
+
+ private static void ApplyEncoding(ImageMagick.IMagickImage image, string outputExtension, ConvertOptions options)
+ {
+ switch (outputExtension)
+ {
+ case ".jpg":
+ case ".jpeg":
+ image.Quality = (uint)Math.Clamp(options.Jpeg.Quality, 1, 100);
+ image.Format = ImageMagick.MagickFormat.Jpeg;
+ break;
+ case ".png":
+ image.Format = ImageMagick.MagickFormat.Png;
+ break;
+ case ".webp":
+ image.Quality = (uint)Math.Clamp(options.Webp.Quality, 1, 100);
+ if (options.Webp.Lossless)
+ image.Settings.SetDefine(ImageMagick.MagickFormat.WebP, "lossless", "true");
+ image.Format = ImageMagick.MagickFormat.WebP;
+ break;
+ case ".avif":
+ image.Quality = (uint)Math.Clamp(options.Avif.Quality, 1, 100);
+ image.Settings.SetDefine(ImageMagick.MagickFormat.Avif, "speed", Math.Clamp(options.Avif.Speed, 0, 10).ToString());
+ image.Format = ImageMagick.MagickFormat.Avif;
+ break;
+ case ".bmp":
+ image.Format = ImageMagick.MagickFormat.Bmp;
+ break;
+ case ".tif":
+ case ".tiff":
+ if (!string.IsNullOrWhiteSpace(options.Tiff.Compression))
+ image.Settings.SetDefine(ImageMagick.MagickFormat.Tiff, "compression", options.Tiff.Compression);
+ image.Format = ImageMagick.MagickFormat.Tiff;
+ break;
+ }
+ }
+}
diff --git a/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj b/src/Everything2Everything.Core/Everything2Everything.Core.csproj
similarity index 79%
rename from src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj
rename to src/Everything2Everything.Core/Everything2Everything.Core.csproj
index ce3376c..80ed443 100644
--- a/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj
+++ b/src/Everything2Everything.Core/Everything2Everything.Core.csproj
@@ -1,7 +1,8 @@
- net9.0-windows
+ net9.0-windows10.0.19041.0
+ 10.0.17763.0
enable
enable
latest
@@ -16,6 +17,7 @@
+
diff --git a/src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs b/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
similarity index 74%
rename from src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs
rename to src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
index 96ef52f..0099dc4 100644
--- a/src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs
+++ b/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
@@ -1,8 +1,8 @@
-using EverythingToJpeg.Core.Providers;
+using Everything2Everything.Core.Providers;
-namespace EverythingToJpeg.Core;
+namespace Everything2Everything.Core;
-public static class EverythingToJpegBootstrap
+public static class Everything2EverythingBootstrap
{
public static ConversionEngine CreateDefault()
{
@@ -16,6 +16,7 @@ public static class EverythingToJpegBootstrap
new Converters.DocxProvider(pdf),
new Converters.HtmlProvider(),
new Converters.HwpxProvider(),
+ new Converters.OcrProvider(pdf),
};
return new ConversionEngine(new ProviderRegistry(providers));
}
diff --git a/src/EverythingToJpeg.Core/HistoryStorage.cs b/src/Everything2Everything.Core/HistoryStorage.cs
similarity index 96%
rename from src/EverythingToJpeg.Core/HistoryStorage.cs
rename to src/Everything2Everything.Core/HistoryStorage.cs
index 2e289b2..c22d6a0 100644
--- a/src/EverythingToJpeg.Core/HistoryStorage.cs
+++ b/src/Everything2Everything.Core/HistoryStorage.cs
@@ -1,13 +1,13 @@
using System.Text.Json;
using System.Text.Json.Serialization;
-namespace EverythingToJpeg.Core;
+namespace Everything2Everything.Core;
public static class HistoryStorage
{
private static readonly string Dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "EverythingToJpeg");
+ "Everything2Everything");
private static readonly string FilePath = Path.Combine(Dir, "history.jsonl");
diff --git a/src/EverythingToJpeg.Core/HistoryStore.cs b/src/Everything2Everything.Core/HistoryStore.cs
similarity index 95%
rename from src/EverythingToJpeg.Core/HistoryStore.cs
rename to src/Everything2Everything.Core/HistoryStore.cs
index 3f4515c..3435c8b 100644
--- a/src/EverythingToJpeg.Core/HistoryStore.cs
+++ b/src/Everything2Everything.Core/HistoryStore.cs
@@ -1,6 +1,6 @@
using System.Collections.ObjectModel;
-namespace EverythingToJpeg.Core;
+namespace Everything2Everything.Core;
public sealed record HistoryEntry(
DateTime Timestamp,
diff --git a/src/EverythingToJpeg.Core/OutputPathHelper.cs b/src/Everything2Everything.Core/OutputPathHelper.cs
similarity index 78%
rename from src/EverythingToJpeg.Core/OutputPathHelper.cs
rename to src/Everything2Everything.Core/OutputPathHelper.cs
index dad1695..a08320c 100644
--- a/src/EverythingToJpeg.Core/OutputPathHelper.cs
+++ b/src/Everything2Everything.Core/OutputPathHelper.cs
@@ -1,4 +1,4 @@
-namespace EverythingToJpeg.Core;
+namespace Everything2Everything.Core;
internal static class OutputPathHelper
{
@@ -6,10 +6,12 @@ internal static class OutputPathHelper
string outputDirectory,
string baseName,
string? pageSuffix,
+ string outputExtension,
NameCollision collision)
{
var safe = SanitizeFileName(baseName);
- var fileName = string.IsNullOrEmpty(pageSuffix) ? $"{safe}.jpg" : $"{safe}{pageSuffix}.jpg";
+ var ext = NormalizeExtension(outputExtension);
+ var fileName = string.IsNullOrEmpty(pageSuffix) ? $"{safe}{ext}" : $"{safe}{pageSuffix}{ext}";
var fullPath = Path.Combine(outputDirectory, fileName);
if (!File.Exists(fullPath)) return fullPath;
@@ -25,8 +27,8 @@ internal static class OutputPathHelper
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");
+ ? Path.Combine(outputDirectory, $"{safe} ({i}){ext}")
+ : Path.Combine(outputDirectory, $"{safe}{pageSuffix} ({i}){ext}");
if (!File.Exists(candidate)) return candidate;
}
return fullPath;
@@ -46,4 +48,10 @@ internal static class OutputPathHelper
}
return new string(buffer);
}
+
+ private static string NormalizeExtension(string ext)
+ {
+ if (string.IsNullOrWhiteSpace(ext)) return ".jpg";
+ return ext.StartsWith('.') ? ext.ToLowerInvariant() : "." + ext.ToLowerInvariant();
+ }
}
diff --git a/src/EverythingToJpeg.Core/PreviewService.cs b/src/Everything2Everything.Core/PreviewService.cs
similarity index 97%
rename from src/EverythingToJpeg.Core/PreviewService.cs
rename to src/Everything2Everything.Core/PreviewService.cs
index eb28fd9..97a767c 100644
--- a/src/EverythingToJpeg.Core/PreviewService.cs
+++ b/src/Everything2Everything.Core/PreviewService.cs
@@ -5,7 +5,7 @@ using PhotoSauce.MagicScaler;
using PhotoSauce.NativeCodecs.Libheif;
using SkiaSharp;
-namespace EverythingToJpeg.Core;
+namespace Everything2Everything.Core;
public sealed record PreviewResult(BitmapSource? Image, string? Reason, string? Dimensions, int? PageCount);
@@ -64,7 +64,7 @@ public static class PreviewService
if (Interlocked.Exchange(ref _heifConfigured, 1) == 0)
CodecManager.Configure(c => c.UseLibheif());
- var tempPng = Path.Combine(Path.GetTempPath(), $"e2j_pv_{Guid.NewGuid():N}.png");
+ var tempPng = Path.Combine(Path.GetTempPath(), $"e2e_pv_{Guid.NewGuid():N}.png");
try
{
MagicImageProcessor.ProcessImage(path, tempPng, ProcessImageSettings.Default);
diff --git a/src/Everything2Everything.Core/Providers/ConversionPair.cs b/src/Everything2Everything.Core/Providers/ConversionPair.cs
new file mode 100644
index 0000000..07fe8dc
--- /dev/null
+++ b/src/Everything2Everything.Core/Providers/ConversionPair.cs
@@ -0,0 +1,15 @@
+namespace Everything2Everything.Core.Providers;
+
+public sealed record ConversionPair(string InputExtension, string OutputExtension)
+{
+ public static ConversionPair Of(string input, string output)
+ => new(Normalize(input), Normalize(output));
+
+ public static string Normalize(string ext)
+ {
+ if (string.IsNullOrWhiteSpace(ext))
+ throw new ArgumentException("확장자가 비어 있습니다.", nameof(ext));
+ var trimmed = ext.Trim().ToLowerInvariant();
+ return trimmed.StartsWith('.') ? trimmed : "." + trimmed;
+ }
+}
diff --git a/src/EverythingToJpeg.Core/Providers/IConverterProvider.cs b/src/Everything2Everything.Core/Providers/IConverterProvider.cs
similarity index 90%
rename from src/EverythingToJpeg.Core/Providers/IConverterProvider.cs
rename to src/Everything2Everything.Core/Providers/IConverterProvider.cs
index 01b93eb..402ddad 100644
--- a/src/EverythingToJpeg.Core/Providers/IConverterProvider.cs
+++ b/src/Everything2Everything.Core/Providers/IConverterProvider.cs
@@ -1,4 +1,4 @@
-namespace EverythingToJpeg.Core.Providers;
+namespace Everything2Everything.Core.Providers;
public interface IConverterProvider
{
@@ -9,6 +9,7 @@ public interface IConverterProvider
Task ConvertAsync(
string sourcePath,
string outputDirectory,
+ string outputExtension,
ConvertOptions options,
IProgress? progress,
CancellationToken cancellationToken);
diff --git a/src/Everything2Everything.Core/Providers/ProviderCapability.cs b/src/Everything2Everything.Core/Providers/ProviderCapability.cs
new file mode 100644
index 0000000..e8688df
--- /dev/null
+++ b/src/Everything2Everything.Core/Providers/ProviderCapability.cs
@@ -0,0 +1,75 @@
+namespace Everything2Everything.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 SupportedConversions,
+ ProviderStatus Status,
+ string Summary,
+ IReadOnlyList ExternalDependencies,
+ string? RoadmapNote = null)
+{
+ public bool CanRegisterContextMenu => Status is ProviderStatus.Available or ProviderStatus.Preview or ProviderStatus.RequiresExternal;
+ public bool IsImplemented => Status is not ProviderStatus.ComingSoon and not ProviderStatus.Disabled;
+
+ public IReadOnlyList InputExtensions
+ => SupportedConversions
+ .Select(p => p.InputExtension)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(e => e, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ public IReadOnlyList OutputExtensions
+ => SupportedConversions
+ .Select(p => p.OutputExtension)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(e => e, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ public bool Supports(string inputExtension, string outputExtension)
+ {
+ var input = ConversionPair.Normalize(inputExtension);
+ var output = ConversionPair.Normalize(outputExtension);
+ return SupportedConversions.Any(p =>
+ string.Equals(p.InputExtension, input, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(p.OutputExtension, output, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public IEnumerable OutputsFor(string inputExtension)
+ {
+ var input = ConversionPair.Normalize(inputExtension);
+ return SupportedConversions
+ .Where(p => string.Equals(p.InputExtension, input, StringComparison.OrdinalIgnoreCase))
+ .Select(p => p.OutputExtension)
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+ }
+
+ public static IReadOnlyList PairsFromMatrix(IEnumerable inputs, IEnumerable outputs)
+ {
+ var inputList = inputs.Select(ConversionPair.Normalize).ToList();
+ var outputList = outputs.Select(ConversionPair.Normalize).ToList();
+ var pairs = new List(inputList.Count * outputList.Count);
+ foreach (var i in inputList)
+ foreach (var o in outputList)
+ pairs.Add(new ConversionPair(i, o));
+ return pairs;
+ }
+
+ public static IReadOnlyList PairsToSingleOutput(IEnumerable inputs, string output)
+ => inputs.Select(i => ConversionPair.Of(i, output)).ToList();
+}
diff --git a/src/Everything2Everything.Core/Providers/ProviderRegistry.cs b/src/Everything2Everything.Core/Providers/ProviderRegistry.cs
new file mode 100644
index 0000000..5d538b5
--- /dev/null
+++ b/src/Everything2Everything.Core/Providers/ProviderRegistry.cs
@@ -0,0 +1,80 @@
+namespace Everything2Everything.Core.Providers;
+
+public sealed class ProviderRegistry
+{
+ private readonly List _providers;
+ private readonly Dictionary<(string Input, string Output), IConverterProvider> _byPair
+ = new(PairComparer.Instance);
+ private readonly Dictionary> _outputsByInput
+ = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _allInputs = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _allOutputs = new(StringComparer.OrdinalIgnoreCase);
+
+ public ProviderRegistry(IEnumerable providers)
+ {
+ _providers = providers.ToList();
+ foreach (var provider in _providers)
+ {
+ if (!provider.Capability.IsImplemented) continue;
+ foreach (var pair in provider.Capability.SupportedConversions)
+ {
+ var key = (pair.InputExtension, pair.OutputExtension);
+ _byPair.TryAdd(key, provider);
+ if (!_outputsByInput.TryGetValue(pair.InputExtension, out var list))
+ _outputsByInput[pair.InputExtension] = list = new List();
+ if (!list.Contains(pair.OutputExtension, StringComparer.OrdinalIgnoreCase))
+ list.Add(pair.OutputExtension);
+ _allInputs.Add(pair.InputExtension);
+ _allOutputs.Add(pair.OutputExtension);
+ }
+ }
+ }
+
+ public IReadOnlyList All => _providers;
+
+ public IEnumerable Implemented => _providers.Where(p => p.Capability.IsImplemented);
+
+ public IEnumerable ComingSoon => _providers.Where(p => p.Capability.Status == ProviderStatus.ComingSoon);
+
+ public bool TryGet(string sourcePath, string outputExtension, out IConverterProvider? provider)
+ {
+ var input = ConversionPair.Normalize(Path.GetExtension(sourcePath));
+ var output = ConversionPair.Normalize(outputExtension);
+ return _byPair.TryGetValue((input, output), out provider);
+ }
+
+ public IConverterProvider? FindByPair(string inputExtension, string outputExtension)
+ {
+ var key = (ConversionPair.Normalize(inputExtension), ConversionPair.Normalize(outputExtension));
+ return _byPair.TryGetValue(key, out var p) ? p : null;
+ }
+
+ public IReadOnlyList OutputsForInput(string inputExtension)
+ {
+ var input = ConversionPair.Normalize(inputExtension);
+ return _outputsByInput.TryGetValue(input, out var list)
+ ? list.OrderBy(e => e, StringComparer.OrdinalIgnoreCase).ToList()
+ : Array.Empty();
+ }
+
+ public IReadOnlyList OutputsForFile(string sourcePath)
+ => OutputsForInput(Path.GetExtension(sourcePath));
+
+ public IReadOnlyCollection AllInputExtensions => _allInputs;
+
+ public IReadOnlyCollection AllOutputExtensions => _allOutputs;
+
+ private sealed class PairComparer : IEqualityComparer<(string, string)>
+ {
+ public static readonly PairComparer Instance = new();
+
+ public bool Equals((string, string) x, (string, string) y)
+ => StringComparer.OrdinalIgnoreCase.Equals(x.Item1, y.Item1)
+ && StringComparer.OrdinalIgnoreCase.Equals(x.Item2, y.Item2);
+
+ public int GetHashCode((string, string) obj)
+ => HashCode.Combine(
+ StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item1 ?? ""),
+ StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item2 ?? ""));
+ }
+}
diff --git a/src/EverythingToJpeg.Shell/EverythingToJpeg.Shell.vcxproj b/src/Everything2Everything.Shell/Everything2Everything.Shell.vcxproj
similarity index 95%
rename from src/EverythingToJpeg.Shell/EverythingToJpeg.Shell.vcxproj
rename to src/Everything2Everything.Shell/Everything2Everything.Shell.vcxproj
index a2fa155..545efa0 100644
--- a/src/EverythingToJpeg.Shell/EverythingToJpeg.Shell.vcxproj
+++ b/src/Everything2Everything.Shell/Everything2Everything.Shell.vcxproj
@@ -19,7 +19,7 @@
17.0
Win32Proj
{1A2B3C4D-5E6F-7A8B-9C0D-EF1234567890}
- EverythingToJpegShell
+ Everything2EverythingShell
10.0
@@ -55,7 +55,7 @@
Use
pch.h
stdcpp20
- EVERYTHINGTOJPEG_SHELL_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)
+ Everything2Everything_SHELL_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)
/utf-8 %(AdditionalOptions)
diff --git a/src/EverythingToJpeg.Shell/Source.def b/src/Everything2Everything.Shell/Source.def
similarity index 100%
rename from src/EverythingToJpeg.Shell/Source.def
rename to src/Everything2Everything.Shell/Source.def
diff --git a/src/EverythingToJpeg.Shell/dllmain.cpp b/src/Everything2Everything.Shell/dllmain.cpp
similarity index 91%
rename from src/EverythingToJpeg.Shell/dllmain.cpp
rename to src/Everything2Everything.Shell/dllmain.cpp
index 228df55..74a1bb5 100644
--- a/src/EverythingToJpeg.Shell/dllmain.cpp
+++ b/src/Everything2Everything.Shell/dllmain.cpp
@@ -1,7 +1,7 @@
-// EverythingToJpeg shell extension — IExplorerCommand handlers
+// Everything2Everything shell extension — IExplorerCommand handlers
// Two verbs:
-// - QuickCommandHandler → "EverythingToJpeg.exe quick """
-// - DialogCommandHandler → "EverythingToJpeg.exe dialog """
+// - QuickCommandHandler → "Everything2Everything.exe quick """
+// - DialogCommandHandler → "Everything2Everything.exe dialog """
#include "pch.h"
@@ -17,7 +17,7 @@ using Microsoft::WRL::RuntimeClassFlags;
namespace {
-constexpr const wchar_t* kExeFileName = L"EverythingToJpeg.exe";
+constexpr const wchar_t* kExeFileName = L"Everything2Everything.exe";
std::wstring QuoteForCommandLineArg(const std::wstring& arg) {
const std::wstring quotable_chars(L" \\\"");
@@ -153,7 +153,7 @@ class __declspec(uuid("801B2DD3-632C-4731-9510-AEAE09345264"))
: public CommandHandlerBase
{
public:
- static constexpr const wchar_t* Title() { return L"JPEG로 빠른 변환"; }
+ static constexpr const wchar_t* Title() { return L"Everything2Everything: 빠른 변환 (JPEG)"; }
static constexpr const wchar_t* Verb() { return L"quick"; }
};
@@ -162,7 +162,7 @@ class __declspec(uuid("CEBA1DB7-9175-4DF6-A362-490DEA49B598"))
: public CommandHandlerBase
{
public:
- static constexpr const wchar_t* Title() { return L"JPEG로 변환…"; }
+ static constexpr const wchar_t* Title() { return L"Everything2Everything: 변환…"; }
static constexpr const wchar_t* Verb() { return L"dialog"; }
};
diff --git a/src/EverythingToJpeg.Shell/framework.h b/src/Everything2Everything.Shell/framework.h
similarity index 100%
rename from src/EverythingToJpeg.Shell/framework.h
rename to src/Everything2Everything.Shell/framework.h
diff --git a/src/EverythingToJpeg.Shell/packages.config b/src/Everything2Everything.Shell/packages.config
similarity index 100%
rename from src/EverythingToJpeg.Shell/packages.config
rename to src/Everything2Everything.Shell/packages.config
diff --git a/src/EverythingToJpeg.Shell/pch.cpp b/src/Everything2Everything.Shell/pch.cpp
similarity index 100%
rename from src/EverythingToJpeg.Shell/pch.cpp
rename to src/Everything2Everything.Shell/pch.cpp
diff --git a/src/EverythingToJpeg.Shell/pch.h b/src/Everything2Everything.Shell/pch.h
similarity index 100%
rename from src/EverythingToJpeg.Shell/pch.h
rename to src/Everything2Everything.Shell/pch.h
diff --git a/src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs b/src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs
deleted file mode 100644
index d752263..0000000
--- a/src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs
+++ /dev/null
@@ -1,96 +0,0 @@
-using EverythingToJpeg.Core;
-using EverythingToJpeg.Core.Providers;
-using Microsoft.Win32;
-
-namespace EverythingToJpeg.App.Shell;
-
-internal static class ContextMenuRegistrar
-{
- private const string QuickVerb = "EverythingToJpeg.Quick";
- private const string DialogVerb = "EverythingToJpeg.Dialog";
-
- private const string QuickLabel = "JPEG로 빠른 변환";
- private const string DialogLabel = "JPEG로 변환…";
-
- public static void Register(ConversionEngine engine)
- {
- var exe = GetAppExecutablePath();
- var icon = exe + ",0";
-
- foreach (var ext in CollectExtensions(engine))
- {
- WriteVerb(ext, QuickVerb, QuickLabel, icon, $"\"{exe}\" quick \"%1\"");
- WriteVerb(ext, DialogVerb, DialogLabel, icon, $"\"{exe}\" dialog \"%1\"");
- }
-
- NotifyShell();
- }
-
- public static void Unregister(ConversionEngine engine)
- {
- foreach (var ext in CollectExtensions(engine))
- {
- DeleteVerb(ext, QuickVerb);
- DeleteVerb(ext, DialogVerb);
- }
- NotifyShell();
- }
-
- private static IEnumerable CollectExtensions(ConversionEngine engine)
- {
- return engine.Providers.Implemented
- .Where(p => p.Capability.CanRegisterContextMenu)
- .SelectMany(p => p.Capability.Extensions)
- .Select(e => e.StartsWith('.') ? e : "." + e)
- .Select(e => e.ToLowerInvariant())
- .Distinct();
- }
-
- private static void WriteVerb(string ext, string verb, string label, string icon, string command)
- {
- var keyPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{verb}";
- using var verbKey = Registry.CurrentUser.CreateSubKey(keyPath, writable: true)
- ?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {keyPath}");
-
- verbKey.SetValue(null, label, RegistryValueKind.String);
- verbKey.SetValue("Icon", icon, RegistryValueKind.String);
- verbKey.SetValue("MUIVerb", label, RegistryValueKind.String);
-
- using var commandKey = verbKey.CreateSubKey("command", writable: true)
- ?? throw new InvalidOperationException("command 하위 키 생성 실패");
- commandKey.SetValue(null, command, RegistryValueKind.String);
- }
-
- private static void DeleteVerb(string ext, string verb)
- {
- var parentPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell";
- try
- {
- using var parent = Registry.CurrentUser.OpenSubKey(parentPath, writable: true);
- parent?.DeleteSubKeyTree(verb, throwOnMissingSubKey: false);
- }
- catch
- {
- }
- }
-
- private static string GetAppExecutablePath()
- {
- var exe = Environment.ProcessPath;
- if (!string.IsNullOrEmpty(exe) && File.Exists(exe)) return exe;
- return AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar)
- + Path.DirectorySeparatorChar + "EverythingToJpeg.exe";
- }
-
- private static void NotifyShell()
- {
- try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); }
- catch { }
- }
-
- private static class NativeMethods
- {
- [System.Runtime.InteropServices.DllImport("shell32.dll")]
- public static extern void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2);
- }
-}
diff --git a/src/EverythingToJpeg.Core/ConvertOptions.cs b/src/EverythingToJpeg.Core/ConvertOptions.cs
deleted file mode 100644
index 6de34bb..0000000
--- a/src/EverythingToJpeg.Core/ConvertOptions.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-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 int HtmlViewportWidth { get; set; } = 1280;
-
- public int? HtmlViewportHeight { get; set; }
-
- public int HtmlWaitMilliseconds { get; set; } = 2000;
-
- public bool HtmlFullPage { get; set; } = true;
-
- public static ConvertOptions Quick() => new();
-}
diff --git a/src/EverythingToJpeg.Core/Converters/MagickProvider.cs b/src/EverythingToJpeg.Core/Converters/MagickProvider.cs
deleted file mode 100644
index deca1b9..0000000
--- a/src/EverythingToJpeg.Core/Converters/MagickProvider.cs
+++ /dev/null
@@ -1,131 +0,0 @@
-using EverythingToJpeg.Core.Providers;
-using ImageMagick;
-
-namespace EverythingToJpeg.Core.Converters;
-
-public sealed class MagickProvider : IConverterProvider
-{
- private static readonly string[] SingleFrameExtensions =
- {
- ".png", ".bmp", ".jpg", ".jpeg", ".jpe", ".webp", ".avif", ".psd",
- ".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ".rw2", ".srw", ".pef", ".raw",
- };
-
- private static readonly string[] MultiFrameExtensions = { ".gif", ".tif", ".tiff" };
-
- public ProviderCapability Capability { get; } = new(
- Id: "magick",
- DisplayName: "이미지·RAW·애니메이션",
- Extensions: SingleFrameExtensions.Concat(MultiFrameExtensions).ToList(),
- Status: ProviderStatus.Available,
- Summary: "PNG, BMP, JPEG, WebP, AVIF, PSD, GIF, TIFF, RAW(NEF/CR2/ARW/DNG/RAF/ORF/RW2 등)을 JPEG로 변환합니다.",
- ExternalDependencies: Array.Empty(),
- RoadmapNote: null);
-
- public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default)
- => Task.FromResult(ProviderAvailability.Ready);
-
- public Task ConvertAsync(
- string sourcePath,
- string outputDirectory,
- ConvertOptions options,
- IProgress? progress,
- CancellationToken cancellationToken)
- {
- return Task.Run(() => ConvertCore(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken);
- }
-
- private static ConvertResult ConvertCore(
- string sourcePath,
- string outputDirectory,
- ConvertOptions options,
- IProgress? progress,
- CancellationToken cancellationToken)
- {
- var ext = Path.GetExtension(sourcePath).ToLowerInvariant();
- var baseName = Path.GetFileNameWithoutExtension(sourcePath);
- var isMultiFrame = MultiFrameExtensions.Contains(ext);
-
- if (isMultiFrame)
- {
- using var collection = new MagickImageCollection(sourcePath);
- if (collection.Count == 0)
- return ConvertResult.Fail(sourcePath, "이미지 프레임을 읽지 못했습니다.");
-
- if (collection.Count == 1)
- {
- var single = collection[0];
- ApplyCommonTransforms(single, options);
- var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision);
- if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
- return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
- WriteJpeg(single, path, options.Quality);
- progress?.Report(1.0);
- return ConvertResult.Ok(sourcePath, new[] { path });
- }
-
- collection.Coalesce();
- var outputs = new List();
- var width = (int)Math.Ceiling(Math.Log10(collection.Count + 1));
- for (var i = 0; i < collection.Count; i++)
- {
- cancellationToken.ThrowIfCancellationRequested();
- var frame = collection[i];
- ApplyCommonTransforms(frame, options);
- var suffix = $"_{(i + 1).ToString().PadLeft(width, '0')}";
- var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, options.OnCollision);
- if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue;
- WriteJpeg(frame, path, options.Quality);
- outputs.Add(path);
- progress?.Report((i + 1.0) / collection.Count);
- }
-
- return outputs.Count > 0
- ? ConvertResult.Ok(sourcePath, outputs)
- : ConvertResult.Skip(sourcePath, "모든 프레임이 이미 존재해 건너뜁니다.");
- }
- else
- {
- using var image = new MagickImage(sourcePath);
- ApplyCommonTransforms(image, options);
- var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision);
- if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
- return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
- WriteJpeg(image, path, options.Quality);
- progress?.Report(1.0);
- return ConvertResult.Ok(sourcePath, new[] { path });
- }
- }
-
- private static void ApplyCommonTransforms(IMagickImage image, ConvertOptions options)
- {
- try { image.AutoOrient(); } catch { }
-
- if (options.FlattenTransparency && image.HasAlpha)
- {
- image.BackgroundColor = new MagickColor(options.TransparencyBackground);
- image.Alpha(AlphaOption.Remove);
- image.Alpha(AlphaOption.Off);
- }
-
- if (options.MaxLongEdgePixels is int maxLong && maxLong > 0)
- {
- var w = (int)image.Width;
- var h = (int)image.Height;
- if (w > maxLong || h > maxLong)
- {
- var geom = new MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false };
- image.Resize(geom);
- }
- }
-
- image.Format = MagickFormat.Jpeg;
- }
-
- private static void WriteJpeg(IMagickImage image, string path, int quality)
- {
- image.Quality = (uint)Math.Clamp(quality, 1, 100);
- image.Format = MagickFormat.Jpeg;
- image.Write(path);
- }
-}
diff --git a/src/EverythingToJpeg.Core/Converters/PdfProvider.cs b/src/EverythingToJpeg.Core/Converters/PdfProvider.cs
deleted file mode 100644
index 2cd40ec..0000000
--- a/src/EverythingToJpeg.Core/Converters/PdfProvider.cs
+++ /dev/null
@@ -1,98 +0,0 @@
-using EverythingToJpeg.Core.Providers;
-using PDFtoImage;
-using SkiaSharp;
-
-namespace EverythingToJpeg.Core.Converters;
-
-public sealed class PdfProvider : IConverterProvider
-{
- public ProviderCapability Capability { get; } = new(
- Id: "pdf",
- DisplayName: "PDF",
- Extensions: new[] { ".pdf" },
- Status: ProviderStatus.Available,
- Summary: "PDF 각 페이지를 JPEG로 변환합니다.",
- ExternalDependencies: Array.Empty(),
- RoadmapNote: null);
-
- public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default)
- => Task.FromResult(ProviderAvailability.Ready);
-
- public Task ConvertAsync(
- string sourcePath,
- string outputDirectory,
- ConvertOptions options,
- IProgress? progress,
- CancellationToken cancellationToken)
- {
- return Task.Run(() => ConvertCore(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken);
- }
-
- internal ConvertResult ConvertCore(
- string sourcePath,
- string outputDirectory,
- ConvertOptions options,
- IProgress? progress,
- CancellationToken cancellationToken)
- {
- var baseName = Path.GetFileNameWithoutExtension(sourcePath);
-
- int pageCount;
- using (var probe = File.OpenRead(sourcePath))
- {
- pageCount = Conversion.GetPageCount(probe);
- }
- if (pageCount <= 0)
- return ConvertResult.Fail(sourcePath, "PDF에 페이지가 없습니다.");
-
- var renderOptions = new RenderOptions
- {
- Dpi = options.PdfDpi,
- BackgroundColor = SKColors.White,
- WithAnnotations = true,
- WithFormFill = true,
- UseTiling = true,
- };
-
- var width = (int)Math.Ceiling(Math.Log10(pageCount + 1));
- var outputs = new List();
-
- for (var i = 0; i < pageCount; i++)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var suffix = pageCount == 1 ? null : $"_p{(i + 1).ToString().PadLeft(width, '0')}";
- var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, options.OnCollision);
- if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue;
-
- using (var input = File.OpenRead(sourcePath))
- {
- Conversion.SaveJpeg(path, input, page: i, leaveOpen: false, password: null, options: renderOptions);
- }
-
- if (options.MaxLongEdgePixels is int maxLong && maxLong > 0)
- {
- ResizeIfNeeded(path, maxLong, options.Quality);
- }
-
- outputs.Add(path);
- progress?.Report((i + 1.0) / pageCount);
- }
-
- return outputs.Count > 0
- ? ConvertResult.Ok(sourcePath, outputs)
- : ConvertResult.Skip(sourcePath, "모든 페이지가 이미 존재해 건너뜁니다.");
- }
-
- private static void ResizeIfNeeded(string jpegPath, int maxLongEdge, int quality)
- {
- using var image = new ImageMagick.MagickImage(jpegPath);
- if (image.Width <= (uint)maxLongEdge && image.Height <= (uint)maxLongEdge) return;
-
- var geom = new ImageMagick.MagickGeometry((uint)maxLongEdge, (uint)maxLongEdge) { IgnoreAspectRatio = false };
- image.Resize(geom);
- image.Quality = (uint)Math.Clamp(quality, 1, 100);
- image.Format = ImageMagick.MagickFormat.Jpeg;
- image.Write(jpegPath);
- }
-}
diff --git a/src/EverythingToJpeg.Core/Providers/ProviderCapability.cs b/src/EverythingToJpeg.Core/Providers/ProviderCapability.cs
deleted file mode 100644
index 8981a5a..0000000
--- a/src/EverythingToJpeg.Core/Providers/ProviderCapability.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-namespace EverythingToJpeg.Core.Providers;
-
-public enum ProviderStatus
-{
- Available,
- Preview,
- RequiresExternal,
- ComingSoon,
- Disabled,
-}
-
-public sealed record ExternalDependency(
- string Name,
- string Description,
- string? DownloadUrl = null,
- bool IsRequired = true);
-
-public sealed record ProviderCapability(
- string Id,
- string DisplayName,
- IReadOnlyList Extensions,
- ProviderStatus Status,
- string Summary,
- IReadOnlyList ExternalDependencies,
- string? RoadmapNote = null)
-{
- public bool CanRegisterContextMenu => Status is ProviderStatus.Available or ProviderStatus.Preview or ProviderStatus.RequiresExternal;
- public bool IsImplemented => Status is not ProviderStatus.ComingSoon and not ProviderStatus.Disabled;
-}
diff --git a/src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs b/src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs
deleted file mode 100644
index 0e85979..0000000
--- a/src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-namespace EverythingToJpeg.Core.Providers;
-
-public sealed class ProviderRegistry
-{
- private readonly List _providers;
- private readonly Dictionary _byExtension = new(StringComparer.OrdinalIgnoreCase);
-
- public ProviderRegistry(IEnumerable providers)
- {
- _providers = providers.ToList();
- foreach (var provider in _providers)
- {
- if (!provider.Capability.IsImplemented) continue;
- foreach (var ext in provider.Capability.Extensions)
- {
- _byExtension[Normalize(ext)] = provider;
- }
- }
- }
-
- public IReadOnlyList All => _providers;
-
- public IEnumerable Implemented => _providers.Where(p => p.Capability.IsImplemented);
-
- public IEnumerable ComingSoon => _providers.Where(p => p.Capability.Status == ProviderStatus.ComingSoon);
-
- public bool TryGetForFile(string sourcePath, out IConverterProvider? provider)
- {
- var ext = Normalize(Path.GetExtension(sourcePath));
- return _byExtension.TryGetValue(ext, out provider);
- }
-
- public IConverterProvider? FindByExtension(string ext)
- => _byExtension.TryGetValue(Normalize(ext), out var p) ? p : null;
-
- public IReadOnlyCollection ImplementedExtensions => _byExtension.Keys;
-
- private static string Normalize(string ext)
- => ext.StartsWith('.') ? ext.ToLowerInvariant() : "." + ext.ToLowerInvariant();
-}