chore: pivot 시작 — origin/master 코드 흡수 (Everything2Everything 베이스)
This commit is contained in:
commit
96861e627d
58 changed files with 5465 additions and 0 deletions
43
.github/workflows/build.yml
vendored
Normal file
43
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Setup MSBuild
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
|
||||
- name: Restore + Build solution
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet build EverythingToJpeg.slnx -c Release --nologo
|
||||
|
||||
- name: Build MSIX
|
||||
shell: pwsh
|
||||
run: |
|
||||
./packaging/GenerateAssets.ps1
|
||||
./packaging/BuildMsix.ps1 -Configuration Release -Platform x64
|
||||
|
||||
- name: Upload MSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: EverythingToJpeg-x64-msix-${{ github.sha }}
|
||||
path: packaging/dist/EverythingToJpeg-x64.msix
|
||||
retention-days: 14
|
||||
98
.github/workflows/release.yml
vendored
Normal file
98
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: '릴리즈 태그 (예: v0.1.0)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Setup MSBuild
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
|
||||
- name: Generate placeholder logos
|
||||
shell: pwsh
|
||||
run: ./packaging/GenerateAssets.ps1
|
||||
|
||||
- name: Decode PFX (if secret provided)
|
||||
id: pfx
|
||||
shell: pwsh
|
||||
env:
|
||||
PFX_BASE64: ${{ secrets.PFX_BASE64 }}
|
||||
run: |
|
||||
if ($env:PFX_BASE64) {
|
||||
$bytes = [Convert]::FromBase64String($env:PFX_BASE64)
|
||||
$path = Join-Path $env:RUNNER_TEMP 'cert.pfx'
|
||||
[IO.File]::WriteAllBytes($path, $bytes)
|
||||
"pfx_path=$path" >> $env:GITHUB_OUTPUT
|
||||
Write-Host '✅ PFX 디코딩 완료 — 서명 모드로 빌드'
|
||||
} else {
|
||||
Write-Host 'ℹ PFX_BASE64 secret 없음 — unsigned 모드로 빌드'
|
||||
}
|
||||
|
||||
- name: Build MSIX (signed if PFX, else unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }}
|
||||
run: |
|
||||
$pfx = '${{ steps.pfx.outputs.pfx_path }}'
|
||||
if ($pfx) {
|
||||
$sec = ConvertTo-SecureString -String $env:PFX_PASSWORD -AsPlainText -Force
|
||||
./packaging/BuildMsix.ps1 -Configuration Release -Platform x64 -Sign -PfxPath $pfx -PfxPassword $sec
|
||||
} else {
|
||||
./packaging/BuildMsix.ps1 -Configuration Release -Platform x64
|
||||
}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: EverythingToJpeg-x64-msix
|
||||
path: packaging/dist/EverythingToJpeg-x64.msix
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/v') || 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
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## EverythingToJpeg
|
||||
|
||||
모든 파일을 우클릭 한 번으로 JPEG 로 변환합니다.
|
||||
|
||||
### 설치 방법
|
||||
1. 아래 `EverythingToJpeg-x64.msix` 다운로드.
|
||||
2. 관리자 PowerShell:
|
||||
```powershell
|
||||
# 자체 서명 인증서를 신뢰 저장소에 등록 (PFX 별도 보유 필요)
|
||||
Import-PfxCertificate -CertStoreLocation Cert:\LocalMachine\TrustedPeople `
|
||||
-FilePath .\EverythingToJpeg-DevCert.pfx `
|
||||
-Password (ConvertTo-SecureString 'EverythingToJpegDev' -AsPlainText -Force)
|
||||
|
||||
# MSIX 사이드로드
|
||||
Add-AppxPackage -Path .\EverythingToJpeg-x64.msix -ForceApplicationShutdown
|
||||
```
|
||||
3. PNG/HEIC/PDF 등 파일 우클릭 → "JPEG로 빠른 변환" 또는 "JPEG로 변환…"
|
||||
|
||||
> 이 패키지는 자체 서명입니다. 첫 PC 1회만 인증서 등록이 필요합니다.
|
||||
|
||||
### 변경사항
|
||||
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# build outputs
|
||||
bin/
|
||||
obj/
|
||||
publish/
|
||||
publish-self/
|
||||
|
||||
# IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
*.user
|
||||
*.suo
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
# rider
|
||||
.idea/
|
||||
|
||||
# Phase 2 packaging artifacts
|
||||
*.pfx
|
||||
*.msix
|
||||
*.msixbundle
|
||||
*.appx
|
||||
*.appxbundle
|
||||
packaging/Layout/
|
||||
packaging/dist/
|
||||
|
||||
# C++ project intermediate
|
||||
src/EverythingToJpeg.Shell/x64/
|
||||
src/EverythingToJpeg.Shell/Win32/
|
||||
src/EverythingToJpeg.Shell/Debug/
|
||||
src/EverythingToJpeg.Shell/Release/
|
||||
src/EverythingToJpeg.Shell/.vs/
|
||||
src/EverythingToJpeg.Shell/Everythi*/
|
||||
*.tlog
|
||||
*.obj
|
||||
*.pch
|
||||
*.iobj
|
||||
*.ipdb
|
||||
*.recipe
|
||||
*.lastbuildstate
|
||||
|
||||
# .NET artifacts dir from BuildMsix.ps1
|
||||
artifacts/
|
||||
|
||||
# NuGet packages restore + nuget.exe cache
|
||||
packages/
|
||||
tools/
|
||||
4
EverythingToJpeg.slnx
Normal file
4
EverythingToJpeg.slnx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<Solution>
|
||||
<Project Path="src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj" />
|
||||
<Project Path="src/EverythingToJpeg.App/EverythingToJpeg.App.csproj" />
|
||||
</Solution>
|
||||
130
README.md
Normal file
130
README.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# EverythingToJpeg
|
||||
|
||||
Windows 우클릭 컨텍스트 메뉴에서 한 방에 JPEG로. PNG · GIF · BMP · TIFF · WebP · AVIF · HEIC · RAW · PSD · PDF · DOCX 를 지원합니다.
|
||||
|
||||
- **빠른 변환** — 다이얼로그 없이 원본 폴더의 `<원본명>_jpeg/` 하위에 즉시 저장
|
||||
- **변환…** — 옵션 다이얼로그(품질, 출력 위치, 이름 충돌, 크기 제한, PDF DPI)
|
||||
- 진행 상황 + 썸네일 + 드래그 & 드롭 (메인 창)
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 1) 빌드
|
||||
|
||||
```powershell
|
||||
# 솔루션 빌드
|
||||
dotnet build EverythingToJpeg.slnx -c Release
|
||||
|
||||
# 단일 폴더 publish (framework-dependent, .NET 9 Desktop Runtime 필요)
|
||||
dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj `
|
||||
-c Release -r win-x64 --self-contained false -o publish
|
||||
|
||||
# .NET 런타임 동봉 (단일 사용자 배포가 편함)
|
||||
dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj `
|
||||
-c Release -r win-x64 --self-contained true `
|
||||
-p:PublishSingleFile=false -o publish-self
|
||||
```
|
||||
|
||||
산출물: `publish\EverythingToJpeg.exe`
|
||||
|
||||
### 2) 컨텍스트 메뉴 등록
|
||||
|
||||
`EverythingToJpeg.exe`를 한 번 실행 → "컨텍스트 메뉴 등록" 클릭. 또는 CLI:
|
||||
|
||||
```powershell
|
||||
.\EverythingToJpeg.exe register
|
||||
.\EverythingToJpeg.exe unregister
|
||||
```
|
||||
|
||||
> Windows 11 메인 우클릭 메뉴가 아니라 "추가 옵션 표시(Shift+우클릭)" 메뉴에 노출됩니다. 메인 메뉴 노출은 Phase 2에서 IExplorerCommand + MSIX 로 추가 예정.
|
||||
|
||||
### 3) 사용
|
||||
|
||||
- 파일 우클릭 → "추가 옵션 표시" → **JPEG로 빠른 변환** 또는 **JPEG로 변환…**
|
||||
- 또는 메인 창에 파일/폴더를 끌어다 놓기
|
||||
|
||||
## 지원 현황
|
||||
|
||||
| 형식 | 상태 | 참고 |
|
||||
|---|---|---|
|
||||
| PNG · BMP · JPEG · WebP · AVIF · PSD · TIFF · GIF · RAW(NEF/CR2/CR3/ARW/DNG/RAF/ORF/RW2/SRW/PEF) | ✅ 준비됨 | Magick.NET 14.x |
|
||||
| HEIC · HEIF | ✅ 준비됨 | PhotoSauce + libheif 디코드 |
|
||||
| PDF | ✅ 준비됨 | PDFtoImage(PDFium) |
|
||||
| HTML · HTM | ✅ 준비됨 | WebView2 헤드리스 + CDP `Page.captureScreenshot` 풀페이지 |
|
||||
| DOCX · DOC | ⚙ 외부 도구 필요 | Microsoft Word 또는 LibreOffice 자동 감지 |
|
||||
| HWP · HWPX | ⚙ 외부 도구 필요 | 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
|
||||
|
||||
## 로드맵
|
||||
|
||||
| 단계 | 상태 | 내용 |
|
||||
|---|---|---|
|
||||
| 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 가용성 자동 체크, 사이드바에 외부 도구 필요 안내 |
|
||||
|
||||
## 키보드 단축키
|
||||
|
||||
| 키 | 동작 |
|
||||
|---|---|
|
||||
| Ctrl+O | 파일 추가 |
|
||||
| Ctrl+Enter | Process Queue (변환 시작) |
|
||||
| Esc | 창 닫기 |
|
||||
| F5 | 통계 새로고침 |
|
||||
| Active Queue 행 클릭 | 우측 Preview에 즉시 표시 |
|
||||
| Past Results 행 클릭 | 원본 파일이 있으면 Preview 표시 |
|
||||
|
||||
## 두 가지 사용 방식
|
||||
|
||||
### A) Portable EXE — 가장 가벼움 (Phase 1)
|
||||
- `dotnet publish` 산출물 그대로 사용
|
||||
- 우클릭 → **추가 옵션 표시** → "JPEG로 빠른 변환" / "JPEG로 변환…"
|
||||
- 인증서·서명 불필요
|
||||
|
||||
### B) MSIX 패키지 — Win11 메인 메뉴 노출 (Phase 2)
|
||||
- `packaging/BuildMsix.ps1` 로 MSIX 빌드
|
||||
- 자체 서명 인증서를 `LocalMachine\TrustedPeople`에 임포트 후 사이드로드
|
||||
- 우클릭 → 바로 메인 메뉴에 항목 노출
|
||||
- 자세한 절차는 [packaging/README.md](packaging/README.md)
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
everythingToJpeg/
|
||||
├── EverythingToJpeg.slnx
|
||||
└── src/
|
||||
├── EverythingToJpeg.Core/ — 변환 엔진, Provider 추상화
|
||||
│ ├── Providers/ — IConverterProvider + Capability 메타데이터
|
||||
│ ├── Converters/ — Magick / Heic / Pdf / Docx / Html / Hwpx
|
||||
│ ├── ConversionEngine.cs
|
||||
│ └── EverythingToJpegBootstrap.cs
|
||||
└── EverythingToJpeg.App/ — WPF + CLI 통합 진입점
|
||||
├── App.xaml(.cs) — CLI 라우터
|
||||
├── Cli/CliRouter.cs — verb: quick / dialog / register / diagnose
|
||||
├── Shell/ContextMenuRegistrar.cs — HKCU 레지스트리 등록
|
||||
└── Views/ — Fluent UI 화면
|
||||
```
|
||||
|
||||
## Provider 전략 (확장 포인트)
|
||||
|
||||
새 형식을 지원하려면 `IConverterProvider`를 구현하고 `EverythingToJpegBootstrap.CreateDefault()`에 등록합니다. `ProviderCapability`에 다음을 명시하세요:
|
||||
|
||||
- `Status` — `Available` / `Preview` / `RequiresExternal` / `ComingSoon` / `Disabled`
|
||||
- `Extensions` — 자동 라우팅 + 컨텍스트 메뉴 등록 키
|
||||
- `ExternalDependencies` — UI에 자동 노출되는 외부 도구
|
||||
- `RoadmapNote` — 사용자에게 보여줄 향후 계획
|
||||
|
||||
`ComingSoon` 상태는 메인 창의 "지원 형식" 섹션에 자동 노출되지만 컨텍스트 메뉴 등록에서는 자동 제외됩니다.
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT (예정).
|
||||
BIN
packaging/Assets/Square150x150Logo.png
Normal file
BIN
packaging/Assets/Square150x150Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
packaging/Assets/Square44x44Logo.png
Normal file
BIN
packaging/Assets/Square44x44Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 670 B |
BIN
packaging/Assets/StoreLogo.png
Normal file
BIN
packaging/Assets/StoreLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 729 B |
BIN
packaging/Assets/Wide310x150Logo.png
Normal file
BIN
packaging/Assets/Wide310x150Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
68
packaging/BuildAndSign.ps1
Normal file
68
packaging/BuildAndSign.ps1
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#Requires -Version 5.1
|
||||
# 한방에 빌드+자체서명: 인증서 자동 생성 → MSIX 빌드 → 서명까지 일관 처리.
|
||||
# 산출:
|
||||
# - packaging/dist/EverythingToJpeg-x64.msix (서명됨)
|
||||
# - packaging/EverythingToJpeg-DevCert.pfx (5대 PC 신뢰 등록용)
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Subject = 'CN=EverythingToJpegDev',
|
||||
[string]$Password = 'EverythingToJpegDev',
|
||||
[string]$Configuration = 'Release',
|
||||
[string]$Platform = 'x64'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$packagingDir = $PSScriptRoot
|
||||
$pfxPath = Join-Path $packagingDir 'EverythingToJpeg-DevCert.pfx'
|
||||
$securePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force
|
||||
|
||||
# ---- 1) 인증서 ----
|
||||
$existing = Get-ChildItem -Path 'Cert:\CurrentUser\My' -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Subject -eq $Subject } |
|
||||
Sort-Object NotAfter -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $existing) {
|
||||
Write-Host "[1/3] 자체 서명 인증서 생성: $Subject"
|
||||
$existing = New-SelfSignedCertificate `
|
||||
-Type CodeSigningCert `
|
||||
-Subject $Subject `
|
||||
-KeyAlgorithm RSA `
|
||||
-KeyLength 3072 `
|
||||
-Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider' `
|
||||
-KeyExportPolicy Exportable `
|
||||
-KeyUsage DigitalSignature `
|
||||
-CertStoreLocation 'Cert:\CurrentUser\My' `
|
||||
-HashAlgorithm SHA256 `
|
||||
-NotAfter (Get-Date).AddYears(5) `
|
||||
-FriendlyName 'EverythingToJpeg Dev'
|
||||
}
|
||||
else {
|
||||
Write-Host "[1/3] 기존 인증서 재사용 (Thumbprint $($existing.Thumbprint))"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $pfxPath)) {
|
||||
Export-PfxCertificate -Cert $existing -FilePath $pfxPath -Password $securePassword | Out-Null
|
||||
Write-Host " PFX 내보냄: $pfxPath"
|
||||
}
|
||||
|
||||
# ---- 2) MSIX 빌드 + 서명 ----
|
||||
Write-Host "[2/3] MSIX 빌드 + 서명"
|
||||
& (Join-Path $packagingDir 'BuildMsix.ps1') `
|
||||
-Configuration $Configuration `
|
||||
-Platform $Platform `
|
||||
-Sign `
|
||||
-CertThumbprint $existing.Thumbprint
|
||||
|
||||
# ---- 3) 안내 ----
|
||||
Write-Host ''
|
||||
Write-Host '[3/3] 완료. 다음 단계:'
|
||||
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 ' PFX 비밀번호:' $Password
|
||||
Write-Host ''
|
||||
Write-Host ' 3. 우클릭 → JPEG로 빠른 변환 / JPEG로 변환… 이 메인 메뉴에 노출됨.'
|
||||
155
packaging/BuildMsix.ps1
Normal file
155
packaging/BuildMsix.ps1
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
#Requires -Version 5.1
|
||||
# MSIX 빌드 파이프라인.
|
||||
# 1) .NET App publish (framework-dependent)
|
||||
# 2) C++ Shell DLL 빌드
|
||||
# 3) 패키지 Layout 디렉토리 구성
|
||||
# 4) makeappx pack
|
||||
# 5) (선택) signtool sign
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Configuration = 'Release',
|
||||
[string]$Platform = 'x64',
|
||||
[switch]$Sign,
|
||||
[string]$PfxPath,
|
||||
[securestring]$PfxPassword,
|
||||
[string]$CertThumbprint,
|
||||
[switch]$SelfContained
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$packagingDir = $PSScriptRoot
|
||||
$layoutDir = Join-Path $packagingDir 'Layout'
|
||||
$distDir = Join-Path $packagingDir 'dist'
|
||||
$manifestPath = Join-Path $packagingDir 'Package.appxmanifest'
|
||||
$assetsSrc = Join-Path $packagingDir 'Assets'
|
||||
|
||||
$appProj = Join-Path $repoRoot 'src\EverythingToJpeg.App\EverythingToJpeg.App.csproj'
|
||||
$shellProj = Join-Path $repoRoot 'src\EverythingToJpeg.Shell\EverythingToJpeg.Shell.vcxproj'
|
||||
|
||||
function Find-WindowsSdkTool {
|
||||
param([string]$ToolName)
|
||||
$sdkRoots = @(
|
||||
"${env:ProgramFiles(x86)}\Windows Kits\10\bin",
|
||||
"$env:ProgramFiles\Windows Kits\10\bin"
|
||||
) | Where-Object { Test-Path $_ }
|
||||
foreach ($root in $sdkRoots) {
|
||||
$versions = Get-ChildItem -Path $root -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } |
|
||||
Sort-Object Name -Descending
|
||||
foreach ($v in $versions) {
|
||||
$candidate = Join-Path $v.FullName "x64\$ToolName"
|
||||
if (Test-Path $candidate) { return [string]$candidate }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$makeappx = Find-WindowsSdkTool 'makeappx.exe'
|
||||
if (-not $makeappx) { throw 'makeappx.exe를 찾지 못했습니다. Windows 10 SDK가 필요합니다.' }
|
||||
Write-Host "makeappx: $makeappx"
|
||||
|
||||
if ($Sign) {
|
||||
$signtool = Find-WindowsSdkTool 'signtool.exe'
|
||||
if (-not $signtool) { throw 'signtool.exe를 찾지 못했습니다.' }
|
||||
Write-Host "signtool: $signtool"
|
||||
}
|
||||
|
||||
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
if (-not (Test-Path $vswhere)) { throw 'vswhere.exe를 찾지 못했습니다.' }
|
||||
$msbuild = (& $vswhere -latest -find 'MSBuild\**\Bin\MSBuild.exe' | Select-Object -First 1)
|
||||
if (-not $msbuild) { throw 'MSBuild를 찾지 못했습니다.' }
|
||||
Write-Host "msbuild: $msbuild"
|
||||
|
||||
# ---- 1) .NET App publish ----
|
||||
Write-Host ''
|
||||
Write-Host '[1/5] .NET App publish'
|
||||
$publishOut = Join-Path $repoRoot ('artifacts\publish\app-' + $Platform.ToLower())
|
||||
if (Test-Path $publishOut) { Remove-Item $publishOut -Recurse -Force }
|
||||
$rid = if ($Platform -eq 'ARM64') { 'win-arm64' } else { 'win-x64' }
|
||||
$selfFlag = if ($SelfContained) { 'true' } else { 'false' }
|
||||
& dotnet publish $appProj -c $Configuration -r $rid --self-contained $selfFlag -o $publishOut | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish 실패' }
|
||||
|
||||
# ---- 2) C++ Shell DLL ----
|
||||
Write-Host ''
|
||||
Write-Host '[2/5] C++ Shell DLL 빌드'
|
||||
|
||||
# nuget.exe 자동 다운로드 (없으면)
|
||||
$nuget = Get-Command nuget.exe -ErrorAction SilentlyContinue
|
||||
if (-not $nuget) {
|
||||
$nugetExe = Join-Path $repoRoot 'tools\nuget.exe'
|
||||
if (-not (Test-Path $nugetExe)) {
|
||||
New-Item -ItemType Directory -Path (Split-Path $nugetExe) -Force | Out-Null
|
||||
Write-Host ' nuget.exe 다운로드 중…'
|
||||
Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetExe
|
||||
}
|
||||
$nugetCmd = $nugetExe
|
||||
} else {
|
||||
$nugetCmd = $nuget.Source
|
||||
}
|
||||
|
||||
$packagesDir = Join-Path $repoRoot 'packages'
|
||||
& $nugetCmd restore (Join-Path $repoRoot 'src\EverythingToJpeg.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")
|
||||
if (-not (Test-Path $shellDll)) { throw "Shell DLL 산출물 없음: $shellDll" }
|
||||
|
||||
# ---- 3) Layout 디렉토리 ----
|
||||
Write-Host ''
|
||||
Write-Host '[3/5] Layout 디렉토리 구성'
|
||||
if (Test-Path $layoutDir) { Remove-Item $layoutDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $layoutDir | Out-Null
|
||||
|
||||
Copy-Item -Path (Join-Path $publishOut '*') -Destination $layoutDir -Recurse -Force
|
||||
Copy-Item -Path $shellDll -Destination $layoutDir -Force
|
||||
|
||||
$layoutAssets = Join-Path $layoutDir 'Assets'
|
||||
New-Item -ItemType Directory -Path $layoutAssets -Force | Out-Null
|
||||
Copy-Item -Path (Join-Path $assetsSrc '*') -Destination $layoutAssets -Force
|
||||
|
||||
Copy-Item -Path $manifestPath -Destination (Join-Path $layoutDir 'AppxManifest.xml') -Force
|
||||
|
||||
# ---- 4) makeappx pack ----
|
||||
Write-Host ''
|
||||
Write-Host '[4/5] makeappx pack'
|
||||
if (-not (Test-Path $distDir)) { New-Item -ItemType Directory -Path $distDir | Out-Null }
|
||||
$msixPath = Join-Path $distDir ("EverythingToJpeg-$($Platform.ToLower()).msix")
|
||||
if (Test-Path $msixPath) { Remove-Item $msixPath -Force }
|
||||
& $makeappx pack /d $layoutDir /p $msixPath /o | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'makeappx pack 실패' }
|
||||
Write-Host "✅ MSIX 산출: $msixPath"
|
||||
|
||||
# ---- 5) (선택) sign ----
|
||||
if ($Sign) {
|
||||
Write-Host ''
|
||||
Write-Host '[5/5] signtool sign'
|
||||
if ($CertThumbprint) {
|
||||
& $signtool sign /fd SHA256 /sha1 $CertThumbprint /tr 'http://timestamp.digicert.com' /td SHA256 $msixPath | Out-Host
|
||||
} elseif ($PfxPath) {
|
||||
if (-not $PfxPassword) {
|
||||
$PfxPassword = Read-Host -AsSecureString -Prompt 'PFX 비밀번호'
|
||||
}
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($PfxPassword)
|
||||
try {
|
||||
$plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
|
||||
& $signtool sign /fd SHA256 /a /f $PfxPath /p $plain /tr 'http://timestamp.digicert.com' /td SHA256 $msixPath | Out-Host
|
||||
} finally {
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||
}
|
||||
} else {
|
||||
throw '서명을 하려면 -CertThumbprint 또는 -PfxPath 가 필요합니다.'
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw 'signtool sign 실패' }
|
||||
Write-Host '✅ 서명 완료'
|
||||
} else {
|
||||
Write-Host ''
|
||||
Write-Host '[5/5] 서명 건너뜀 (-Sign 미지정). 사이드로드 시 인증서 필요.'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "최종 산출물: $msixPath"
|
||||
44
packaging/CreateDevCert.ps1
Normal file
44
packaging/CreateDevCert.ps1
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#Requires -Version 5.1
|
||||
# 자체 서명 코드 사이닝 인증서 생성 + PFX export.
|
||||
# Subject가 Package.appxmanifest 의 <Identity Publisher="..."> 와 정확히 일치해야 한다.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Subject = 'CN=EverythingToJpegDev',
|
||||
[string]$OutputPfx = (Join-Path $PSScriptRoot 'EverythingToJpeg-DevCert.pfx'),
|
||||
[securestring]$Password
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $Password) {
|
||||
Write-Host '인증서 PFX 보호용 비밀번호를 입력하세요. (5대 PC에 설치할 때 필요합니다)'
|
||||
$Password = Read-Host -AsSecureString -Prompt '비밀번호'
|
||||
}
|
||||
|
||||
Write-Host "Creating self-signed code-signing certificate: $Subject"
|
||||
$cert = New-SelfSignedCertificate `
|
||||
-Type CodeSigningCert `
|
||||
-Subject $Subject `
|
||||
-KeyAlgorithm RSA `
|
||||
-KeyLength 3072 `
|
||||
-Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider' `
|
||||
-KeyExportPolicy Exportable `
|
||||
-KeyUsage DigitalSignature `
|
||||
-CertStoreLocation 'Cert:\CurrentUser\My' `
|
||||
-HashAlgorithm SHA256 `
|
||||
-NotAfter (Get-Date).AddYears(5) `
|
||||
-FriendlyName 'EverythingToJpeg Dev'
|
||||
|
||||
Write-Host "Thumbprint: $($cert.Thumbprint)"
|
||||
Write-Host "Exporting PFX: $OutputPfx"
|
||||
Export-PfxCertificate -Cert $cert -FilePath $OutputPfx -Password $Password | Out-Null
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '--- 다음 단계 ---'
|
||||
Write-Host " 1. 이 PFX 파일을 5대 PC 각각에 복사"
|
||||
Write-Host " 2. 각 PC에서 관리자 PowerShell로:"
|
||||
Write-Host ' Import-PfxCertificate -CertStoreLocation "Cert:\LocalMachine\TrustedPeople" -FilePath <경로>.pfx -Password (Read-Host -AsSecureString)'
|
||||
Write-Host ' 3. MSIX 빌드 시 BuildMsix.ps1 -CertThumbprint ' + $cert.Thumbprint
|
||||
Write-Host ''
|
||||
Write-Host "PFX는 비밀이므로 절대 git에 커밋하지 마세요. (.gitignore에 *.pfx 추가됨)"
|
||||
56
packaging/GenerateAssets.ps1
Normal file
56
packaging/GenerateAssets.ps1
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#Requires -Version 5.1
|
||||
# packaging/Assets/ 의 placeholder 아이콘들을 생성한다.
|
||||
# 추후 진짜 로고로 교체.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$assetsDir = Join-Path $PSScriptRoot 'Assets'
|
||||
if (-not (Test-Path $assetsDir)) { New-Item -ItemType Directory -Path $assetsDir | Out-Null }
|
||||
|
||||
function New-LogoPng {
|
||||
param(
|
||||
[int]$Width,
|
||||
[int]$Height,
|
||||
[string]$Path,
|
||||
[string]$Label = ''
|
||||
)
|
||||
$bmp = New-Object System.Drawing.Bitmap($Width, $Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit
|
||||
|
||||
$rect = New-Object System.Drawing.Rectangle(0, 0, $Width, $Height)
|
||||
$brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
|
||||
$rect,
|
||||
[System.Drawing.Color]::FromArgb(0xFF, 0x3B, 0x82, 0xF6),
|
||||
[System.Drawing.Color]::FromArgb(0xFF, 0x1E, 0x40, 0xAF),
|
||||
[System.Drawing.Drawing2D.LinearGradientMode]::Diagonal)
|
||||
$g.FillRectangle($brush, $rect)
|
||||
|
||||
if ($Label) {
|
||||
$fontSize = [Math]::Max(8, [Math]::Min($Width, $Height) / 5)
|
||||
$font = New-Object System.Drawing.Font('Segoe UI', $fontSize, [System.Drawing.FontStyle]::Bold)
|
||||
$textBrush = [System.Drawing.Brushes]::White
|
||||
$sf = New-Object System.Drawing.StringFormat
|
||||
$sf.Alignment = [System.Drawing.StringAlignment]::Center
|
||||
$sf.LineAlignment = [System.Drawing.StringAlignment]::Center
|
||||
$rectF = New-Object System.Drawing.RectangleF(0, 0, [float]$Width, [float]$Height)
|
||||
$g.DrawString($Label, $font, $textBrush, $rectF, $sf)
|
||||
$font.Dispose()
|
||||
$sf.Dispose()
|
||||
}
|
||||
|
||||
$g.Dispose()
|
||||
$bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$bmp.Dispose()
|
||||
$brush.Dispose()
|
||||
Write-Host " [+] $Path ($Width x $Height)"
|
||||
}
|
||||
|
||||
Write-Host 'Generating placeholder logos…'
|
||||
New-LogoPng -Width 50 -Height 50 -Path (Join-Path $assetsDir 'StoreLogo.png') -Label 'E2J'
|
||||
New-LogoPng -Width 44 -Height 44 -Path (Join-Path $assetsDir 'Square44x44Logo.png') -Label 'E2J'
|
||||
New-LogoPng -Width 150 -Height 150 -Path (Join-Path $assetsDir 'Square150x150Logo.png')-Label 'E2J'
|
||||
New-LogoPng -Width 310 -Height 150 -Path (Join-Path $assetsDir 'Wide310x150Logo.png') -Label 'EverythingToJpeg'
|
||||
Write-Host 'Done.'
|
||||
42
packaging/Install-EverythingToJpeg.ps1
Normal file
42
packaging/Install-EverythingToJpeg.ps1
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#Requires -Version 5.1
|
||||
#Requires -RunAsAdministrator
|
||||
# 5대 PC에서 MSIX 사이드로드 설치 — 1회 셋업 스크립트.
|
||||
# 사용법:
|
||||
# PowerShell (관리자) > .\Install-EverythingToJpeg.ps1 -PfxPath .\EverythingToJpeg-DevCert.pfx -MsixPath .\EverythingToJpeg.msix
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$PfxPath,
|
||||
[Parameter(Mandatory)] [string]$MsixPath,
|
||||
[securestring]$PfxPassword,
|
||||
[string]$Password = 'EverythingToJpegDev'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $PfxPath)) { throw "PFX 파일을 찾을 수 없습니다: $PfxPath" }
|
||||
if (-not (Test-Path $MsixPath)) { throw "MSIX 파일을 찾을 수 없습니다: $MsixPath" }
|
||||
|
||||
if (-not $PfxPassword) {
|
||||
$PfxPassword = ConvertTo-SecureString -String $Password -AsPlainText -Force
|
||||
}
|
||||
|
||||
Write-Host '[1/3] 인증서를 LocalMachine\TrustedPeople에 임포트…'
|
||||
$importResult = Import-PfxCertificate `
|
||||
-CertStoreLocation 'Cert:\LocalMachine\TrustedPeople' `
|
||||
-FilePath $PfxPath `
|
||||
-Password $PfxPassword
|
||||
Write-Host " Thumbprint: $($importResult.Thumbprint)"
|
||||
|
||||
Write-Host '[2/3] 인증서를 LocalMachine\Root에도 임포트 (체인 신뢰)…'
|
||||
Import-PfxCertificate `
|
||||
-CertStoreLocation 'Cert:\LocalMachine\Root' `
|
||||
-FilePath $PfxPath `
|
||||
-Password $PfxPassword | Out-Null
|
||||
|
||||
Write-Host '[3/3] MSIX 패키지 설치…'
|
||||
Add-AppxPackage -Path $MsixPath -ForceApplicationShutdown
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '✅ 설치 완료. Win11 메인 우클릭 메뉴에 "JPEG로 빠른 변환" / "JPEG로 변환…" 항목이 보일 겁니다.'
|
||||
Write-Host ' (탐색기 재시작이 필요할 수 있음: 작업 관리자 → "Windows 탐색기" 다시 시작)'
|
||||
202
packaging/Package.appxmanifest
Normal file
202
packaging/Package.appxmanifest
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
|
||||
xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"
|
||||
xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5"
|
||||
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
|
||||
IgnorableNamespaces="uap rescap desktop desktop4 desktop5 com">
|
||||
|
||||
<Identity
|
||||
Name="EverythingToJpeg.YunChan"
|
||||
Publisher="CN=EverythingToJpegDev"
|
||||
Version="1.0.0.0"
|
||||
ProcessorArchitecture="x64" />
|
||||
|
||||
<Properties>
|
||||
<DisplayName>EverythingToJpeg</DisplayName>
|
||||
<PublisherDisplayName>YunChan</PublisherDisplayName>
|
||||
<Logo>Assets\StoreLogo.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.26100.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="ko-KR" />
|
||||
<Resource Language="en-US" />
|
||||
</Resources>
|
||||
|
||||
<Applications>
|
||||
<Application Id="EverythingToJpeg"
|
||||
Executable="EverythingToJpeg.exe"
|
||||
EntryPoint="Windows.FullTrustApplication">
|
||||
<uap:VisualElements
|
||||
DisplayName="EverythingToJpeg"
|
||||
Description="모든 파일을 JPEG로 변환"
|
||||
BackgroundColor="transparent"
|
||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
|
||||
</uap:VisualElements>
|
||||
|
||||
<Extensions>
|
||||
<!-- COM Surrogate Server: hosts the two IExplorerCommand handlers -->
|
||||
<com:Extension Category="windows.comServer">
|
||||
<com:ComServer>
|
||||
<com:SurrogateServer DisplayName="EverythingToJpeg Shell Extension">
|
||||
<com:Class Id="801B2DD3-632C-4731-9510-AEAE09345264"
|
||||
Path="EverythingToJpeg.Shell.dll"
|
||||
ThreadingModel="STA" />
|
||||
<com:Class Id="CEBA1DB7-9175-4DF6-A362-490DEA49B598"
|
||||
Path="EverythingToJpeg.Shell.dll"
|
||||
ThreadingModel="STA" />
|
||||
</com:SurrogateServer>
|
||||
</com:ComServer>
|
||||
</com:Extension>
|
||||
|
||||
<!-- File Explorer context menu registrations -->
|
||||
<desktop4:Extension Category="windows.fileExplorerContextMenus">
|
||||
<desktop4:FileExplorerContextMenus>
|
||||
<!-- 이미지 -->
|
||||
<desktop5:ItemType Type=".png">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".jpg">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".jpeg">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".gif">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".bmp">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".tif">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".tiff">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".webp">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".avif">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".psd">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- HEIC/HEIF -->
|
||||
<desktop5:ItemType Type=".heic">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".heif">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- RAW -->
|
||||
<desktop5:ItemType Type=".dng">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".nef">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".cr2">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".cr3">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".arw">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".raf">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".orf">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".rw2">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".srw">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".pef">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- PDF -->
|
||||
<desktop5:ItemType Type=".pdf">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- DOCX -->
|
||||
<desktop5:ItemType Type=".docx">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".doc">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- HTML -->
|
||||
<desktop5:ItemType Type=".html">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".htm">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- HWP / HWPX -->
|
||||
<desktop5:ItemType Type=".hwp">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".hwpx">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
</desktop5:ItemType>
|
||||
</desktop4:FileExplorerContextMenus>
|
||||
</desktop4:Extension>
|
||||
</Extensions>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
</Capabilities>
|
||||
</Package>
|
||||
87
packaging/README.md
Normal file
87
packaging/README.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Phase 2 — MSIX 패키징
|
||||
|
||||
Win11 메인 우클릭 메뉴에 "JPEG로 빠른 변환" / "JPEG로 변환…"을 띄우는 정공법.
|
||||
|
||||
## 구성
|
||||
|
||||
```
|
||||
packaging/
|
||||
├── Package.appxmanifest — IExplorerCommand 등록 (com:Class + desktop4:FileExplorerContextMenus)
|
||||
├── Assets/ — 앱 아이콘 (placeholder, GenerateAssets.ps1로 생성)
|
||||
├── GenerateAssets.ps1 — placeholder PNG 일괄 생성
|
||||
├── CreateDevCert.ps1 — 자체 서명 코드사이닝 인증서 생성 + PFX export
|
||||
├── BuildMsix.ps1 — .NET publish + C++ DLL 빌드 + makeappx + (선택) signtool
|
||||
└── Install-EverythingToJpeg.ps1 — 5대 PC 1회 설치 스크립트
|
||||
```
|
||||
|
||||
C++ Shell DLL은 `src/EverythingToJpeg.Shell/` 에 있고 `BuildMsix.ps1` 안에서 자동 빌드됩니다.
|
||||
|
||||
## 1회: 자체 서명 인증서 만들기
|
||||
|
||||
```powershell
|
||||
cd packaging
|
||||
.\CreateDevCert.ps1
|
||||
# Subject 기본값: CN=EverythingToJpegDev (Package.appxmanifest의 Publisher와 일치)
|
||||
# 비밀번호 입력 → EverythingToJpeg-DevCert.pfx 생성
|
||||
```
|
||||
|
||||
출력된 Thumbprint를 `BuildMsix.ps1 -CertThumbprint <값>` 으로 사용하거나, PFX 파일을 5대 PC에 복사해서 설치 시 사용합니다.
|
||||
|
||||
## 빌드
|
||||
|
||||
### 미서명 (Phase 1 그대로 사용 가능, 메인 메뉴 노출은 안 됨)
|
||||
```powershell
|
||||
.\BuildMsix.ps1
|
||||
# 산출: packaging/dist/EverythingToJpeg-x64.msix
|
||||
```
|
||||
|
||||
### 서명
|
||||
```powershell
|
||||
# 방법 1: PFX 사용
|
||||
.\BuildMsix.ps1 -Sign -PfxPath .\EverythingToJpeg-DevCert.pfx
|
||||
|
||||
# 방법 2: 인증서 저장소의 Thumbprint
|
||||
.\BuildMsix.ps1 -Sign -CertThumbprint AABBCCDD...
|
||||
```
|
||||
|
||||
## 5대 PC 설치 (관리자 PowerShell)
|
||||
|
||||
```powershell
|
||||
.\Install-EverythingToJpeg.ps1 `
|
||||
-PfxPath .\EverythingToJpeg-DevCert.pfx `
|
||||
-MsixPath .\EverythingToJpeg-x64.msix
|
||||
```
|
||||
|
||||
스크립트가 자동으로:
|
||||
1. PFX를 `LocalMachine\TrustedPeople` 에 임포트
|
||||
2. PFX를 `LocalMachine\Root` 에도 임포트 (체인 신뢰)
|
||||
3. `Add-AppxPackage` 로 MSIX 사이드로드
|
||||
|
||||
설치 후 PNG/JPG/HEIC/PDF/DOCX 등을 우클릭하면 **메인 메뉴에 직접** "JPEG로 빠른 변환" / "JPEG로 변환…"이 보입니다.
|
||||
|
||||
## 미서명 사이드로드 (Phase 2 임시 사용)
|
||||
|
||||
자체 서명 만들기조차 귀찮을 때:
|
||||
```powershell
|
||||
# 개발자 모드 켜기: 설정 → 개인 정보 및 보안 → 개발자용 → 켜기
|
||||
Add-AppxPackage -AllowUnsigned -Path .\EverythingToJpeg-x64.msix
|
||||
```
|
||||
> Win11 24H2부터 `-AllowUnsigned` 지원. 이전 버전은 자체 서명 권장.
|
||||
|
||||
## CI/CD
|
||||
|
||||
`.github/workflows/release.yml` — 태그 푸시(`v1.0.0` 등) 시 자동:
|
||||
1. .NET / MSBuild 셋업
|
||||
2. `BuildMsix.ps1` 실행 (미서명)
|
||||
3. GitHub Release 생성 + MSIX 첨부
|
||||
|
||||
서명까지 자동화하려면 GitHub Secrets에 `PFX_BASE64`, `PFX_PASSWORD`를 등록하고 워크플로에 단계 추가 (별도 보안 검토 후).
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
| 증상 | 원인 / 해결 |
|
||||
|---|---|
|
||||
| `Add-AppxPackage`: "신뢰할 수 없는 인증서" | PFX를 `LocalMachine\TrustedPeople`에 임포트했는지 확인 (Install 스크립트 자동 수행) |
|
||||
| 메뉴가 안 뜸 | 탐색기 재시작: 작업관리자 → "Windows 탐색기" 다시 시작 |
|
||||
| Publisher 불일치 오류 | `Package.appxmanifest`의 `Publisher=` 와 인증서 `Subject` 가 정확히 일치해야 함 |
|
||||
| `App identity required` | MSIX 패키지로 설치된 경우에만 IExplorerCommand 작동. portable EXE는 Phase 1 레지스트리 방식 사용 |
|
||||
60
src/EverythingToJpeg.App/App.xaml
Normal file
60
src/EverythingToJpeg.App/App.xaml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<Application x:Class="EverythingToJpeg.App.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
xmlns:local="clr-namespace:EverythingToJpeg.App"
|
||||
ShutdownMode="OnLastWindowClose">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ui:ThemesDictionary Theme="Dark"/>
|
||||
<ui:ControlsDictionary/>
|
||||
<ResourceDictionary Source="Views/FormatShiftTheme.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<!-- Spacing tokens (Fluent 8pt grid) -->
|
||||
<Thickness x:Key="SpacingXS">4</Thickness>
|
||||
<Thickness x:Key="SpacingS">8</Thickness>
|
||||
<Thickness x:Key="SpacingM">16</Thickness>
|
||||
<Thickness x:Key="SpacingL">24</Thickness>
|
||||
<Thickness x:Key="SpacingXL">32</Thickness>
|
||||
|
||||
<!-- Typography (Fluent 2 type ramp, Segoe UI Variable) -->
|
||||
<Style x:Key="TextTitleLarge" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe UI Variable Display, Segoe UI"/>
|
||||
<Setter Property="FontSize" Value="28"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
<Style x:Key="TextTitle" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe UI Variable Display, Segoe UI"/>
|
||||
<Setter Property="FontSize" Value="20"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
<Style x:Key="TextSubtitle" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe UI Variable Text, Segoe UI"/>
|
||||
<Setter Property="FontSize" Value="16"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
<Style x:Key="TextBodyStrong" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe UI Variable Text, Segoe UI"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
<Style x:Key="TextBody" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe UI Variable Text, Segoe UI"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
</Style>
|
||||
<Style x:Key="TextCaption" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe UI Variable Small, Segoe UI"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Status badge brushes -->
|
||||
<SolidColorBrush x:Key="BadgeReadyBrush" Color="#10B981"/>
|
||||
<SolidColorBrush x:Key="BadgeWarnBrush" Color="#F59E0B"/>
|
||||
<SolidColorBrush x:Key="BadgeInfoBrush" Color="#3B82F6"/>
|
||||
<SolidColorBrush x:Key="BadgeMutedBrush" Color="#6B7280"/>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
155
src/EverythingToJpeg.App/App.xaml.cs
Normal file
155
src/EverythingToJpeg.App/App.xaml.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
using System.Windows;
|
||||
using EverythingToJpeg.App.Cli;
|
||||
using EverythingToJpeg.App.Views;
|
||||
using EverythingToJpeg.Core;
|
||||
|
||||
namespace EverythingToJpeg.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public ConversionEngine Engine { get; } = EverythingToJpegBootstrap.CreateDefault();
|
||||
|
||||
protected override async void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
WireGlobalExceptionLogging();
|
||||
|
||||
var parsed = CliRouter.Parse(e.Args);
|
||||
|
||||
switch (parsed.Mode)
|
||||
{
|
||||
case CliRouter.Mode.Help:
|
||||
ConsoleHelper.WriteLine(CliRouter.HelpText());
|
||||
Environment.Exit(0);
|
||||
return;
|
||||
|
||||
case CliRouter.Mode.Register:
|
||||
Environment.Exit(CliRouter.RunRegister(register: true));
|
||||
return;
|
||||
|
||||
case CliRouter.Mode.Unregister:
|
||||
Environment.Exit(CliRouter.RunRegister(register: false));
|
||||
return;
|
||||
|
||||
case CliRouter.Mode.Diagnose:
|
||||
ShowDiagnoseWindow();
|
||||
return;
|
||||
|
||||
case CliRouter.Mode.Quick:
|
||||
if (parsed.Files.Count == 0)
|
||||
{
|
||||
MessageBox.Show("변환할 파일이 없습니다.", "EverythingToJpeg",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
Shutdown(1);
|
||||
return;
|
||||
}
|
||||
await RunQuickAsync(parsed.Files);
|
||||
return;
|
||||
|
||||
case CliRouter.Mode.Dialog:
|
||||
ShowConvertDialog(parsed.Files);
|
||||
return;
|
||||
|
||||
case CliRouter.Mode.ShowMain:
|
||||
default:
|
||||
ShowMainWindow();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowMainWindow()
|
||||
{
|
||||
var window = new MainWindow();
|
||||
MainWindow = window;
|
||||
window.Show();
|
||||
}
|
||||
|
||||
private void ShowConvertDialog(IReadOnlyList<string> files)
|
||||
{
|
||||
var window = new Views.MainWindow(files);
|
||||
MainWindow = window;
|
||||
window.Show();
|
||||
}
|
||||
|
||||
private void ShowDiagnoseWindow()
|
||||
{
|
||||
var window = new DiagnoseWindow(Engine);
|
||||
MainWindow = window;
|
||||
window.Show();
|
||||
}
|
||||
|
||||
private async Task RunQuickAsync(IReadOnlyList<string> files)
|
||||
{
|
||||
var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_quick.log");
|
||||
var log = new System.Text.StringBuilder();
|
||||
log.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Quick start, {files.Count} file(s)");
|
||||
foreach (var f in files) log.AppendLine($" src: {f}");
|
||||
|
||||
var progress = new QuickProgressWindow(files.Count);
|
||||
progress.Show();
|
||||
|
||||
try
|
||||
{
|
||||
var options = ConvertOptions.Quick();
|
||||
var reporter = new Progress<ConvertProgress>(p => progress.Report(p));
|
||||
var results = await Engine.ConvertManyAsync(files, options, reporter);
|
||||
|
||||
foreach (var r in results)
|
||||
{
|
||||
log.AppendLine($" [{r.Status}] {Path.GetFileName(r.SourcePath)} → {r.OutputPaths.Count} output(s)");
|
||||
if (r.Message is { Length: > 0 }) log.AppendLine($" msg: {r.Message}");
|
||||
if (r.Error is not null) log.AppendLine($" err: {r.Error}");
|
||||
foreach (var o in r.OutputPaths) log.AppendLine($" out: {o}");
|
||||
}
|
||||
|
||||
progress.Finish(results);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.AppendLine($" EXCEPTION {ex.GetType().Name}: {ex.Message}");
|
||||
log.AppendLine(ex.ToString());
|
||||
try { progress.Close(); } catch { }
|
||||
MessageBox.Show($"변환 중 오류: {ex.Message}\n\n로그: {logPath}", "EverythingToJpeg",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.WriteAllText(logPath, log.ToString()); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static void WireGlobalExceptionLogging()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_unhandled.log");
|
||||
|
||||
void Append(string source, Exception? ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.AppendAllText(path,
|
||||
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {source}\n{ex}\n\n");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
Append("AppDomain.UnhandledException", e.ExceptionObject as Exception);
|
||||
|
||||
Current.DispatcherUnhandledException += (_, e) =>
|
||||
{
|
||||
Append("Application.DispatcherUnhandledException", e.Exception);
|
||||
MessageBox.Show(
|
||||
"예기치 못한 오류:\n\n" + e.Exception.Message + "\n\n로그: " + path,
|
||||
"EverythingToJpeg",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
e.Handled = true;
|
||||
};
|
||||
|
||||
System.Threading.Tasks.TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||
{
|
||||
Append("TaskScheduler.UnobservedTaskException", e.Exception);
|
||||
e.SetObserved();
|
||||
};
|
||||
}
|
||||
}
|
||||
10
src/EverythingToJpeg.App/AssemblyInfo.cs
Normal file
10
src/EverythingToJpeg.App/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using System.Windows;
|
||||
|
||||
[assembly:ThemeInfo(
|
||||
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||
//(used if a resource is not found in the page,
|
||||
// or application resource dictionaries)
|
||||
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||
//(used if a resource is not found in the page,
|
||||
// app, or any theme specific resource dictionaries)
|
||||
)]
|
||||
120
src/EverythingToJpeg.App/Cli/CliRouter.cs
Normal file
120
src/EverythingToJpeg.App/Cli/CliRouter.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
using EverythingToJpeg.App.Shell;
|
||||
using EverythingToJpeg.Core;
|
||||
|
||||
namespace EverythingToJpeg.App.Cli;
|
||||
|
||||
internal static class CliRouter
|
||||
{
|
||||
public enum Mode
|
||||
{
|
||||
ShowMain,
|
||||
Quick,
|
||||
Dialog,
|
||||
Register,
|
||||
Unregister,
|
||||
Diagnose,
|
||||
Help,
|
||||
}
|
||||
|
||||
public sealed record ParsedArgs(Mode Mode, IReadOnlyList<string> Files);
|
||||
|
||||
public static ParsedArgs Parse(string[] args)
|
||||
{
|
||||
if (args is null || args.Length == 0)
|
||||
return new ParsedArgs(Mode.ShowMain, Array.Empty<string>());
|
||||
|
||||
var verb = args[0].Trim().ToLowerInvariant();
|
||||
var rest = args.Skip(1).Where(a => !string.IsNullOrWhiteSpace(a)).ToList();
|
||||
|
||||
return verb switch
|
||||
{
|
||||
"quick" => new ParsedArgs(Mode.Quick, ExpandFiles(rest)),
|
||||
"dialog" => new ParsedArgs(Mode.Dialog, ExpandFiles(rest)),
|
||||
"register" => new ParsedArgs(Mode.Register, Array.Empty<string>()),
|
||||
"unregister" => new ParsedArgs(Mode.Unregister, Array.Empty<string>()),
|
||||
"diagnose" or "doctor" => new ParsedArgs(Mode.Diagnose, Array.Empty<string>()),
|
||||
"help" or "--help" or "-h" or "/?" => new ParsedArgs(Mode.Help, Array.Empty<string>()),
|
||||
_ when File.Exists(args[0]) => new ParsedArgs(Mode.Dialog, ExpandFiles(args)),
|
||||
_ => new ParsedArgs(Mode.ShowMain, Array.Empty<string>()),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExpandFiles(IEnumerable<string> raw)
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var arg in raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arg)) continue;
|
||||
try
|
||||
{
|
||||
if (File.Exists(arg)) { list.Add(Path.GetFullPath(arg)); continue; }
|
||||
if (Directory.Exists(arg))
|
||||
{
|
||||
foreach (var f in Directory.EnumerateFiles(arg, "*", SearchOption.TopDirectoryOnly))
|
||||
list.Add(Path.GetFullPath(f));
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static string HelpText()
|
||||
{
|
||||
var engine = EverythingToJpegBootstrap.CreateDefault();
|
||||
var supported = string.Join(", ",
|
||||
engine.Providers.Implemented.SelectMany(p => p.Capability.Extensions).Distinct().OrderBy(e => e));
|
||||
var coming = string.Join(", ",
|
||||
engine.Providers.ComingSoon.SelectMany(p => p.Capability.Extensions).Distinct().OrderBy(e => e));
|
||||
|
||||
return $"""
|
||||
EverythingToJpeg — 모든 것을 JPEG로
|
||||
|
||||
사용:
|
||||
EverythingToJpeg.exe quick <파일들...> 빠른 변환 (다이얼로그 없이 즉시)
|
||||
EverythingToJpeg.exe dialog <파일들...> 상세 옵션 다이얼로그 표시
|
||||
EverythingToJpeg.exe register 컨텍스트 메뉴 등록 (현재 사용자)
|
||||
EverythingToJpeg.exe unregister 컨텍스트 메뉴 해제
|
||||
EverythingToJpeg.exe diagnose 지원 형식·외부 도구 진단
|
||||
EverythingToJpeg.exe 메인 창 표시
|
||||
|
||||
지원 (지금): {supported}
|
||||
지원 예정 : {coming}
|
||||
""";
|
||||
}
|
||||
|
||||
public static int RunRegister(bool register)
|
||||
{
|
||||
var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_register.log");
|
||||
var log = new System.Text.StringBuilder();
|
||||
log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] RunRegister start, register={register}");
|
||||
try
|
||||
{
|
||||
log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Creating engine...");
|
||||
var engine = EverythingToJpegBootstrap.CreateDefault();
|
||||
log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Engine OK. Implemented providers: {string.Join(",", engine.Providers.Implemented.Select(p => p.Capability.Id))}");
|
||||
|
||||
if (register)
|
||||
{
|
||||
log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Calling Register...");
|
||||
ContextMenuRegistrar.Register(engine);
|
||||
}
|
||||
else
|
||||
{
|
||||
ContextMenuRegistrar.Unregister(engine);
|
||||
}
|
||||
log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] OK");
|
||||
File.WriteAllText(logPath, log.ToString());
|
||||
Console.Out.WriteLine($"등록 완료. 로그: {logPath}");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] EXCEPTION {ex.GetType().Name}: {ex.Message}");
|
||||
log.AppendLine(ex.ToString());
|
||||
try { File.WriteAllText(logPath, log.ToString()); } catch { }
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/EverythingToJpeg.App/Cli/ConsoleHelper.cs
Normal file
24
src/EverythingToJpeg.App/Cli/ConsoleHelper.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace EverythingToJpeg.App.Cli;
|
||||
|
||||
internal static class ConsoleHelper
|
||||
{
|
||||
private static bool _attached;
|
||||
|
||||
public static void WriteLine(string text)
|
||||
{
|
||||
EnsureAttached();
|
||||
Console.Out.WriteLine(text);
|
||||
Console.Out.Flush();
|
||||
}
|
||||
|
||||
private static void EnsureAttached()
|
||||
{
|
||||
if (_attached) return;
|
||||
_attached = AttachConsole(-1);
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool AttachConsole(int dwProcessId);
|
||||
}
|
||||
29
src/EverythingToJpeg.App/EverythingToJpeg.App.csproj
Normal file
29
src/EverythingToJpeg.App/EverythingToJpeg.App.csproj
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<AssemblyName>EverythingToJpeg</AssemblyName>
|
||||
<RootNamespace>EverythingToJpeg.App</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
||||
<NoWarn>$(NoWarn);NU1901;NU1902;NU1903;NU1904</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EverythingToJpeg.Core\EverythingToJpeg.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="WPF-UI" Version="4.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="System.IO" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
5
src/EverythingToJpeg.App/GlobalUsings.cs
Normal file
5
src/EverythingToJpeg.App/GlobalUsings.cs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
global using MessageBox = System.Windows.MessageBox;
|
||||
global using MessageBoxButton = System.Windows.MessageBoxButton;
|
||||
global using MessageBoxImage = System.Windows.MessageBoxImage;
|
||||
global using MessageBoxResult = System.Windows.MessageBoxResult;
|
||||
global using TextBlock = System.Windows.Controls.TextBlock;
|
||||
96
src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs
Normal file
96
src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
using EverythingToJpeg.Core;
|
||||
using EverythingToJpeg.Core.Providers;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace EverythingToJpeg.App.Shell;
|
||||
|
||||
internal static class ContextMenuRegistrar
|
||||
{
|
||||
private const string QuickVerb = "EverythingToJpeg.Quick";
|
||||
private const string DialogVerb = "EverythingToJpeg.Dialog";
|
||||
|
||||
private const string QuickLabel = "JPEG로 빠른 변환";
|
||||
private const string DialogLabel = "JPEG로 변환…";
|
||||
|
||||
public static void Register(ConversionEngine engine)
|
||||
{
|
||||
var exe = GetAppExecutablePath();
|
||||
var icon = exe + ",0";
|
||||
|
||||
foreach (var ext in CollectExtensions(engine))
|
||||
{
|
||||
WriteVerb(ext, QuickVerb, QuickLabel, icon, $"\"{exe}\" quick \"%1\"");
|
||||
WriteVerb(ext, DialogVerb, DialogLabel, icon, $"\"{exe}\" dialog \"%1\"");
|
||||
}
|
||||
|
||||
NotifyShell();
|
||||
}
|
||||
|
||||
public static void Unregister(ConversionEngine engine)
|
||||
{
|
||||
foreach (var ext in CollectExtensions(engine))
|
||||
{
|
||||
DeleteVerb(ext, QuickVerb);
|
||||
DeleteVerb(ext, DialogVerb);
|
||||
}
|
||||
NotifyShell();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> CollectExtensions(ConversionEngine engine)
|
||||
{
|
||||
return engine.Providers.Implemented
|
||||
.Where(p => p.Capability.CanRegisterContextMenu)
|
||||
.SelectMany(p => p.Capability.Extensions)
|
||||
.Select(e => e.StartsWith('.') ? e : "." + e)
|
||||
.Select(e => e.ToLowerInvariant())
|
||||
.Distinct();
|
||||
}
|
||||
|
||||
private static void WriteVerb(string ext, string verb, string label, string icon, string command)
|
||||
{
|
||||
var keyPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{verb}";
|
||||
using var verbKey = Registry.CurrentUser.CreateSubKey(keyPath, writable: true)
|
||||
?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {keyPath}");
|
||||
|
||||
verbKey.SetValue(null, label, RegistryValueKind.String);
|
||||
verbKey.SetValue("Icon", icon, RegistryValueKind.String);
|
||||
verbKey.SetValue("MUIVerb", label, RegistryValueKind.String);
|
||||
|
||||
using var commandKey = verbKey.CreateSubKey("command", writable: true)
|
||||
?? throw new InvalidOperationException("command 하위 키 생성 실패");
|
||||
commandKey.SetValue(null, command, RegistryValueKind.String);
|
||||
}
|
||||
|
||||
private static void DeleteVerb(string ext, string verb)
|
||||
{
|
||||
var parentPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell";
|
||||
try
|
||||
{
|
||||
using var parent = Registry.CurrentUser.OpenSubKey(parentPath, writable: true);
|
||||
parent?.DeleteSubKeyTree(verb, throwOnMissingSubKey: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetAppExecutablePath()
|
||||
{
|
||||
var exe = Environment.ProcessPath;
|
||||
if (!string.IsNullOrEmpty(exe) && File.Exists(exe)) return exe;
|
||||
return AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar + "EverythingToJpeg.exe";
|
||||
}
|
||||
|
||||
private static void NotifyShell()
|
||||
{
|
||||
try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static class NativeMethods
|
||||
{
|
||||
[System.Runtime.InteropServices.DllImport("shell32.dll")]
|
||||
public static extern void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2);
|
||||
}
|
||||
}
|
||||
32
src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml
Normal file
32
src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.DiagnoseWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
Title="진단"
|
||||
Width="640" Height="540"
|
||||
ExtendsContentIntoTitleBar="True"
|
||||
WindowBackdropType="Mica"
|
||||
WindowCornerPreference="Round"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<ui:TitleBar Grid.Row="0" Title="진단"/>
|
||||
<ScrollViewer Grid.Row="1" Margin="32,8,32,16" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="ItemsPanel"/>
|
||||
</ScrollViewer>
|
||||
<Border Grid.Row="2" Padding="32,16"
|
||||
Background="{DynamicResource LayerOnAcrylicFillColorDefaultBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<ui:Button Content="새로고침" Icon="{ui:SymbolIcon ArrowClockwise24}"
|
||||
Click="OnRefreshClick" Margin="0,0,8,0"/>
|
||||
<ui:Button Content="닫기" Click="OnCloseClick" Appearance="Primary"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ui:FluentWindow>
|
||||
118
src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs
Normal file
118
src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using EverythingToJpeg.Core;
|
||||
using EverythingToJpeg.Core.Providers;
|
||||
using Wpf.Ui.Controls;
|
||||
|
||||
namespace EverythingToJpeg.App.Views;
|
||||
|
||||
public partial class DiagnoseWindow : FluentWindow
|
||||
{
|
||||
private readonly ConversionEngine _engine;
|
||||
|
||||
public DiagnoseWindow(ConversionEngine engine)
|
||||
{
|
||||
_engine = engine;
|
||||
InitializeComponent();
|
||||
Loaded += async (_, _) => await PopulateAsync();
|
||||
}
|
||||
|
||||
private async Task PopulateAsync()
|
||||
{
|
||||
ItemsPanel.Children.Clear();
|
||||
|
||||
ItemsPanel.Children.Add(BuildSection("환경", new[]
|
||||
{
|
||||
("OS", Environment.OSVersion.VersionString),
|
||||
(".NET", Environment.Version.ToString()),
|
||||
("실행 경로", Environment.ProcessPath ?? AppContext.BaseDirectory),
|
||||
}));
|
||||
|
||||
foreach (var provider in _engine.Providers.All)
|
||||
{
|
||||
var availability = await provider.CheckAvailabilityAsync();
|
||||
ItemsPanel.Children.Add(BuildProviderCard(provider, availability));
|
||||
}
|
||||
}
|
||||
|
||||
private static UIElement BuildSection(string title, IEnumerable<(string Key, string Value)> items)
|
||||
{
|
||||
var card = new CardControl { Padding = new Thickness(16, 12, 16, 12), Margin = new Thickness(0, 0, 0, 12) };
|
||||
var stack = new StackPanel();
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = title,
|
||||
FontSize = 14,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Margin = new Thickness(0, 0, 0, 8),
|
||||
});
|
||||
foreach (var (k, v) in items)
|
||||
{
|
||||
var row = new Grid { Margin = new Thickness(0, 2, 0, 2) };
|
||||
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) });
|
||||
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
var keyText = new TextBlock { Text = k, FontSize = 12, Foreground = (Brush)Application.Current.FindResource("TextFillColorSecondaryBrush") };
|
||||
var valueText = new TextBlock { Text = v, FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis };
|
||||
Grid.SetColumn(valueText, 1);
|
||||
row.Children.Add(keyText);
|
||||
row.Children.Add(valueText);
|
||||
stack.Children.Add(row);
|
||||
}
|
||||
card.Content = stack;
|
||||
return card;
|
||||
}
|
||||
|
||||
private UIElement BuildProviderCard(IConverterProvider provider, ProviderAvailability availability)
|
||||
{
|
||||
var card = new CardControl { Padding = new Thickness(16, 12, 16, 12), Margin = new Thickness(0, 0, 0, 12) };
|
||||
var stack = new StackPanel();
|
||||
var header = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
header.Children.Add(new TextBlock
|
||||
{
|
||||
Text = provider.Capability.DisplayName,
|
||||
FontSize = 14,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
});
|
||||
var (badgeText, brushKey) = (provider.Capability.Status, availability.IsReady) switch
|
||||
{
|
||||
(ProviderStatus.ComingSoon, _) => ("개발 중", "BadgeMutedBrush"),
|
||||
(ProviderStatus.Disabled, _) => ("비활성", "BadgeMutedBrush"),
|
||||
(_, true) => ("준비됨", "BadgeReadyBrush"),
|
||||
_ => ("점검 필요", "BadgeWarnBrush"),
|
||||
};
|
||||
header.Children.Add(new Border
|
||||
{
|
||||
Background = (Brush)Application.Current.FindResource(brushKey),
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(8, 2, 8, 2),
|
||||
Margin = new Thickness(8, 0, 0, 0),
|
||||
Child = new TextBlock { Text = badgeText, FontSize = 11, Foreground = Brushes.White },
|
||||
});
|
||||
stack.Children.Add(header);
|
||||
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"확장자: {string.Join(", ", provider.Capability.Extensions)}",
|
||||
FontSize = 11,
|
||||
Foreground = (Brush)Application.Current.FindResource("TextFillColorTertiaryBrush"),
|
||||
Margin = new Thickness(0, 4, 0, 0),
|
||||
});
|
||||
if (!availability.IsReady && !string.IsNullOrEmpty(availability.Reason))
|
||||
{
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = availability.Reason,
|
||||
FontSize = 11,
|
||||
Foreground = (Brush)Application.Current.FindResource("BadgeWarnBrush"),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(0, 4, 0, 0),
|
||||
});
|
||||
}
|
||||
card.Content = stack;
|
||||
return card;
|
||||
}
|
||||
|
||||
private async void OnRefreshClick(object sender, RoutedEventArgs e) => await PopulateAsync();
|
||||
private void OnCloseClick(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
293
src/EverythingToJpeg.App/Views/FormatShiftTheme.xaml
Normal file
293
src/EverythingToJpeg.App/Views/FormatShiftTheme.xaml
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- Backgrounds -->
|
||||
<SolidColorBrush x:Key="FsBgBase" Color="#090A0C"/>
|
||||
<SolidColorBrush x:Key="FsBgPanel" Color="#131417"/>
|
||||
<SolidColorBrush x:Key="FsBgSurface" Color="#1C1E22"/>
|
||||
<SolidColorBrush x:Key="FsBgSurfaceHover" Color="#23252A"/>
|
||||
<SolidColorBrush x:Key="FsBgInput" Color="#0F1012"/>
|
||||
|
||||
<!-- Borders -->
|
||||
<SolidColorBrush x:Key="FsBorderSubtle" Color="#26282D"/>
|
||||
<SolidColorBrush x:Key="FsBorderStrong" Color="#3A3D45"/>
|
||||
|
||||
<!-- Text -->
|
||||
<SolidColorBrush x:Key="FsTextPrimary" Color="#ECEEFA"/>
|
||||
<SolidColorBrush x:Key="FsTextSecondary" Color="#8B909A"/>
|
||||
<SolidColorBrush x:Key="FsTextTertiary" Color="#5D626C"/>
|
||||
|
||||
<!-- Accents -->
|
||||
<SolidColorBrush x:Key="FsAccentBlue" Color="#3B72FF"/>
|
||||
<SolidColorBrush x:Key="FsAccentGreen" Color="#10B981"/>
|
||||
<SolidColorBrush x:Key="FsAccentAmber" Color="#F59E0B"/>
|
||||
<SolidColorBrush x:Key="FsAccentRed" Color="#EF4444"/>
|
||||
|
||||
<!-- Format pills -->
|
||||
<SolidColorBrush x:Key="FsFmtPdf" Color="#E53E3E"/>
|
||||
<SolidColorBrush x:Key="FsFmtPng" Color="#805AD5"/>
|
||||
<SolidColorBrush x:Key="FsFmtHeic" Color="#D69E2E"/>
|
||||
<SolidColorBrush x:Key="FsFmtJpg" Color="#3182CE"/>
|
||||
<SolidColorBrush x:Key="FsFmtDocx" Color="#2B6CB0"/>
|
||||
<SolidColorBrush x:Key="FsFmtHtml" Color="#DD6B20"/>
|
||||
<SolidColorBrush x:Key="FsFmtRaw" Color="#319795"/>
|
||||
<SolidColorBrush x:Key="FsFmtGif" Color="#9F7AEA"/>
|
||||
<SolidColorBrush x:Key="FsFmtTiff" Color="#38B2AC"/>
|
||||
<SolidColorBrush x:Key="FsFmtWebp" Color="#48BB78"/>
|
||||
<SolidColorBrush x:Key="FsFmtBmp" Color="#718096"/>
|
||||
<SolidColorBrush x:Key="FsFmtHwp" Color="#9B2C2C"/>
|
||||
<SolidColorBrush x:Key="FsFmtOther" Color="#4A5568"/>
|
||||
|
||||
<!-- Font families -->
|
||||
<FontFamily x:Key="FsFontSans">Inter, Segoe UI Variable Text, Segoe UI</FontFamily>
|
||||
<FontFamily x:Key="FsFontMono">JetBrains Mono, Cascadia Mono, Consolas</FontFamily>
|
||||
|
||||
<!-- Typography styles -->
|
||||
<Style x:Key="FsLabelStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{StaticResource FsFontSans}"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource FsTextSecondary}"/>
|
||||
<Setter Property="TextOptions.TextFormattingMode" Value="Display"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FsCaptionStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{StaticResource FsFontSans}"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource FsTextTertiary}"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FsBodyStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{StaticResource FsFontSans}"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource FsTextPrimary}"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FsMonoStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{StaticResource FsFontMono}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource FsTextSecondary}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Slider style: 2px track, 14px round thumb, blue fill up to value -->
|
||||
<Style x:Key="FsSliderStyle" TargetType="Slider">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource FsAccentBlue}"/>
|
||||
<Setter Property="MinHeight" Value="20"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="IsMoveToPointEnabled" Value="True"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Slider">
|
||||
<Grid>
|
||||
<Border Height="2" CornerRadius="1"
|
||||
Background="{StaticResource FsBorderStrong}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Track x:Name="PART_Track">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton Command="Slider.DecreaseLarge">
|
||||
<RepeatButton.Template>
|
||||
<ControlTemplate TargetType="RepeatButton">
|
||||
<Border Height="2" CornerRadius="1"
|
||||
Background="{StaticResource FsAccentBlue}"
|
||||
VerticalAlignment="Center"/>
|
||||
</ControlTemplate>
|
||||
</RepeatButton.Template>
|
||||
</RepeatButton>
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb>
|
||||
<Thumb.Template>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Ellipse Width="14" Height="14"
|
||||
Fill="{StaticResource FsTextPrimary}"/>
|
||||
</ControlTemplate>
|
||||
</Thumb.Template>
|
||||
</Thumb>
|
||||
</Track.Thumb>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton Command="Slider.IncreaseLarge">
|
||||
<RepeatButton.Template>
|
||||
<ControlTemplate TargetType="RepeatButton">
|
||||
<Border Background="Transparent"/>
|
||||
</ControlTemplate>
|
||||
</RepeatButton.Template>
|
||||
</RepeatButton>
|
||||
</Track.IncreaseRepeatButton>
|
||||
</Track>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Tab pill style -->
|
||||
<Style x:Key="FsTabPillStyle" TargetType="ToggleButton">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource FsTextSecondary}"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="14,6"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="PART_Bd" CornerRadius="20"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Background="{TemplateBinding Background}">
|
||||
<ContentPresenter HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
TextElement.Foreground="{TemplateBinding Foreground}"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="PART_Bd" Property="Background" Value="{StaticResource FsAccentBlue}"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="PART_Bd" Property="Background" Value="{StaticResource FsBgSurfaceHover}"/>
|
||||
</Trigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsChecked" Value="True"/>
|
||||
<Condition Property="IsMouseOver" Value="True"/>
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="PART_Bd" Property="Background" Value="{StaticResource FsAccentBlue}"/>
|
||||
</MultiTrigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Segment button style (Skip / Rename / Replace) -->
|
||||
<Style x:Key="FsSegmentStyle" TargetType="ToggleButton">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource FsTextSecondary}"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="PART_Bd" CornerRadius="4" Padding="0,6">
|
||||
<ContentPresenter HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
TextElement.Foreground="{TemplateBinding Foreground}"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="PART_Bd" Property="Background" Value="{StaticResource FsBgSurface}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource FsTextPrimary}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Primary button (Processing Queue / Run) -->
|
||||
<Style x:Key="FsPrimaryButtonStyle" TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource FsAccentBlue}"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="12"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="PART_Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
CornerRadius="6"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextElement.Foreground="{TemplateBinding Foreground}"
|
||||
TextElement.FontSize="{TemplateBinding FontSize}"
|
||||
TextElement.FontWeight="{TemplateBinding FontWeight}"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="PART_Bd" Property="Opacity" Value="0.5"/>
|
||||
<Setter Property="Cursor" Value="Arrow"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="PART_Bd" Property="Background" Value="#2D5DDB"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Secondary button -->
|
||||
<Style x:Key="FsSecondaryButtonStyle" TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource FsBgSurface}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource FsTextPrimary}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource FsBorderSubtle}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="12,6"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="PART_Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="6"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextElement.Foreground="{TemplateBinding Foreground}"
|
||||
TextElement.FontSize="{TemplateBinding FontSize}"
|
||||
TextElement.FontWeight="{TemplateBinding FontWeight}"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="PART_Bd" Property="Background" Value="{StaticResource FsBgSurfaceHover}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Tab badge -->
|
||||
<Style x:Key="FsTabBadgeStyle" TargetType="Border">
|
||||
<Setter Property="CornerRadius" Value="10"/>
|
||||
<Setter Property="Padding" Value="6,1"/>
|
||||
<Setter Property="Background" Value="{StaticResource FsBgSurface}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Path input box -->
|
||||
<Style x:Key="FsPathInputStyle" TargetType="TextBox">
|
||||
<Setter Property="Background" Value="{StaticResource FsBgInput}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource FsTextSecondary}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource FsBorderSubtle}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="10,8"/>
|
||||
<Setter Property="FontFamily" Value="{StaticResource FsFontMono}"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="CaretBrush" Value="{StaticResource FsTextPrimary}"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="6"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ScrollViewer x:Name="PART_ContentHost"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
671
src/EverythingToJpeg.App/Views/MainWindow.xaml
Normal file
671
src/EverythingToJpeg.App/Views/MainWindow.xaml
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
Title="FormatShift Utility"
|
||||
Width="1280" Height="960"
|
||||
MinWidth="1080" MinHeight="640"
|
||||
ExtendsContentIntoTitleBar="True"
|
||||
WindowBackdropType="None"
|
||||
WindowCornerPreference="Round"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
AllowDrop="True"
|
||||
Drop="OnFilesDropped"
|
||||
DragOver="OnDragOver"
|
||||
DragLeave="OnDragLeave"
|
||||
TextOptions.TextFormattingMode="Display"
|
||||
UseLayoutRounding="True">
|
||||
<Window.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Window.Resources>
|
||||
|
||||
<Window.InputBindings>
|
||||
<KeyBinding Key="O" Modifiers="Ctrl" Command="{Binding AddFilesCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<KeyBinding Key="Enter" Modifiers="Ctrl" Command="{Binding ProcessQueueCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<KeyBinding Key="Escape" Command="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<KeyBinding Key="F5" Command="{Binding RefreshCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
</Window.InputBindings>
|
||||
|
||||
<Grid Background="{StaticResource FsBgBase}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="32"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Title bar (window controls only) -->
|
||||
<ui:TitleBar Grid.Row="0" Title="" ShowMaximize="True" ShowMinimize="True"/>
|
||||
|
||||
<!-- App body -->
|
||||
<Grid Grid.Row="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="320"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ============== SIDEBAR ============== -->
|
||||
<Border Grid.Column="0"
|
||||
Background="{StaticResource FsBgPanel}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="0,0,1,0">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="56"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Sidebar header -->
|
||||
<Border Grid.Row="0" BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="0,0,0,1" Padding="24,0">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Path Width="18" Height="18" Stretch="Uniform"
|
||||
Stroke="{StaticResource FsAccentBlue}" StrokeThickness="2"
|
||||
Data="M3,3 L21,3 L21,21 L3,21 Z M12,8 L12,16 M8,12 L16,12"/>
|
||||
<TextBlock Margin="8,0,0,0" FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource FsTextPrimary}"
|
||||
FontFamily="{StaticResource FsFontSans}">
|
||||
FormatShift
|
||||
<Run Foreground="{StaticResource FsTextTertiary}" FontWeight="Medium"
|
||||
Text=" Utility"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Sidebar content -->
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="24,24,24,24">
|
||||
<StackPanel>
|
||||
<!-- Target Format -->
|
||||
<StackPanel Margin="0,0,0,32">
|
||||
<TextBlock Text="TARGET FORMAT" Style="{StaticResource FsLabelStyle}"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,8,0,0" VerticalAlignment="Center">
|
||||
<Border Width="24" Height="24" CornerRadius="4"
|
||||
Background="{StaticResource FsFmtJpg}">
|
||||
<TextBlock Text="JPG" Foreground="White" FontSize="8"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="JPEG Image" Margin="8,0,0,0"
|
||||
Style="{StaticResource FsBodyStyle}"
|
||||
FontWeight="Medium" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Encoding Quality -->
|
||||
<StackPanel Margin="0,0,0,32">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="ENCODING QUALITY" Style="{StaticResource FsLabelStyle}"/>
|
||||
<Border Grid.Column="1" CornerRadius="4"
|
||||
Background="{StaticResource FsBgInput}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="1" Padding="6,2">
|
||||
<TextBlock x:Name="QualityValueText"
|
||||
FontFamily="{StaticResource FsFontMono}" FontSize="12"
|
||||
Foreground="{StaticResource FsAccentBlue}"
|
||||
Text="85%"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
<Slider x:Name="QualitySlider" Margin="0,12,0,0"
|
||||
Style="{StaticResource FsSliderStyle}"
|
||||
Minimum="1" Maximum="100" Value="85"
|
||||
ValueChanged="OnQualityChanged"/>
|
||||
<Grid Margin="0,4,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Smaller File" Style="{StaticResource FsCaptionStyle}"/>
|
||||
<TextBlock Grid.Column="1" Text="Higher Quality" Style="{StaticResource FsCaptionStyle}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Output Destination -->
|
||||
<StackPanel Margin="0,0,0,32">
|
||||
<TextBlock Text="OUTPUT DESTINATION" Style="{StaticResource FsLabelStyle}"/>
|
||||
<Grid Margin="0,8,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="OutputPathTextBox"
|
||||
Style="{StaticResource FsPathInputStyle}"
|
||||
ToolTip="비워두면 원본 폴더 안 _jpeg 하위에 저장"/>
|
||||
<Button Grid.Column="1" Margin="8,0,0,0" Width="36"
|
||||
Content="…" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Click="OnPickOutputFolderClick"/>
|
||||
</Grid>
|
||||
<TextBlock Margin="0,6,0,0"
|
||||
Style="{StaticResource FsCaptionStyle}"
|
||||
Text="비워두면 원본 옆 _jpeg 폴더에 저장됩니다"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- File Conflict Rule -->
|
||||
<StackPanel Margin="0,0,0,32">
|
||||
<TextBlock Text="FILE CONFLICT RULE" Style="{StaticResource FsLabelStyle}"/>
|
||||
<Border Margin="0,8,0,0"
|
||||
Background="{StaticResource FsBgInput}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="2">
|
||||
<UniformGrid Rows="1" Columns="3">
|
||||
<ToggleButton x:Name="ConflictSkipBtn" Content="Skip"
|
||||
Style="{StaticResource FsSegmentStyle}"
|
||||
Click="OnConflictSegmentClick"
|
||||
Tag="Skip"/>
|
||||
<ToggleButton x:Name="ConflictRenameBtn" Content="Rename"
|
||||
Style="{StaticResource FsSegmentStyle}"
|
||||
IsChecked="True"
|
||||
Click="OnConflictSegmentClick"
|
||||
Tag="Rename"/>
|
||||
<ToggleButton x:Name="ConflictReplaceBtn" Content="Replace"
|
||||
Style="{StaticResource FsSegmentStyle}"
|
||||
Click="OnConflictSegmentClick"
|
||||
Tag="Replace"/>
|
||||
</UniformGrid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Stats Box -->
|
||||
<Border Background="{StaticResource FsBgInput}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="12">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="PROCESSED TODAY"
|
||||
FontFamily="{StaticResource FsFontSans}"
|
||||
FontSize="10" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource FsTextSecondary}"/>
|
||||
<TextBlock x:Name="ProcessedTodayText"
|
||||
FontFamily="{StaticResource FsFontMono}"
|
||||
FontSize="14"
|
||||
Foreground="{StaticResource FsTextPrimary}"
|
||||
Text="0" Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="EST. SPACE SAVED"
|
||||
FontFamily="{StaticResource FsFontSans}"
|
||||
FontSize="10" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource FsTextSecondary}"/>
|
||||
<TextBlock x:Name="SpaceSavedText"
|
||||
FontFamily="{StaticResource FsFontMono}"
|
||||
FontSize="14"
|
||||
Foreground="{StaticResource FsAccentGreen}"
|
||||
Text="0 B" Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Sidebar footer -->
|
||||
<Border Grid.Row="2" BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="0,1,0,0" Padding="24">
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="CapabilityStatusText" Margin="0,0,0,12"
|
||||
Style="{StaticResource FsCaptionStyle}"
|
||||
TextWrapping="Wrap" Visibility="Collapsed"/>
|
||||
<Button x:Name="ProcessQueueButton"
|
||||
Content="Idle — drop files to begin"
|
||||
Style="{StaticResource FsPrimaryButtonStyle}"
|
||||
IsEnabled="False"
|
||||
Click="OnProcessQueueClick"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============== MAIN ============== -->
|
||||
<Grid Grid.Column="1" Background="{StaticResource FsBgBase}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="56"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Nav bar -->
|
||||
<Border Grid.Row="0" BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="0,0,0,1" Padding="24,0">
|
||||
<Grid VerticalAlignment="Center">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Tabs -->
|
||||
<Border HorizontalAlignment="Left"
|
||||
Background="{StaticResource FsBgPanel}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="1" CornerRadius="20" Padding="4">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<ToggleButton x:Name="TabActiveBtn"
|
||||
Style="{StaticResource FsTabPillStyle}"
|
||||
Click="OnTabClick" Tag="Active">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="Active Queue" VerticalAlignment="Center"/>
|
||||
<Border Margin="8,0,0,0" Style="{StaticResource FsTabBadgeStyle}">
|
||||
<TextBlock x:Name="TabActiveBadge" Text="0"
|
||||
FontSize="10" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource FsTextTertiary}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ToggleButton>
|
||||
<ToggleButton x:Name="TabPastBtn" Margin="8,0,0,0"
|
||||
IsChecked="True"
|
||||
Style="{StaticResource FsTabPillStyle}"
|
||||
Click="OnTabClick" Tag="Past">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="Past Results" VerticalAlignment="Center"/>
|
||||
<Border Margin="8,0,0,0" Style="{StaticResource FsTabBadgeStyle}">
|
||||
<TextBlock x:Name="TabPastBadge" Text="0"
|
||||
FontSize="10" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource FsTextTertiary}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Actions -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<Button Content="Register Menu" Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Click="OnRegisterClick"/>
|
||||
<Button Content="Diagnose" Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Click="OnDiagnoseClick"/>
|
||||
<Button Content="Export Log" Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Click="OnExportLogClick"/>
|
||||
<Button x:Name="ClearAllButton" Content="Clear All"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Click="OnClearAllClick"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- View content (좌측: 탭 컨텐츠 / 우측: Preview 컬럼) -->
|
||||
<Grid Grid.Row="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" MinWidth="360"/>
|
||||
<ColumnDefinition Width="380"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 좌측: Active Queue OR Past Results -->
|
||||
<Grid Grid.Column="0">
|
||||
<!-- Active Queue view -->
|
||||
<Grid x:Name="ActiveQueueView" Visibility="Collapsed">
|
||||
<Grid x:Name="DropZoneEmpty">
|
||||
<Border Background="{StaticResource FsBgBase}">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Margin="48">
|
||||
<Path Width="48" Height="48" Stretch="Uniform"
|
||||
Stroke="{StaticResource FsAccentBlue}" StrokeThickness="1.5"
|
||||
Data="M21,15 L21,19 C21,20.1 20.1,21 19,21 L5,21 C3.9,21 3,20.1 3,19 L3,15 M7,10 L12,15 L17,10 M12,15 L12,3"/>
|
||||
<TextBlock Margin="0,16,0,0" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource FsTextPrimary}"
|
||||
HorizontalAlignment="Center"
|
||||
Text="Drop files anywhere to queue"/>
|
||||
<TextBlock Margin="0,4,0,0" Style="{StaticResource FsCaptionStyle}"
|
||||
HorizontalAlignment="Center"
|
||||
Text="PNG · JPG · GIF · HEIC · RAW · PDF · DOCX · HTML · HWP/HWPX"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
<ScrollViewer x:Name="ActiveQueueScroll" VerticalScrollBarVisibility="Auto"
|
||||
Padding="24" Visibility="Collapsed">
|
||||
<ItemsControl x:Name="ActiveQueueList">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Margin="0,0,0,8" Padding="12,10" CornerRadius="6"
|
||||
Background="{StaticResource FsBgPanel}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="1"
|
||||
Cursor="Hand"
|
||||
PreviewMouseLeftButtonUp="OnQueueRowClick"
|
||||
Tag="{Binding}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="40"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="100"/>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="40"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Width="32" Height="32" CornerRadius="4"
|
||||
Background="{Binding FormatBrush}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding FormatLabel}"
|
||||
Foreground="White" FontSize="10"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="16,0,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding FileName}"
|
||||
Style="{StaticResource FsBodyStyle}"
|
||||
FontWeight="Medium"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding MetaLine}"
|
||||
Style="{StaticResource FsCaptionStyle}"
|
||||
Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="{Binding SizeText}"
|
||||
Style="{StaticResource FsMonoStyle}"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding StateText}"
|
||||
FontFamily="{StaticResource FsFontMono}"
|
||||
FontSize="12"
|
||||
Foreground="{Binding StateBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="4"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="6"
|
||||
Click="OnRemoveQueueItem"
|
||||
Tag="{Binding}">
|
||||
<Path Width="14" Height="14" Stretch="Uniform"
|
||||
Stroke="{StaticResource FsTextTertiary}"
|
||||
StrokeThickness="2"
|
||||
Data="M18,6 L6,18 M6,6 L18,18"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<!-- Past Results view (default) -->
|
||||
<ScrollViewer x:Name="PastResultsView" VerticalScrollBarVisibility="Auto"
|
||||
Padding="24">
|
||||
<ItemsControl x:Name="PastResultsList">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Margin="0,0,0,40">
|
||||
<!-- Date header -->
|
||||
<Grid Margin="0,0,0,12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{Binding DateTitle}"
|
||||
FontFamily="{StaticResource FsFontSans}"
|
||||
FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource FsTextPrimary}"/>
|
||||
<Border Grid.Column="1" CornerRadius="20" Padding="10,4"
|
||||
Background="#10B98122">
|
||||
<TextBlock Text="{Binding SessionSavingsText}"
|
||||
FontFamily="{StaticResource FsFontMono}"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource FsAccentGreen}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
<Border Height="1" Background="{StaticResource FsBorderSubtle}"
|
||||
Margin="0,0,0,12"/>
|
||||
|
||||
<!-- Rows -->
|
||||
<ItemsControl ItemsSource="{Binding Entries}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border BorderBrush="#0DFFFFFF"
|
||||
BorderThickness="0,0,0,1" Padding="0,12"
|
||||
Cursor="Hand"
|
||||
Background="Transparent"
|
||||
PreviewMouseLeftButtonUp="OnPastRowClick"
|
||||
Tag="{Binding}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="40"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="100"/>
|
||||
<ColumnDefinition Width="100"/>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="40"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Width="32" Height="32" CornerRadius="4"
|
||||
Background="{Binding FormatBrush}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding FormatLabel}"
|
||||
Foreground="White" FontSize="10"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="16,0,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding FileName}"
|
||||
Style="{StaticResource FsBodyStyle}"
|
||||
FontWeight="Medium"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding MetaLine}"
|
||||
Style="{StaticResource FsCaptionStyle}"
|
||||
Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{Binding SizeText}"
|
||||
Style="{StaticResource FsMonoStyle}"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<TextBlock Grid.Column="3"
|
||||
Text="{Binding SavingsText}"
|
||||
FontFamily="{StaticResource FsFontMono}"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource FsAccentGreen}"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<StackPanel Grid.Column="4" Orientation="Horizontal"
|
||||
VerticalAlignment="Center">
|
||||
<Path Width="12" Height="12" Stretch="Uniform"
|
||||
Stroke="{StaticResource FsAccentGreen}"
|
||||
StrokeThickness="3"
|
||||
Data="M20,6 L9,17 L4,12"/>
|
||||
<TextBlock Text="Success" Margin="6,0,0,0"
|
||||
FontSize="11" FontWeight="Medium"
|
||||
Foreground="{StaticResource FsAccentGreen}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="5"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="6"
|
||||
Click="OnOpenFolderClick"
|
||||
Tag="{Binding SourcePath}"
|
||||
ToolTip="원본 폴더 열기">
|
||||
<Path Width="14" Height="14" Stretch="Uniform"
|
||||
Stroke="{StaticResource FsTextTertiary}"
|
||||
StrokeThickness="2"
|
||||
Data="M22,19 C22,20.1 21.1,21 20,21 L4,21 C2.9,21 2,20.1 2,19 L2,5 C2,3.9 2.9,3 4,3 L9,3 L11,6 L20,6 C21.1,6 22,6.9 22,8 Z"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
<!-- /좌측 컬럼 끝 -->
|
||||
|
||||
<!-- 우측: Preview 컬럼 (항상 보임) -->
|
||||
<Border Grid.Column="1"
|
||||
Background="{StaticResource FsBgPanel}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="1,0,0,0">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<Border Grid.Row="0" Padding="20,16,20,12"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Path Width="14" Height="14" Stretch="Uniform"
|
||||
Stroke="{StaticResource FsTextSecondary}" StrokeThickness="2"
|
||||
Data="M1,12 C1,12 5,4 12,4 C19,4 23,12 23,12 C23,12 19,20 12,20 C5,20 1,12 1,12 Z M12,9 A3,3 0 1,1 12,15 A3,3 0 1,1 12,9 Z"/>
|
||||
<TextBlock Text="PREVIEW" Margin="8,0,0,0"
|
||||
Style="{StaticResource FsLabelStyle}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 이미지 영역 -->
|
||||
<Grid Grid.Row="1" Margin="20,16,20,16" x:Name="PreviewImageArea">
|
||||
<Border Background="{StaticResource FsBgInput}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="1" CornerRadius="6">
|
||||
<Grid>
|
||||
<!-- Empty state -->
|
||||
<StackPanel x:Name="PreviewEmpty" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="20">
|
||||
<Path Width="36" Height="36" Stretch="Uniform"
|
||||
Stroke="{StaticResource FsTextTertiary}" StrokeThickness="1.5"
|
||||
Data="M1,12 C1,12 5,4 12,4 C19,4 23,12 23,12 C23,12 19,20 12,20 C5,20 1,12 1,12 Z M12,9 A3,3 0 1,1 12,15 A3,3 0 1,1 12,9 Z"/>
|
||||
<TextBlock Margin="0,12,0,0" FontSize="13" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource FsTextSecondary}"
|
||||
HorizontalAlignment="Center"
|
||||
Text="No selection"/>
|
||||
<TextBlock Margin="0,4,0,0" Style="{StaticResource FsCaptionStyle}"
|
||||
HorizontalAlignment="Center" TextAlignment="Center"
|
||||
TextWrapping="Wrap" MaxWidth="240"
|
||||
Text="Active Queue 항목을 클릭하면 여기에 미리보기가 표시됩니다."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Loading -->
|
||||
<StackPanel x:Name="PreviewLoading" Visibility="Collapsed"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<ProgressBar IsIndeterminate="True" Width="180" Height="3"
|
||||
Foreground="{StaticResource FsAccentBlue}"
|
||||
Background="{StaticResource FsBgSurface}"/>
|
||||
<TextBlock Margin="0,12,0,0" Style="{StaticResource FsCaptionStyle}"
|
||||
HorizontalAlignment="Center"
|
||||
Text="미리보기 생성 중…"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Image -->
|
||||
<Image x:Name="PreviewImage" Stretch="Uniform" Margin="16"
|
||||
Visibility="Collapsed"/>
|
||||
|
||||
<!-- Reason -->
|
||||
<StackPanel x:Name="PreviewReason" Visibility="Collapsed"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" Margin="20">
|
||||
<Path Width="32" Height="32" Stretch="Uniform"
|
||||
Stroke="{StaticResource FsAccentAmber}" StrokeThickness="1.5"
|
||||
Data="M12,2 L22,20 L2,20 Z M12,9 L12,14 M12,17 L12,17.01"/>
|
||||
<TextBlock x:Name="PreviewReasonText" Margin="0,12,0,0"
|
||||
Style="{StaticResource FsBodyStyle}"
|
||||
FontSize="12"
|
||||
HorizontalAlignment="Center" TextAlignment="Center"
|
||||
TextWrapping="Wrap" MaxWidth="280"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- 메타 -->
|
||||
<Border Grid.Row="2" Padding="20,12,20,20"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="0,1,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="PreviewFileName"
|
||||
Style="{StaticResource FsBodyStyle}"
|
||||
FontWeight="SemiBold" TextWrapping="NoWrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Text="—"/>
|
||||
<TextBlock x:Name="PreviewFilePath" Margin="0,4,0,0"
|
||||
FontFamily="{StaticResource FsFontMono}" FontSize="11"
|
||||
Foreground="{StaticResource FsTextTertiary}"
|
||||
TextTrimming="CharacterEllipsis" MaxHeight="32"/>
|
||||
|
||||
<Border Margin="0,12,0,12" Height="1"
|
||||
Background="{StaticResource FsBorderSubtle}"/>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Format"
|
||||
Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,4"/>
|
||||
<TextBlock x:Name="PreviewFormatText" Grid.Row="0" Grid.Column="1"
|
||||
FontFamily="{StaticResource FsFontMono}" FontSize="12"
|
||||
Foreground="{StaticResource FsTextPrimary}" Margin="0,0,0,4"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Size"
|
||||
Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,4"/>
|
||||
<TextBlock x:Name="PreviewSizeText" Grid.Row="1" Grid.Column="1"
|
||||
FontFamily="{StaticResource FsFontMono}" FontSize="12"
|
||||
Foreground="{StaticResource FsTextPrimary}" Margin="0,0,0,4"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Dimensions"
|
||||
Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,4"/>
|
||||
<TextBlock x:Name="PreviewDimText" Grid.Row="2" Grid.Column="1"
|
||||
FontFamily="{StaticResource FsFontMono}" FontSize="12"
|
||||
Foreground="{StaticResource FsTextPrimary}" Text="—"
|
||||
Margin="0,0,0,4"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Pages"
|
||||
Style="{StaticResource FsCaptionStyle}"/>
|
||||
<TextBlock x:Name="PreviewPageText" Grid.Row="3" Grid.Column="1"
|
||||
FontFamily="{StaticResource FsFontMono}" FontSize="12"
|
||||
Foreground="{StaticResource FsTextPrimary}" Text="—"/>
|
||||
</Grid>
|
||||
|
||||
<Button Content="Open in Explorer" Margin="0,16,0,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Click="OnPreviewOpenFolder"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Drop hint overlay (전체 덮음) -->
|
||||
<Border x:Name="DropHintOverlay" Visibility="Collapsed" Grid.ColumnSpan="2"
|
||||
Background="#CC090A0C" IsHitTestVisible="False">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Path Width="64" Height="64" Stretch="Uniform"
|
||||
Stroke="{StaticResource FsAccentBlue}" StrokeThickness="2"
|
||||
Data="M21,15 L21,19 C21,20.1 20.1,21 19,21 L5,21 C3.9,21 3,20.1 3,19 L3,15 M7,10 L12,15 L17,10 M12,15 L12,3"/>
|
||||
<TextBlock Text="Drop to add to queue" Margin="0,16,0,0"
|
||||
FontSize="18" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource FsTextPrimary}"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ui:FluentWindow>
|
||||
892
src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
Normal file
892
src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,892 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
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;
|
||||
|
||||
namespace EverythingToJpeg.App.Views;
|
||||
|
||||
public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||
{
|
||||
private readonly ObservableCollection<QueueItem> _activeQueue = new();
|
||||
private readonly ObservableCollection<DateGroup> _pastResults = new();
|
||||
private CancellationTokenSource? _cts;
|
||||
private NameCollision _conflictRule = NameCollision.AppendNumber;
|
||||
|
||||
public ICommand AddFilesCommand { get; }
|
||||
public ICommand ProcessQueueCommand { get; }
|
||||
public ICommand CloseCommand { get; }
|
||||
public ICommand RefreshCommand { get; }
|
||||
|
||||
public MainWindow() : this(null) { }
|
||||
|
||||
public MainWindow(IReadOnlyList<string>? initialFiles)
|
||||
{
|
||||
AddFilesCommand = new RelayCommand(_ => PickAndAddFiles());
|
||||
ProcessQueueCommand = new RelayCommand(_ => OnProcessQueueClick(this, new RoutedEventArgs()),
|
||||
_ => _activeQueue.Count > 0 && _cts is null);
|
||||
CloseCommand = new RelayCommand(_ => Close());
|
||||
RefreshCommand = new RelayCommand(_ => ApplyAppDataStats());
|
||||
|
||||
InitializeComponent();
|
||||
|
||||
ActiveQueueList.ItemsSource = _activeQueue;
|
||||
PastResultsList.ItemsSource = _pastResults;
|
||||
|
||||
LoadHistory();
|
||||
UpdateBadges();
|
||||
UpdateProcessQueueButton();
|
||||
|
||||
if (initialFiles is { Count: > 0 })
|
||||
{
|
||||
AddToQueue(initialFiles);
|
||||
ShowTab("Active");
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowTab("Past");
|
||||
}
|
||||
|
||||
UpdateActiveQueueVisibility();
|
||||
ApplyAppDataStats();
|
||||
|
||||
_ = RefreshCapabilityStatusAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshCapabilityStatusAsync()
|
||||
{
|
||||
var engine = ((App)Application.Current).Engine;
|
||||
var notReady = new List<string>();
|
||||
foreach (var p in engine.Providers.All)
|
||||
{
|
||||
if (p.Capability.Status == EverythingToJpeg.Core.Providers.ProviderStatus.RequiresExternal)
|
||||
{
|
||||
var availability = await p.CheckAvailabilityAsync();
|
||||
if (!availability.IsReady)
|
||||
notReady.Add(p.Capability.DisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
if (notReady.Count == 0)
|
||||
{
|
||||
CapabilityStatusText.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
CapabilityStatusText.Text = $"⚠ {notReady.Count}개 형식이 외부 도구를 기다립니다 (Diagnose 참조)";
|
||||
CapabilityStatusText.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
private void PickAndAddFiles()
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Multiselect = true,
|
||||
Title = "변환할 파일 추가",
|
||||
Filter = "지원 파일|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.tif;*.tiff;*.webp;*.avif;*.heic;*.heif;*.psd;*.dng;*.nef;*.cr2;*.cr3;*.arw;*.raf;*.orf;*.rw2;*.srw;*.pef;*.pdf;*.docx;*.doc;*.html;*.htm;*.hwp;*.hwpx|모든 파일|*.*",
|
||||
};
|
||||
if (dlg.ShowDialog(this) == true)
|
||||
{
|
||||
AddToQueue(dlg.FileNames);
|
||||
ShowTab("Active");
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Tabs ==============
|
||||
|
||||
private void OnTabClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is ToggleButton tb && tb.Tag is string tag)
|
||||
{
|
||||
ShowTab(tag);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowTab(string tag)
|
||||
{
|
||||
TabActiveBtn.IsChecked = tag == "Active";
|
||||
TabPastBtn.IsChecked = tag == "Past";
|
||||
|
||||
ActiveQueueView.Visibility = tag == "Active" ? Visibility.Visible : Visibility.Collapsed;
|
||||
PastResultsView.Visibility = tag == "Past" ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
// ============== Drag & Drop ==============
|
||||
|
||||
private void OnDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop)
|
||||
? DragDropEffects.Copy : DragDropEffects.None;
|
||||
DropHintOverlay.Visibility = e.Effects == DragDropEffects.Copy
|
||||
? Visibility.Visible : Visibility.Collapsed;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnDragLeave(object sender, DragEventArgs e)
|
||||
{
|
||||
DropHintOverlay.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private void OnFilesDropped(object sender, DragEventArgs e)
|
||||
{
|
||||
DropHintOverlay.Visibility = Visibility.Collapsed;
|
||||
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
|
||||
if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths) return;
|
||||
|
||||
AddToQueue(ExpandPaths(paths));
|
||||
ShowTab("Active");
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ExpandPaths(IEnumerable<string> paths)
|
||||
{
|
||||
foreach (var p in paths)
|
||||
{
|
||||
if (File.Exists(p)) yield return p;
|
||||
else if (Directory.Exists(p))
|
||||
{
|
||||
foreach (var f in Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly))
|
||||
yield return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddToQueue(IEnumerable<string> paths)
|
||||
{
|
||||
var wasEmpty = _activeQueue.Count == 0;
|
||||
var existing = new HashSet<string>(_activeQueue.Select(q => q.SourcePath), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (!File.Exists(path) || existing.Contains(path)) continue;
|
||||
_activeQueue.Add(QueueItem.FromPath(path));
|
||||
}
|
||||
UpdateBadges();
|
||||
UpdateProcessQueueButton();
|
||||
UpdateActiveQueueVisibility();
|
||||
|
||||
if (wasEmpty && _activeQueue.Count > 0 && _selectedPreviewItem is null)
|
||||
{
|
||||
_selectedPreviewItem = _activeQueue[0];
|
||||
_ = LoadPreviewAsync(_selectedPreviewItem);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRemoveQueueItem(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement fe && fe.Tag is QueueItem item)
|
||||
{
|
||||
_activeQueue.Remove(item);
|
||||
UpdateBadges();
|
||||
UpdateProcessQueueButton();
|
||||
UpdateActiveQueueVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateActiveQueueVisibility()
|
||||
{
|
||||
var hasItems = _activeQueue.Count > 0;
|
||||
DropZoneEmpty.Visibility = hasItems ? Visibility.Collapsed : Visibility.Visible;
|
||||
ActiveQueueScroll.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private void UpdateBadges()
|
||||
{
|
||||
TabActiveBadge.Text = _activeQueue.Count.ToString(CultureInfo.InvariantCulture);
|
||||
var count = _pastResults.Sum(g => g.Entries.Count);
|
||||
TabPastBadge.Text = count.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private void UpdateProcessQueueButton()
|
||||
{
|
||||
var count = _activeQueue.Count;
|
||||
if (_cts is not null)
|
||||
{
|
||||
ProcessQueueButton.Content = $"Processing… ({count} files)";
|
||||
ProcessQueueButton.IsEnabled = false;
|
||||
}
|
||||
else if (count == 0)
|
||||
{
|
||||
ProcessQueueButton.Content = "Idle — drop files to begin";
|
||||
ProcessQueueButton.IsEnabled = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
ProcessQueueButton.Content = $"Process Queue ({count})";
|
||||
ProcessQueueButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Sidebar inputs ==============
|
||||
|
||||
private void OnQualityChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
{
|
||||
if (QualityValueText is null) return;
|
||||
QualityValueText.Text = $"{(int)e.NewValue}%";
|
||||
}
|
||||
|
||||
private void OnConflictSegmentClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not ToggleButton clicked) return;
|
||||
ConflictSkipBtn.IsChecked = clicked == ConflictSkipBtn;
|
||||
ConflictRenameBtn.IsChecked = clicked == ConflictRenameBtn;
|
||||
ConflictReplaceBtn.IsChecked = clicked == ConflictReplaceBtn;
|
||||
_conflictRule = (clicked.Tag as string) switch
|
||||
{
|
||||
"Skip" => NameCollision.Skip,
|
||||
"Replace" => NameCollision.Overwrite,
|
||||
_ => NameCollision.AppendNumber,
|
||||
};
|
||||
}
|
||||
|
||||
private void OnPickOutputFolderClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFolderDialog { Title = "출력 폴더 선택" };
|
||||
if (dlg.ShowDialog(this) == true)
|
||||
OutputPathTextBox.Text = dlg.FolderName;
|
||||
}
|
||||
|
||||
private ConvertOptions BuildOptions()
|
||||
{
|
||||
var opts = new ConvertOptions
|
||||
{
|
||||
Quality = (int)QualitySlider.Value,
|
||||
OnCollision = _conflictRule,
|
||||
};
|
||||
|
||||
var custom = OutputPathTextBox.Text?.Trim();
|
||||
if (!string.IsNullOrEmpty(custom))
|
||||
{
|
||||
opts.OutputLocation = OutputLocation.Custom;
|
||||
opts.CustomOutputDirectory = custom;
|
||||
}
|
||||
else
|
||||
{
|
||||
opts.OutputLocation = OutputLocation.SubfolderBesideSource;
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
// ============== Process queue ==============
|
||||
|
||||
// ============== Preview ==============
|
||||
|
||||
private QueueItem? _selectedPreviewItem;
|
||||
private string? _selectedPreviewPath;
|
||||
private CancellationTokenSource? _previewCts;
|
||||
|
||||
private async void OnQueueRowClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.OriginalSource is DependencyObject src && IsInsideButton(src)) return;
|
||||
if (sender is not FrameworkElement fe || fe.Tag is not QueueItem item) return;
|
||||
|
||||
_selectedPreviewItem = item;
|
||||
await LoadPreviewAsync(item);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async void OnPastRowClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.OriginalSource is DependencyObject src && IsInsideButton(src)) return;
|
||||
if (sender is not FrameworkElement fe || fe.Tag is not HistoryRow row) return;
|
||||
|
||||
if (row.SourcePath == "<demo>")
|
||||
{
|
||||
SetPreviewMeta(row.FileName, row.SourcePath, row.FormatLabel, row.SizeText);
|
||||
ShowPreviewReason("샘플 데모 항목입니다. 원본 파일이 없으므로 미리보기를 만들 수 없습니다.");
|
||||
_selectedPreviewPath = null;
|
||||
}
|
||||
else if (!File.Exists(row.SourcePath))
|
||||
{
|
||||
SetPreviewMeta(row.FileName, row.SourcePath, row.FormatLabel, row.SizeText);
|
||||
ShowPreviewReason("원본 파일을 찾을 수 없습니다. 파일이 이동·삭제되었을 수 있습니다.");
|
||||
_selectedPreviewPath = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedPreviewItem = null;
|
||||
_selectedPreviewPath = row.SourcePath;
|
||||
await LoadPreviewByPathAsync(row.SourcePath, row.FileName, row.FormatLabel, row.SizeText);
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private static bool IsInsideButton(DependencyObject? node)
|
||||
{
|
||||
while (node is not null)
|
||||
{
|
||||
if (node is Button) return true;
|
||||
node = System.Windows.Media.VisualTreeHelper.GetParent(node)
|
||||
?? (node is FrameworkElement fe ? fe.Parent : null);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Task LoadPreviewAsync(QueueItem item)
|
||||
{
|
||||
_selectedPreviewPath = item.SourcePath;
|
||||
return LoadPreviewByPathAsync(item.SourcePath, item.FileName, item.FormatLabel, item.SizeText);
|
||||
}
|
||||
|
||||
private async Task LoadPreviewByPathAsync(string sourcePath, string fileName, string formatLabel, string sizeText)
|
||||
{
|
||||
_previewCts?.Cancel();
|
||||
_previewCts = new CancellationTokenSource();
|
||||
var token = _previewCts.Token;
|
||||
|
||||
SetPreviewMeta(fileName, sourcePath, formatLabel, sizeText);
|
||||
ShowPreviewLoading();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await PreviewService.CreateAsync(sourcePath, 720, token);
|
||||
if (token.IsCancellationRequested) return;
|
||||
|
||||
PreviewLoading.Visibility = Visibility.Collapsed;
|
||||
|
||||
if (result.Image is not null)
|
||||
{
|
||||
PreviewImage.Source = result.Image;
|
||||
PreviewImage.Visibility = Visibility.Visible;
|
||||
}
|
||||
else
|
||||
{
|
||||
PreviewReasonText.Text = result.Reason ?? "미리보기를 생성하지 못했습니다.";
|
||||
PreviewReason.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
PreviewDimText.Text = result.Dimensions ?? "—";
|
||||
PreviewPageText.Text = result.PageCount?.ToString() ?? "—";
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
PreviewLoading.Visibility = Visibility.Collapsed;
|
||||
ShowPreviewReason("미리보기 오류: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetPreviewMeta(string fileName, string filePath, string formatLabel, string sizeText)
|
||||
{
|
||||
PreviewFileName.Text = fileName;
|
||||
PreviewFilePath.Text = filePath;
|
||||
PreviewFormatText.Text = formatLabel;
|
||||
PreviewSizeText.Text = sizeText;
|
||||
PreviewDimText.Text = "—";
|
||||
PreviewPageText.Text = "—";
|
||||
}
|
||||
|
||||
private void ShowPreviewLoading()
|
||||
{
|
||||
PreviewEmpty.Visibility = Visibility.Collapsed;
|
||||
PreviewImage.Visibility = Visibility.Collapsed;
|
||||
PreviewReason.Visibility = Visibility.Collapsed;
|
||||
PreviewLoading.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
private void ShowPreviewReason(string reason)
|
||||
{
|
||||
PreviewEmpty.Visibility = Visibility.Collapsed;
|
||||
PreviewImage.Visibility = Visibility.Collapsed;
|
||||
PreviewLoading.Visibility = Visibility.Collapsed;
|
||||
PreviewReasonText.Text = reason;
|
||||
PreviewReason.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
private void OnPreviewOpenFolder(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var path = _selectedPreviewPath ?? _selectedPreviewItem?.SourcePath;
|
||||
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return;
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"/select,\"{path}\"",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// ============== Export Log ==============
|
||||
|
||||
private void OnExportLogClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_pastResults.Count == 0)
|
||||
{
|
||||
MessageBox.Show(this, "저장할 이력이 없습니다.", "EverythingToJpeg",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var dlg = new Microsoft.Win32.SaveFileDialog
|
||||
{
|
||||
Title = "Export Log",
|
||||
FileName = $"EverythingToJpeg-log-{DateTime.Now:yyyyMMdd-HHmmss}.csv",
|
||||
DefaultExt = ".csv",
|
||||
Filter = "CSV (*.csv)|*.csv|JSON (*.json)|*.json",
|
||||
};
|
||||
if (dlg.ShowDialog(this) != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (dlg.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
ExportJson(dlg.FileName);
|
||||
else
|
||||
ExportCsv(dlg.FileName);
|
||||
|
||||
MessageBox.Show(this, "저장되었습니다:\n" + dlg.FileName, "EverythingToJpeg",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, "저장 중 오류: " + ex.Message, "EverythingToJpeg",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void ExportCsv(string path)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("Date,Format,FileName,SourcePath,Size,Savings,Meta");
|
||||
foreach (var group in _pastResults)
|
||||
foreach (var row in group.Entries)
|
||||
sb.AppendLine(string.Join(",",
|
||||
EscapeCsv(group.DateTitle),
|
||||
EscapeCsv(row.FormatLabel),
|
||||
EscapeCsv(row.FileName),
|
||||
EscapeCsv(row.SourcePath),
|
||||
EscapeCsv(row.SizeText),
|
||||
EscapeCsv(row.SavingsText),
|
||||
EscapeCsv(row.MetaLine)));
|
||||
File.WriteAllText(path, sb.ToString(), System.Text.Encoding.UTF8);
|
||||
}
|
||||
|
||||
private void ExportJson(string path)
|
||||
{
|
||||
var data = _pastResults.Select(g => new
|
||||
{
|
||||
date = g.DateTitle,
|
||||
sessionSavings = HumanizeBytes(g.SessionSavingsBytes),
|
||||
entries = g.Entries.Select(r => new
|
||||
{
|
||||
format = r.FormatLabel,
|
||||
fileName = r.FileName,
|
||||
sourcePath = r.SourcePath,
|
||||
size = r.SizeText,
|
||||
savings = r.SavingsText,
|
||||
meta = r.MetaLine,
|
||||
}),
|
||||
});
|
||||
File.WriteAllText(path,
|
||||
System.Text.Json.JsonSerializer.Serialize(data,
|
||||
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }),
|
||||
System.Text.Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static string EscapeCsv(string? s)
|
||||
{
|
||||
s ??= "";
|
||||
if (s.Contains('"') || s.Contains(',') || s.Contains('\n'))
|
||||
return "\"" + s.Replace("\"", "\"\"") + "\"";
|
||||
return s;
|
||||
}
|
||||
|
||||
private async void OnProcessQueueClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_activeQueue.Count == 0) return;
|
||||
|
||||
var snapshot = _activeQueue.ToList();
|
||||
foreach (var item in snapshot) item.SetPending();
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
UpdateProcessQueueButton();
|
||||
|
||||
var engine = ((App)Application.Current).Engine;
|
||||
var options = BuildOptions();
|
||||
|
||||
var reporter = new Progress<ConvertProgress>(p =>
|
||||
{
|
||||
for (var i = 0; i < snapshot.Count; i++)
|
||||
{
|
||||
if (i < p.Index) snapshot[i].SetState("done");
|
||||
else if (i == p.Index) snapshot[i].SetState($"{(int)(p.FileProgress * 100)}%");
|
||||
else snapshot[i].SetState("queued");
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var sources = snapshot.Select(s => s.SourcePath).ToList();
|
||||
var results = await engine.ConvertManyAsync(sources, options, reporter, _cts.Token);
|
||||
|
||||
foreach (var (item, result) in snapshot.Zip(results))
|
||||
{
|
||||
long outputSize = 0;
|
||||
foreach (var p in result.OutputPaths)
|
||||
{
|
||||
try { outputSize += new FileInfo(p).Length; } catch { }
|
||||
}
|
||||
|
||||
AddToHistory(new HistoryEntry(
|
||||
Timestamp: DateTime.Now,
|
||||
SourcePath: item.SourcePath,
|
||||
SourceFormat: item.FormatLabel,
|
||||
SourceSizeBytes: item.SourceSizeBytes,
|
||||
OutputSizeBytes: outputSize,
|
||||
OutputCount: result.OutputPaths.Count,
|
||||
MetaLine: item.MetaLine,
|
||||
Status: result.Status,
|
||||
Message: result.Message));
|
||||
|
||||
_activeQueue.Remove(item);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, "변환 중 오류: " + ex.Message, "EverythingToJpeg",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cts = null;
|
||||
UpdateBadges();
|
||||
UpdateProcessQueueButton();
|
||||
UpdateActiveQueueVisibility();
|
||||
ApplyAppDataStats();
|
||||
if (_activeQueue.Count == 0) ShowTab("Past");
|
||||
}
|
||||
}
|
||||
|
||||
// ============== History ==============
|
||||
|
||||
private void AddToHistory(HistoryEntry entry)
|
||||
{
|
||||
AddToHistoryGroups(entry);
|
||||
HistoryStorage.Append(entry);
|
||||
}
|
||||
|
||||
private void AddToHistoryGroups(HistoryEntry entry)
|
||||
{
|
||||
var label = FormatDateLabel(entry.Date);
|
||||
var group = _pastResults.FirstOrDefault(g => g.DateTitle == label);
|
||||
if (group is null)
|
||||
{
|
||||
group = new DateGroup(label);
|
||||
_pastResults.Insert(0, group);
|
||||
}
|
||||
group.Add(HistoryRow.From(entry));
|
||||
}
|
||||
|
||||
private void LoadHistory()
|
||||
{
|
||||
var entries = HistoryStorage.Load();
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
// 첫 실행: 데모 데이터로 시각적 가이드 제공
|
||||
SeedDemoHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
// 가장 오래된 것부터 추가 (Insert(0)이 누적)
|
||||
foreach (var e in entries.OrderBy(e => e.Timestamp))
|
||||
AddToHistoryGroups(e);
|
||||
}
|
||||
|
||||
private void SeedDemoHistory()
|
||||
{
|
||||
var today = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today));
|
||||
var todayGroup = new DateGroup(today);
|
||||
todayGroup.Add(new HistoryRow(
|
||||
FormatLabel: "PNG", FormatBrush: (Brush)FindResource("FsFmtPng"),
|
||||
FileName: "hero_background_final_v2.png",
|
||||
MetaLine: "08:42:12 • 3200x1800",
|
||||
SizeText: "14.2 MB",
|
||||
SavingsText: "↓ 1.1 MB",
|
||||
SourcePath: "<demo>"));
|
||||
todayGroup.Add(new HistoryRow(
|
||||
FormatLabel: "HEIC", FormatBrush: (Brush)FindResource("FsFmtHeic"),
|
||||
FileName: "portrait_session_04.heic",
|
||||
MetaLine: "08:35:45 • 4032x3024",
|
||||
SizeText: "6.8 MB",
|
||||
SavingsText: "↓ 2.4 MB",
|
||||
SourcePath: "<demo>"));
|
||||
todayGroup.SessionSavingsBytes = (long)(842.4 * 1024 * 1024);
|
||||
_pastResults.Add(todayGroup);
|
||||
|
||||
var yesterday = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today.AddDays(-1)));
|
||||
var yGroup = new DateGroup(yesterday);
|
||||
yGroup.Add(new HistoryRow(
|
||||
FormatLabel: "PDF", FormatBrush: (Brush)FindResource("FsFmtPdf"),
|
||||
FileName: "Q3_Full_Marketing_Deck_v12.pdf",
|
||||
MetaLine: "17:22:10 • 124 Pages",
|
||||
SizeText: "245.4 MB",
|
||||
SavingsText: "↓ 12.8 MB",
|
||||
SourcePath: "<demo>"));
|
||||
yGroup.Add(new HistoryRow(
|
||||
FormatLabel: "PNG", FormatBrush: (Brush)FindResource("FsFmtPng"),
|
||||
FileName: "asset_bundle_archive_raw.png",
|
||||
MetaLine: "16:45:33 • 8000x8000",
|
||||
SizeText: "82.1 MB",
|
||||
SavingsText: "↓ 4.5 MB",
|
||||
SourcePath: "<demo>"));
|
||||
yGroup.SessionSavingsBytes = (long)(3.1 * 1024 * 1024 * 1024);
|
||||
_pastResults.Add(yGroup);
|
||||
|
||||
UpdateBadges();
|
||||
}
|
||||
|
||||
private static string FormatDateLabel(DateOnly date)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var label = date == today ? "Today"
|
||||
: date == today.AddDays(-1) ? "Yesterday"
|
||||
: date.ToString("dddd", CultureInfo.GetCultureInfo("en-US"));
|
||||
return $"{label}, {date:MMM d}";
|
||||
}
|
||||
|
||||
private void ApplyAppDataStats()
|
||||
{
|
||||
var todayLabel = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today));
|
||||
var todayGroup = _pastResults.FirstOrDefault(g => g.DateTitle == todayLabel);
|
||||
|
||||
var processedToday = todayGroup?.Entries.Count ?? 0;
|
||||
var allSavings = _pastResults.Sum(g => g.SessionSavingsBytes);
|
||||
|
||||
ProcessedTodayText.Text = processedToday.ToString("N0", CultureInfo.InvariantCulture);
|
||||
SpaceSavedText.Text = HumanizeBytes(allSavings);
|
||||
}
|
||||
|
||||
// ============== Top-bar actions ==============
|
||||
|
||||
private void OnRegisterClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
ContextMenuRegistrar.Register(((App)Application.Current).Engine);
|
||||
MessageBox.Show(this,
|
||||
"컨텍스트 메뉴를 등록했습니다.\n파일 우클릭 → \"추가 옵션 표시\" 또는 \"JPEG로 빠른 변환/변환…\".",
|
||||
"EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, "등록 중 오류: " + ex.Message, "EverythingToJpeg",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDiagnoseClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var window = new DiagnoseWindow(((App)Application.Current).Engine) { Owner = this };
|
||||
window.ShowDialog();
|
||||
}
|
||||
|
||||
private void OnClearAllClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (TabActiveBtn.IsChecked == true)
|
||||
{
|
||||
_activeQueue.Clear();
|
||||
}
|
||||
else if (TabPastBtn.IsChecked == true)
|
||||
{
|
||||
var confirm = MessageBox.Show(this,
|
||||
"Past Results 전체를 삭제하시겠습니까?\n영구 저장된 이력도 함께 삭제됩니다.",
|
||||
"EverythingToJpeg",
|
||||
MessageBoxButton.OKCancel, MessageBoxImage.Question);
|
||||
if (confirm != MessageBoxResult.OK) return;
|
||||
|
||||
_pastResults.Clear();
|
||||
HistoryStorage.Clear();
|
||||
}
|
||||
UpdateBadges();
|
||||
UpdateProcessQueueButton();
|
||||
UpdateActiveQueueVisibility();
|
||||
ApplyAppDataStats();
|
||||
}
|
||||
|
||||
private void OnOpenFolderClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement fe && fe.Tag is string path && File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"/select,\"{path}\"",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public static string HumanizeBytes(long bytes)
|
||||
{
|
||||
if (bytes <= 0) return "0 B";
|
||||
string[] units = { "B", "KB", "MB", "GB", "TB" };
|
||||
double size = bytes;
|
||||
var unit = 0;
|
||||
while (size >= 1024 && unit < units.Length - 1) { size /= 1024; unit++; }
|
||||
return $"{size:0.#} {units[unit]}";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// View models
|
||||
// ============================================================
|
||||
|
||||
public sealed class QueueItem : INotifyPropertyChanged
|
||||
{
|
||||
private string _state = "queued";
|
||||
|
||||
public required string SourcePath { get; init; }
|
||||
public required string FileName { get; init; }
|
||||
public required string FormatLabel { get; init; }
|
||||
public required Brush FormatBrush { get; init; }
|
||||
public required string SizeText { get; init; }
|
||||
public required string MetaLine { get; init; }
|
||||
public required long SourceSizeBytes { get; init; }
|
||||
|
||||
public string StateText
|
||||
{
|
||||
get => _state;
|
||||
set { _state = value; Raise(nameof(StateText)); }
|
||||
}
|
||||
|
||||
public Brush StateBrush => _state switch
|
||||
{
|
||||
"queued" => (Brush)Application.Current.FindResource("FsTextTertiary"),
|
||||
"done" => (Brush)Application.Current.FindResource("FsAccentGreen"),
|
||||
_ => (Brush)Application.Current.FindResource("FsAccentBlue"),
|
||||
};
|
||||
|
||||
public void SetPending() => StateText = "queued";
|
||||
public void SetState(string s)
|
||||
{
|
||||
StateText = s;
|
||||
Raise(nameof(StateBrush));
|
||||
}
|
||||
|
||||
public static QueueItem FromPath(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path).TrimStart('.').ToLowerInvariant();
|
||||
var (label, brushKey) = FormatPalette.For(ext);
|
||||
long size = 0;
|
||||
try { size = new FileInfo(path).Length; } catch { }
|
||||
|
||||
return new QueueItem
|
||||
{
|
||||
SourcePath = path,
|
||||
FileName = Path.GetFileName(path),
|
||||
FormatLabel = label,
|
||||
FormatBrush = (Brush)Application.Current.FindResource(brushKey),
|
||||
SizeText = MainWindow.HumanizeBytes(size),
|
||||
MetaLine = $"{ext.ToUpperInvariant()} • {MainWindow.HumanizeBytes(size)}",
|
||||
SourceSizeBytes = size,
|
||||
};
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
private void Raise([CallerMemberName] string? n = null)
|
||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
|
||||
}
|
||||
|
||||
public sealed class DateGroup : INotifyPropertyChanged
|
||||
{
|
||||
public string DateTitle { get; }
|
||||
public ObservableCollection<HistoryRow> Entries { get; } = new();
|
||||
public long SessionSavingsBytes { get; set; }
|
||||
public string SessionSavingsText => $"Session Savings: {MainWindow.HumanizeBytes(SessionSavingsBytes)}";
|
||||
|
||||
public DateGroup(string dateTitle) { DateTitle = dateTitle; }
|
||||
|
||||
public void Add(HistoryRow row)
|
||||
{
|
||||
Entries.Insert(0, row);
|
||||
SessionSavingsBytes += row.SavingsBytes;
|
||||
Raise(nameof(SessionSavingsText));
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
private void Raise([CallerMemberName] string? n = null)
|
||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
|
||||
}
|
||||
|
||||
public sealed record HistoryRow(
|
||||
string FormatLabel,
|
||||
Brush FormatBrush,
|
||||
string FileName,
|
||||
string MetaLine,
|
||||
string SizeText,
|
||||
string SavingsText,
|
||||
string SourcePath,
|
||||
long SavingsBytes = 0)
|
||||
{
|
||||
public static HistoryRow From(HistoryEntry e)
|
||||
{
|
||||
var ext = Path.GetExtension(e.SourcePath).TrimStart('.').ToLowerInvariant();
|
||||
var (label, brushKey) = FormatPalette.For(ext);
|
||||
var saved = e.SavingsBytes;
|
||||
var arrow = saved >= 0 ? "↓" : "↑";
|
||||
return new HistoryRow(
|
||||
FormatLabel: label,
|
||||
FormatBrush: (Brush)Application.Current.FindResource(brushKey),
|
||||
FileName: Path.GetFileName(e.SourcePath),
|
||||
MetaLine: $"{e.Timestamp:HH:mm:ss} • {e.OutputCount} output(s)",
|
||||
SizeText: MainWindow.HumanizeBytes(e.SourceSizeBytes),
|
||||
SavingsText: $"{arrow} {MainWindow.HumanizeBytes(Math.Abs(saved))}",
|
||||
SourcePath: e.SourcePath,
|
||||
SavingsBytes: saved);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class FormatPalette
|
||||
{
|
||||
public static (string Label, string BrushKey) For(string ext) => ext switch
|
||||
{
|
||||
"pdf" => ("PDF", "FsFmtPdf"),
|
||||
"png" => ("PNG", "FsFmtPng"),
|
||||
"heic" or "heif" => ("HEIC", "FsFmtHeic"),
|
||||
"jpg" or "jpeg" or "jpe" => ("JPG", "FsFmtJpg"),
|
||||
"doc" or "docx" => ("DOCX", "FsFmtDocx"),
|
||||
"html" or "htm" => ("HTML", "FsFmtHtml"),
|
||||
"hwp" or "hwpx" => ("HWP", "FsFmtHwp"),
|
||||
"gif" => ("GIF", "FsFmtGif"),
|
||||
"tif" or "tiff" => ("TIFF", "FsFmtTiff"),
|
||||
"webp" => ("WEBP", "FsFmtWebp"),
|
||||
"bmp" => ("BMP", "FsFmtBmp"),
|
||||
"raw" or "dng" or "nef" or "cr2" or "cr3" or "arw" or "raf" or "orf" or "rw2" or "srw" or "pef"
|
||||
=> ("RAW", "FsFmtRaw"),
|
||||
_ => (ext.ToUpperInvariant(), "FsFmtOther"),
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class RelayCommand : ICommand
|
||||
{
|
||||
private readonly Action<object?> _execute;
|
||||
private readonly Func<object?, bool>? _canExecute;
|
||||
|
||||
public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
|
||||
{
|
||||
_execute = execute;
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
|
||||
public void Execute(object? parameter) => _execute(parameter);
|
||||
|
||||
public event EventHandler? CanExecuteChanged
|
||||
{
|
||||
add { CommandManager.RequerySuggested += value; }
|
||||
remove { CommandManager.RequerySuggested -= value; }
|
||||
}
|
||||
}
|
||||
40
src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml
Normal file
40
src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.QuickProgressWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
Title="JPEG로 빠른 변환"
|
||||
Width="560" Height="220"
|
||||
ExtendsContentIntoTitleBar="True"
|
||||
WindowBackdropType="Mica"
|
||||
WindowCornerPreference="Round"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
ResizeMode="NoResize">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<ui:TitleBar Grid.Row="0" Title="JPEG로 빠른 변환"/>
|
||||
<Grid Grid.Row="1" Margin="32,12,32,24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="변환 중…" Style="{StaticResource TextSubtitle}"/>
|
||||
<TextBlock x:Name="StatusText" Grid.Row="1" Margin="0,4,0,12"
|
||||
Style="{StaticResource TextCaption}" TextTrimming="CharacterEllipsis"/>
|
||||
<ProgressBar x:Name="OverallProgress" Grid.Row="2" Height="6"
|
||||
Minimum="0" Maximum="1"/>
|
||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<ui:Button x:Name="OpenFolderButton" Content="결과 폴더 열기"
|
||||
Icon="{ui:SymbolIcon Folder24}"
|
||||
Click="OnOpenFolderClick" IsEnabled="False" Margin="0,0,8,0"/>
|
||||
<ui:Button x:Name="CloseButton" Content="닫기" Click="OnCloseClick"
|
||||
Appearance="Primary" IsEnabled="False"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ui:FluentWindow>
|
||||
81
src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml.cs
Normal file
81
src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using System.Windows;
|
||||
using EverythingToJpeg.Core;
|
||||
using Wpf.Ui.Controls;
|
||||
|
||||
namespace EverythingToJpeg.App.Views;
|
||||
|
||||
public partial class QuickProgressWindow : FluentWindow
|
||||
{
|
||||
private readonly int _total;
|
||||
private string? _firstSuccessOutput;
|
||||
|
||||
public QuickProgressWindow(int total)
|
||||
{
|
||||
_total = total;
|
||||
InitializeComponent();
|
||||
StatusText.Text = $"0 / {_total}";
|
||||
}
|
||||
|
||||
public void Report(ConvertProgress p)
|
||||
{
|
||||
if (!CheckAccess()) { Dispatcher.Invoke(() => Report(p)); return; }
|
||||
var overall = _total == 0 ? 0 : (p.Index + p.FileProgress) / _total;
|
||||
OverallProgress.Value = Math.Clamp(overall, 0, 1);
|
||||
StatusText.Text = $"{Math.Min(p.Index + 1, _total)} / {_total} — {Path.GetFileName(p.CurrentPath)}";
|
||||
}
|
||||
|
||||
public void Finish(IReadOnlyList<ConvertResult> results)
|
||||
{
|
||||
if (!CheckAccess()) { Dispatcher.Invoke(() => Finish(results)); return; }
|
||||
|
||||
var success = results.Count(r => r.Status == ConvertStatus.Success);
|
||||
var skipped = results.Count(r => r.Status == ConvertStatus.Skipped);
|
||||
var failed = results.Count(r => r.Status == ConvertStatus.Failed);
|
||||
var outputs = results.Sum(r => r.OutputPaths.Count);
|
||||
|
||||
OverallProgress.Value = 1;
|
||||
StatusText.Text = $"성공 {success}개 (출력 {outputs}), 건너뜀 {skipped}, 실패 {failed}";
|
||||
CloseButton.IsEnabled = true;
|
||||
|
||||
_firstSuccessOutput = results
|
||||
.FirstOrDefault(r => r.Status == ConvertStatus.Success)?
|
||||
.OutputPaths.FirstOrDefault();
|
||||
OpenFolderButton.IsEnabled = _firstSuccessOutput is not null;
|
||||
|
||||
if (failed > 0)
|
||||
{
|
||||
var detail = string.Join("\n",
|
||||
results.Where(r => r.Status == ConvertStatus.Failed)
|
||||
.Take(5)
|
||||
.Select(r => $"• {Path.GetFileName(r.SourcePath)}: {r.Message}"));
|
||||
MessageBox.Show(this, "일부 파일 변환에 실패했습니다.\n\n" + detail,
|
||||
"EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
else if (failed == 0 && skipped == 0 && _firstSuccessOutput is not null)
|
||||
{
|
||||
OpenInExplorer(_firstSuccessOutput);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpenFolderClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_firstSuccessOutput is not null) OpenInExplorer(_firstSuccessOutput);
|
||||
}
|
||||
|
||||
private void OnCloseClick(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private static void OpenInExplorer(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"/select,\"{path}\"",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
27
src/EverythingToJpeg.App/app.manifest
Normal file
27
src/EverythingToJpeg.App/app.manifest
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="EverythingToJpeg.App"/>
|
||||
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> <!-- Win10/11 -->
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
99
src/EverythingToJpeg.Core/ConversionEngine.cs
Normal file
99
src/EverythingToJpeg.Core/ConversionEngine.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
using EverythingToJpeg.Core.Providers;
|
||||
|
||||
namespace EverythingToJpeg.Core;
|
||||
|
||||
public sealed class ConversionEngine
|
||||
{
|
||||
private readonly ProviderRegistry _registry;
|
||||
|
||||
public ConversionEngine(ProviderRegistry registry)
|
||||
{
|
||||
_registry = registry;
|
||||
}
|
||||
|
||||
public ProviderRegistry Providers => _registry;
|
||||
|
||||
public async Task<IReadOnlyList<ConvertResult>> ConvertManyAsync(
|
||||
IEnumerable<string> sources,
|
||||
ConvertOptions options,
|
||||
IProgress<ConvertProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sourceList = sources.ToList();
|
||||
var results = new List<ConvertResult>(sourceList.Count);
|
||||
|
||||
for (var i = 0; i < sourceList.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var source = sourceList[i];
|
||||
progress?.Report(new ConvertProgress(i, sourceList.Count, source, 0));
|
||||
|
||||
var result = await ConvertOneAsync(source, options,
|
||||
new Progress<double>(p => progress?.Report(new ConvertProgress(i, sourceList.Count, source, p))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results.Add(result);
|
||||
progress?.Report(new ConvertProgress(i + 1, sourceList.Count, source, 1));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ConvertResult> ConvertOneAsync(
|
||||
string sourcePath,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(sourcePath))
|
||||
return ConvertResult.Fail(sourcePath, "파일을 찾을 수 없습니다.");
|
||||
|
||||
if (!_registry.TryGetForFile(sourcePath, out var provider) || provider is null)
|
||||
return ConvertResult.Fail(sourcePath, $"지원하지 않는 형식입니다: {Path.GetExtension(sourcePath)}");
|
||||
|
||||
var availability = await provider.CheckAvailabilityAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!availability.IsReady)
|
||||
{
|
||||
var missing = availability.MissingDependencies?.Select(d => d.Name) ?? Array.Empty<string>();
|
||||
var detail = availability.Reason ?? "필수 의존성이 준비되지 않았습니다.";
|
||||
if (missing.Any()) detail += $" (필요: {string.Join(", ", missing)})";
|
||||
return ConvertResult.Fail(sourcePath, detail);
|
||||
}
|
||||
|
||||
var outputDir = ResolveOutputDirectory(sourcePath, options);
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
try
|
||||
{
|
||||
return await provider.ConvertAsync(sourcePath, outputDir, options, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ConvertResult.Fail(sourcePath, ex.Message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveOutputDirectory(string sourcePath, ConvertOptions options)
|
||||
{
|
||||
var sourceDir = Path.GetDirectoryName(Path.GetFullPath(sourcePath))
|
||||
?? throw new InvalidOperationException("소스 경로에서 폴더를 결정할 수 없습니다.");
|
||||
|
||||
return options.OutputLocation switch
|
||||
{
|
||||
OutputLocation.SameFolderAsSource => sourceDir,
|
||||
OutputLocation.Custom => string.IsNullOrWhiteSpace(options.CustomOutputDirectory)
|
||||
? sourceDir
|
||||
: options.CustomOutputDirectory!,
|
||||
_ => Path.Combine(sourceDir,
|
||||
Path.GetFileNameWithoutExtension(sourcePath) + options.SubfolderSuffix),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ConvertProgress(int Index, int Total, string CurrentPath, double FileProgress);
|
||||
48
src/EverythingToJpeg.Core/ConvertOptions.cs
Normal file
48
src/EverythingToJpeg.Core/ConvertOptions.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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();
|
||||
}
|
||||
25
src/EverythingToJpeg.Core/ConvertResult.cs
Normal file
25
src/EverythingToJpeg.Core/ConvertResult.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
namespace EverythingToJpeg.Core;
|
||||
|
||||
public enum ConvertStatus
|
||||
{
|
||||
Success,
|
||||
Skipped,
|
||||
Failed
|
||||
}
|
||||
|
||||
public sealed record ConvertResult(
|
||||
string SourcePath,
|
||||
IReadOnlyList<string> OutputPaths,
|
||||
ConvertStatus Status,
|
||||
string? Message = null,
|
||||
Exception? Error = null)
|
||||
{
|
||||
public static ConvertResult Ok(string source, IReadOnlyList<string> outputs)
|
||||
=> new(source, outputs, ConvertStatus.Success);
|
||||
|
||||
public static ConvertResult Fail(string source, string message, Exception? ex = null)
|
||||
=> new(source, Array.Empty<string>(), ConvertStatus.Failed, message, ex);
|
||||
|
||||
public static ConvertResult Skip(string source, string message)
|
||||
=> new(source, Array.Empty<string>(), ConvertStatus.Skipped, message);
|
||||
}
|
||||
169
src/EverythingToJpeg.Core/Converters/DocxProvider.cs
Normal file
169
src/EverythingToJpeg.Core/Converters/DocxProvider.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
using System.Diagnostics;
|
||||
using EverythingToJpeg.Core.Providers;
|
||||
|
||||
namespace EverythingToJpeg.Core.Converters;
|
||||
|
||||
public sealed class DocxProvider : IConverterProvider
|
||||
{
|
||||
private readonly PdfProvider _pdfProvider;
|
||||
|
||||
public DocxProvider(PdfProvider pdfProvider)
|
||||
{
|
||||
_pdfProvider = pdfProvider;
|
||||
}
|
||||
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "docx",
|
||||
DisplayName: "Word 문서 (DOCX)",
|
||||
Extensions: new[] { ".docx", ".doc" },
|
||||
Status: ProviderStatus.RequiresExternal,
|
||||
Summary: "DOCX/DOC 문서를 PDF로 변환한 뒤 페이지별 JPEG로 저장합니다.",
|
||||
ExternalDependencies: new[]
|
||||
{
|
||||
new ExternalDependency(
|
||||
Name: "Microsoft Word 또는 LibreOffice",
|
||||
Description: "DOCX → PDF 변환에 둘 중 하나가 필요합니다. 둘 다 없으면 LibreOffice 설치를 권장합니다.",
|
||||
DownloadUrl: "https://www.libreoffice.org/download/",
|
||||
IsRequired: true),
|
||||
},
|
||||
RoadmapNote: "향후 OpenXML 기반 자체 렌더링 검토.");
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ExternalToolDetector.IsWordComAvailable())
|
||||
return Task.FromResult(ProviderAvailability.Ready);
|
||||
if (ExternalToolDetector.TryFindLibreOfficeSoffice(out _))
|
||||
return Task.FromResult(ProviderAvailability.Ready);
|
||||
|
||||
return Task.FromResult(ProviderAvailability.NotReady(
|
||||
"Microsoft Word 또는 LibreOffice가 설치되어 있어야 합니다.",
|
||||
Capability.ExternalDependencies));
|
||||
}
|
||||
|
||||
public async Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tempPdf = Path.Combine(Path.GetTempPath(),
|
||||
$"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
|
||||
|
||||
try
|
||||
{
|
||||
progress?.Report(0.05);
|
||||
|
||||
var converted = false;
|
||||
string? failureReason = null;
|
||||
|
||||
if (ExternalToolDetector.TryFindLibreOfficeSoffice(out var soffice))
|
||||
{
|
||||
converted = await ConvertWithLibreOfficeAsync(soffice, sourcePath, tempPdf, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!converted) failureReason = "LibreOffice 변환에 실패했습니다.";
|
||||
}
|
||||
|
||||
if (!converted && ExternalToolDetector.IsWordComAvailable())
|
||||
{
|
||||
try
|
||||
{
|
||||
converted = ConvertWithWordCom(sourcePath, tempPdf);
|
||||
if (!converted) failureReason = "Microsoft Word 변환에 실패했습니다.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failureReason = $"Microsoft Word 변환 오류: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
if (!converted)
|
||||
return ConvertResult.Fail(sourcePath, failureReason ?? "DOCX → PDF 외부 변환 도구가 필요합니다.");
|
||||
|
||||
progress?.Report(0.55);
|
||||
|
||||
var inner = new Progress<double>(p => progress?.Report(0.55 + p * 0.45));
|
||||
return _pdfProvider.ConvertCore(tempPdf, outputDirectory, options, inner, cancellationToken)
|
||||
with { SourcePath = sourcePath };
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tempPdf)) File.Delete(tempPdf); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> ConvertWithLibreOfficeAsync(string sofficePath, string sourcePath, string targetPdf, CancellationToken ct)
|
||||
{
|
||||
var outDir = Path.GetDirectoryName(targetPdf)!;
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = sofficePath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--headless");
|
||||
psi.ArgumentList.Add("--norestore");
|
||||
psi.ArgumentList.Add("--nofirststartwizard");
|
||||
psi.ArgumentList.Add("--convert-to");
|
||||
psi.ArgumentList.Add("pdf");
|
||||
psi.ArgumentList.Add("--outdir");
|
||||
psi.ArgumentList.Add(outDir);
|
||||
psi.ArgumentList.Add(sourcePath);
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc is null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { proc.Kill(true); } catch { }
|
||||
throw;
|
||||
}
|
||||
|
||||
if (proc.ExitCode != 0) return false;
|
||||
|
||||
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + ".pdf");
|
||||
if (!File.Exists(produced)) return false;
|
||||
|
||||
if (!string.Equals(produced, targetPdf, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (File.Exists(targetPdf)) File.Delete(targetPdf);
|
||||
File.Move(produced, targetPdf);
|
||||
}
|
||||
return File.Exists(targetPdf);
|
||||
}
|
||||
|
||||
private static bool ConvertWithWordCom(string sourcePath, string targetPdf)
|
||||
{
|
||||
const int wdFormatPDF = 17;
|
||||
var wordType = Type.GetTypeFromProgID("Word.Application");
|
||||
if (wordType is null) return false;
|
||||
|
||||
dynamic? word = Activator.CreateInstance(wordType);
|
||||
if (word is null) return false;
|
||||
try
|
||||
{
|
||||
word.Visible = false;
|
||||
word.DisplayAlerts = 0;
|
||||
dynamic doc = word.Documents.Open(sourcePath, ReadOnly: true, Visible: false);
|
||||
try
|
||||
{
|
||||
doc.SaveAs2(targetPdf, wdFormatPDF);
|
||||
}
|
||||
finally
|
||||
{
|
||||
doc.Close(false);
|
||||
}
|
||||
return File.Exists(targetPdf);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { word.Quit(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs
Normal file
85
src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using Microsoft.Win32;
|
||||
|
||||
namespace EverythingToJpeg.Core.Converters;
|
||||
|
||||
internal static class ExternalToolDetector
|
||||
{
|
||||
public static bool TryFindLibreOfficeSoffice(out string sofficePath)
|
||||
{
|
||||
sofficePath = "";
|
||||
var candidates = new List<string>();
|
||||
|
||||
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
var pfx86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
|
||||
foreach (var root in new[] { pf, pfx86 })
|
||||
{
|
||||
if (string.IsNullOrEmpty(root)) continue;
|
||||
candidates.Add(Path.Combine(root, "LibreOffice", "program", "soffice.com"));
|
||||
candidates.Add(Path.Combine(root, "LibreOffice", "program", "soffice.exe"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\LibreOffice\UNO\InstallPath");
|
||||
if (key?.GetValue(null) is string installPath)
|
||||
{
|
||||
candidates.Add(Path.Combine(installPath, "soffice.com"));
|
||||
candidates.Add(Path.Combine(installPath, "soffice.exe"));
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
foreach (var path in candidates.Distinct())
|
||||
{
|
||||
if (File.Exists(path)) { sofficePath = path; return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool IsWordComAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.ClassesRoot.OpenSubKey("Word.Application");
|
||||
return key is not null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsH2OrestartInstalled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var roots = new[]
|
||||
{
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
};
|
||||
foreach (var root in roots)
|
||||
{
|
||||
if (string.IsNullOrEmpty(root)) continue;
|
||||
var loDir = Path.Combine(root, "LibreOffice", "4", "user", "uno_packages", "cache", "uno_packages");
|
||||
if (Directory.Exists(loDir))
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(loDir, "*H2Orestart*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (Directory.Exists(dir)) return true;
|
||||
}
|
||||
}
|
||||
var extDir = Path.Combine(root, "LibreOffice", "4", "user", "extensions", "bundled");
|
||||
if (Directory.Exists(extDir))
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(extDir, "*H2O*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (Directory.Exists(dir)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
73
src/EverythingToJpeg.Core/Converters/HeicProvider.cs
Normal file
73
src/EverythingToJpeg.Core/Converters/HeicProvider.cs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
using EverythingToJpeg.Core.Providers;
|
||||
using PhotoSauce.MagicScaler;
|
||||
using PhotoSauce.NativeCodecs.Libheif;
|
||||
|
||||
namespace EverythingToJpeg.Core.Converters;
|
||||
|
||||
public sealed class HeicProvider : IConverterProvider
|
||||
{
|
||||
private static int _codecConfigured;
|
||||
private readonly MagickProvider _magickProvider;
|
||||
|
||||
public HeicProvider() : this(new MagickProvider()) { }
|
||||
|
||||
public HeicProvider(MagickProvider magickProvider)
|
||||
{
|
||||
_magickProvider = magickProvider;
|
||||
}
|
||||
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "heic",
|
||||
DisplayName: "HEIC / HEIF",
|
||||
Extensions: new[] { ".heic", ".heif" },
|
||||
Status: ProviderStatus.Available,
|
||||
Summary: "iPhone 등에서 만든 HEIC·HEIF 사진을 JPEG로 변환합니다.",
|
||||
ExternalDependencies: Array.Empty<ExternalDependency>(),
|
||||
RoadmapNote: null);
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureCodec();
|
||||
return Task.FromResult(ProviderAvailability.Ready);
|
||||
}
|
||||
|
||||
public async Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureCodec();
|
||||
|
||||
var tempPng = Path.Combine(Path.GetTempPath(),
|
||||
$"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.png");
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
MagicImageProcessor.ProcessImage(sourcePath, tempPng, ProcessImageSettings.Default);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
progress?.Report(0.5);
|
||||
|
||||
var inner = new Progress<double>(p => progress?.Report(0.5 + p * 0.5));
|
||||
var result = await _magickProvider
|
||||
.ConvertAsync(tempPng, outputDirectory, options, inner, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result with { SourcePath = sourcePath };
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tempPng)) File.Delete(tempPng); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureCodec()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _codecConfigured, 1) == 1) return;
|
||||
CodecManager.Configure(codecs => codecs.UseLibheif());
|
||||
}
|
||||
}
|
||||
199
src/EverythingToJpeg.Core/Converters/HtmlProvider.cs
Normal file
199
src/EverythingToJpeg.Core/Converters/HtmlProvider.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using EverythingToJpeg.Core.Providers;
|
||||
using ImageMagick;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
|
||||
namespace EverythingToJpeg.Core.Converters;
|
||||
|
||||
public sealed class HtmlProvider : IConverterProvider
|
||||
{
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "html",
|
||||
DisplayName: "HTML / 웹 페이지",
|
||||
Extensions: new[] { ".html", ".htm" },
|
||||
Status: ProviderStatus.Available,
|
||||
Summary: "HTML/HTM 파일을 WebView2로 헤드리스 렌더링하여 풀페이지 JPEG로 캡처합니다.",
|
||||
ExternalDependencies: new[]
|
||||
{
|
||||
new ExternalDependency(
|
||||
Name: "Microsoft Edge WebView2 Runtime",
|
||||
Description: "Windows 11에는 기본 포함되어 있습니다.",
|
||||
DownloadUrl: "https://developer.microsoft.com/microsoft-edge/webview2/",
|
||||
IsRequired: true),
|
||||
},
|
||||
RoadmapNote: null);
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var version = CoreWebView2Environment.GetAvailableBrowserVersionString();
|
||||
if (string.IsNullOrEmpty(version))
|
||||
return Task.FromResult(ProviderAvailability.NotReady(
|
||||
"WebView2 Runtime이 설치되어 있지 않습니다.",
|
||||
Capability.ExternalDependencies));
|
||||
return Task.FromResult(ProviderAvailability.Ready);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ProviderAvailability.NotReady(
|
||||
"WebView2 감지 실패: " + ex.Message,
|
||||
Capability.ExternalDependencies));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
|
||||
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||
|
||||
var pngBytes = await CapturePngAsync(sourcePath, options, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
progress?.Report(0.85);
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
using var image = new MagickImage(pngBytes);
|
||||
if (options.FlattenTransparency && image.HasAlpha)
|
||||
{
|
||||
image.BackgroundColor = new MagickColor(options.TransparencyBackground);
|
||||
image.Alpha(AlphaOption.Remove);
|
||||
image.Alpha(AlphaOption.Off);
|
||||
}
|
||||
if (options.MaxLongEdgePixels is int maxLong && maxLong > 0
|
||||
&& (image.Width > (uint)maxLong || image.Height > (uint)maxLong))
|
||||
{
|
||||
image.Resize(new MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false });
|
||||
}
|
||||
image.Quality = (uint)Math.Clamp(options.Quality, 1, 100);
|
||||
image.Format = MagickFormat.Jpeg;
|
||||
image.Write(path);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { path });
|
||||
}
|
||||
|
||||
private static Task<byte[]> CapturePngAsync(
|
||||
string sourcePath,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var dispatcher = Dispatcher.CurrentDispatcher;
|
||||
_ = RunCaptureOnDispatcher(dispatcher, sourcePath, options, progress, cancellationToken, tcs);
|
||||
Dispatcher.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.IsBackground = true;
|
||||
thread.Name = "EverythingToJpeg.HtmlCapture";
|
||||
thread.Start();
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
private static async Task RunCaptureOnDispatcher(
|
||||
Dispatcher dispatcher,
|
||||
string sourcePath,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken,
|
||||
TaskCompletionSource<byte[]> tcs)
|
||||
{
|
||||
CoreWebView2Controller? controller = null;
|
||||
try
|
||||
{
|
||||
var userDataFolder = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"EverythingToJpeg", "WebView2");
|
||||
Directory.CreateDirectory(userDataFolder);
|
||||
|
||||
var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder).ConfigureAwait(true);
|
||||
progress?.Report(0.15);
|
||||
|
||||
// HWND_MESSAGE = (IntPtr)(-3) → headless message-only parent
|
||||
controller = await env.CreateCoreWebView2ControllerAsync(new IntPtr(-3)).ConfigureAwait(true);
|
||||
|
||||
int width = options.HtmlViewportWidth > 0 ? options.HtmlViewportWidth : 1280;
|
||||
int height = options.HtmlViewportHeight ?? 720;
|
||||
controller.Bounds = new System.Drawing.Rectangle(0, 0, width, height);
|
||||
controller.IsVisible = false;
|
||||
|
||||
var web = controller.CoreWebView2;
|
||||
progress?.Report(0.3);
|
||||
|
||||
var navTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
EventHandler<CoreWebView2NavigationCompletedEventArgs>? navHandler = null;
|
||||
navHandler = (_, e) =>
|
||||
{
|
||||
web.NavigationCompleted -= navHandler!;
|
||||
if (e.IsSuccess) navTcs.TrySetResult(true);
|
||||
else navTcs.TrySetException(new InvalidOperationException(
|
||||
$"내비게이션 실패: {e.WebErrorStatus}"));
|
||||
};
|
||||
web.NavigationCompleted += navHandler;
|
||||
|
||||
var fileUri = new Uri(sourcePath).AbsoluteUri;
|
||||
web.Navigate(fileUri);
|
||||
|
||||
using (cancellationToken.Register(() => navTcs.TrySetCanceled()))
|
||||
{
|
||||
await navTcs.Task.ConfigureAwait(true);
|
||||
}
|
||||
progress?.Report(0.5);
|
||||
|
||||
if (options.HtmlWaitMilliseconds > 0)
|
||||
await Task.Delay(options.HtmlWaitMilliseconds, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
progress?.Report(0.65);
|
||||
|
||||
// Use CDP for full-page screenshot beyond viewport
|
||||
var captureParams = options.HtmlFullPage
|
||||
? "{\"captureBeyondViewport\":true,\"format\":\"png\"}"
|
||||
: "{\"format\":\"png\"}";
|
||||
|
||||
var resultJson = await web
|
||||
.CallDevToolsProtocolMethodAsync("Page.captureScreenshot", captureParams)
|
||||
.ConfigureAwait(true);
|
||||
progress?.Report(0.8);
|
||||
|
||||
using var doc = JsonDocument.Parse(resultJson);
|
||||
var b64 = doc.RootElement.GetProperty("data").GetString()
|
||||
?? throw new InvalidOperationException("CDP captureScreenshot이 빈 결과를 반환했습니다.");
|
||||
var pngBytes = Convert.FromBase64String(b64);
|
||||
|
||||
tcs.TrySetResult(pngBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { controller?.Close(); } catch { }
|
||||
dispatcher.BeginInvokeShutdown(DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
}
|
||||
134
src/EverythingToJpeg.Core/Converters/HwpxProvider.cs
Normal file
134
src/EverythingToJpeg.Core/Converters/HwpxProvider.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
using System.Diagnostics;
|
||||
using EverythingToJpeg.Core.Providers;
|
||||
|
||||
namespace EverythingToJpeg.Core.Converters;
|
||||
|
||||
public sealed class HwpxProvider : IConverterProvider
|
||||
{
|
||||
private readonly PdfProvider _pdfProvider;
|
||||
|
||||
public HwpxProvider() : this(new PdfProvider()) { }
|
||||
|
||||
public HwpxProvider(PdfProvider pdfProvider)
|
||||
{
|
||||
_pdfProvider = pdfProvider;
|
||||
}
|
||||
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "hwpx",
|
||||
DisplayName: "한글 문서 (HWP / HWPX)",
|
||||
Extensions: new[] { ".hwp", ".hwpx" },
|
||||
Status: ProviderStatus.RequiresExternal,
|
||||
Summary: "한글(HWP/HWPX) 문서를 LibreOffice + H2Orestart로 PDF 변환 후 페이지별 JPEG로 저장합니다.",
|
||||
ExternalDependencies: new[]
|
||||
{
|
||||
new ExternalDependency(
|
||||
Name: "LibreOffice",
|
||||
Description: "한글 변환에 필요한 헤드리스 오피스 엔진.",
|
||||
DownloadUrl: "https://www.libreoffice.org/download/",
|
||||
IsRequired: true),
|
||||
new ExternalDependency(
|
||||
Name: "H2Orestart 확장",
|
||||
Description: "LibreOffice가 한글 파일을 읽도록 하는 오픈소스 확장. 다운로드한 oxt 파일을 LibreOffice에서 더블클릭해 설치.",
|
||||
DownloadUrl: "https://github.com/ebandal/H2Orestart/releases",
|
||||
IsRequired: true),
|
||||
},
|
||||
RoadmapNote: null);
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out _))
|
||||
return Task.FromResult(ProviderAvailability.NotReady(
|
||||
"LibreOffice가 설치되어 있지 않습니다.",
|
||||
Capability.ExternalDependencies));
|
||||
|
||||
if (!ExternalToolDetector.IsH2OrestartInstalled())
|
||||
return Task.FromResult(ProviderAvailability.NotReady(
|
||||
"H2Orestart 확장이 설치되어 있지 않습니다. https://github.com/ebandal/H2Orestart/releases 에서 .oxt 다운로드 후 LibreOffice에서 설치하세요.",
|
||||
Capability.ExternalDependencies));
|
||||
|
||||
return Task.FromResult(ProviderAvailability.Ready);
|
||||
}
|
||||
|
||||
public async Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out var soffice))
|
||||
return ConvertResult.Fail(sourcePath, "LibreOffice가 필요합니다.");
|
||||
|
||||
var tempPdf = Path.Combine(Path.GetTempPath(),
|
||||
$"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
|
||||
|
||||
try
|
||||
{
|
||||
progress?.Report(0.05);
|
||||
|
||||
var converted = await ConvertWithLibreOfficeAsync(soffice, sourcePath, tempPdf, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!converted)
|
||||
return ConvertResult.Fail(sourcePath,
|
||||
"LibreOffice 변환에 실패했습니다. H2Orestart 확장이 정상 설치되어 있는지 확인하세요.");
|
||||
|
||||
progress?.Report(0.55);
|
||||
|
||||
var inner = new Progress<double>(p => progress?.Report(0.55 + p * 0.45));
|
||||
return _pdfProvider.ConvertCore(tempPdf, outputDirectory, options, inner, cancellationToken)
|
||||
with { SourcePath = sourcePath };
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tempPdf)) File.Delete(tempPdf); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> ConvertWithLibreOfficeAsync(string sofficePath, string sourcePath, string targetPdf, CancellationToken ct)
|
||||
{
|
||||
var outDir = Path.GetDirectoryName(targetPdf)!;
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = sofficePath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--headless");
|
||||
psi.ArgumentList.Add("--norestore");
|
||||
psi.ArgumentList.Add("--nofirststartwizard");
|
||||
psi.ArgumentList.Add("--convert-to");
|
||||
psi.ArgumentList.Add("pdf");
|
||||
psi.ArgumentList.Add("--outdir");
|
||||
psi.ArgumentList.Add(outDir);
|
||||
psi.ArgumentList.Add(sourcePath);
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc is null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { proc.Kill(true); } catch { }
|
||||
throw;
|
||||
}
|
||||
|
||||
if (proc.ExitCode != 0) return false;
|
||||
|
||||
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + ".pdf");
|
||||
if (!File.Exists(produced)) return false;
|
||||
|
||||
if (!string.Equals(produced, targetPdf, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (File.Exists(targetPdf)) File.Delete(targetPdf);
|
||||
File.Move(produced, targetPdf);
|
||||
}
|
||||
return File.Exists(targetPdf);
|
||||
}
|
||||
}
|
||||
131
src/EverythingToJpeg.Core/Converters/MagickProvider.cs
Normal file
131
src/EverythingToJpeg.Core/Converters/MagickProvider.cs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
using EverythingToJpeg.Core.Providers;
|
||||
using ImageMagick;
|
||||
|
||||
namespace EverythingToJpeg.Core.Converters;
|
||||
|
||||
public sealed class MagickProvider : IConverterProvider
|
||||
{
|
||||
private static readonly string[] SingleFrameExtensions =
|
||||
{
|
||||
".png", ".bmp", ".jpg", ".jpeg", ".jpe", ".webp", ".avif", ".psd",
|
||||
".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ".rw2", ".srw", ".pef", ".raw",
|
||||
};
|
||||
|
||||
private static readonly string[] MultiFrameExtensions = { ".gif", ".tif", ".tiff" };
|
||||
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "magick",
|
||||
DisplayName: "이미지·RAW·애니메이션",
|
||||
Extensions: SingleFrameExtensions.Concat(MultiFrameExtensions).ToList(),
|
||||
Status: ProviderStatus.Available,
|
||||
Summary: "PNG, BMP, JPEG, WebP, AVIF, PSD, GIF, TIFF, RAW(NEF/CR2/ARW/DNG/RAF/ORF/RW2 등)을 JPEG로 변환합니다.",
|
||||
ExternalDependencies: Array.Empty<ExternalDependency>(),
|
||||
RoadmapNote: null);
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(ProviderAvailability.Ready);
|
||||
|
||||
public Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(() => ConvertCore(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private static ConvertResult ConvertCore(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ext = Path.GetExtension(sourcePath).ToLowerInvariant();
|
||||
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
var isMultiFrame = MultiFrameExtensions.Contains(ext);
|
||||
|
||||
if (isMultiFrame)
|
||||
{
|
||||
using var collection = new MagickImageCollection(sourcePath);
|
||||
if (collection.Count == 0)
|
||||
return ConvertResult.Fail(sourcePath, "이미지 프레임을 읽지 못했습니다.");
|
||||
|
||||
if (collection.Count == 1)
|
||||
{
|
||||
var single = collection[0];
|
||||
ApplyCommonTransforms(single, options);
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
|
||||
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||
WriteJpeg(single, path, options.Quality);
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { path });
|
||||
}
|
||||
|
||||
collection.Coalesce();
|
||||
var outputs = new List<string>();
|
||||
var width = (int)Math.Ceiling(Math.Log10(collection.Count + 1));
|
||||
for (var i = 0; i < collection.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var frame = collection[i];
|
||||
ApplyCommonTransforms(frame, options);
|
||||
var suffix = $"_{(i + 1).ToString().PadLeft(width, '0')}";
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue;
|
||||
WriteJpeg(frame, path, options.Quality);
|
||||
outputs.Add(path);
|
||||
progress?.Report((i + 1.0) / collection.Count);
|
||||
}
|
||||
|
||||
return outputs.Count > 0
|
||||
? ConvertResult.Ok(sourcePath, outputs)
|
||||
: ConvertResult.Skip(sourcePath, "모든 프레임이 이미 존재해 건너뜁니다.");
|
||||
}
|
||||
else
|
||||
{
|
||||
using var image = new MagickImage(sourcePath);
|
||||
ApplyCommonTransforms(image, options);
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
|
||||
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||
WriteJpeg(image, path, options.Quality);
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { path });
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyCommonTransforms(IMagickImage<ushort> image, ConvertOptions options)
|
||||
{
|
||||
try { image.AutoOrient(); } catch { }
|
||||
|
||||
if (options.FlattenTransparency && image.HasAlpha)
|
||||
{
|
||||
image.BackgroundColor = new MagickColor(options.TransparencyBackground);
|
||||
image.Alpha(AlphaOption.Remove);
|
||||
image.Alpha(AlphaOption.Off);
|
||||
}
|
||||
|
||||
if (options.MaxLongEdgePixels is int maxLong && maxLong > 0)
|
||||
{
|
||||
var w = (int)image.Width;
|
||||
var h = (int)image.Height;
|
||||
if (w > maxLong || h > maxLong)
|
||||
{
|
||||
var geom = new MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false };
|
||||
image.Resize(geom);
|
||||
}
|
||||
}
|
||||
|
||||
image.Format = MagickFormat.Jpeg;
|
||||
}
|
||||
|
||||
private static void WriteJpeg(IMagickImage<ushort> image, string path, int quality)
|
||||
{
|
||||
image.Quality = (uint)Math.Clamp(quality, 1, 100);
|
||||
image.Format = MagickFormat.Jpeg;
|
||||
image.Write(path);
|
||||
}
|
||||
}
|
||||
98
src/EverythingToJpeg.Core/Converters/PdfProvider.cs
Normal file
98
src/EverythingToJpeg.Core/Converters/PdfProvider.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using EverythingToJpeg.Core.Providers;
|
||||
using PDFtoImage;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace EverythingToJpeg.Core.Converters;
|
||||
|
||||
public sealed class PdfProvider : IConverterProvider
|
||||
{
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "pdf",
|
||||
DisplayName: "PDF",
|
||||
Extensions: new[] { ".pdf" },
|
||||
Status: ProviderStatus.Available,
|
||||
Summary: "PDF 각 페이지를 JPEG로 변환합니다.",
|
||||
ExternalDependencies: Array.Empty<ExternalDependency>(),
|
||||
RoadmapNote: null);
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(ProviderAvailability.Ready);
|
||||
|
||||
public Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(() => ConvertCore(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
internal ConvertResult ConvertCore(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
|
||||
int pageCount;
|
||||
using (var probe = File.OpenRead(sourcePath))
|
||||
{
|
||||
pageCount = Conversion.GetPageCount(probe);
|
||||
}
|
||||
if (pageCount <= 0)
|
||||
return ConvertResult.Fail(sourcePath, "PDF에 페이지가 없습니다.");
|
||||
|
||||
var renderOptions = new RenderOptions
|
||||
{
|
||||
Dpi = options.PdfDpi,
|
||||
BackgroundColor = SKColors.White,
|
||||
WithAnnotations = true,
|
||||
WithFormFill = true,
|
||||
UseTiling = true,
|
||||
};
|
||||
|
||||
var width = (int)Math.Ceiling(Math.Log10(pageCount + 1));
|
||||
var outputs = new List<string>();
|
||||
|
||||
for (var i = 0; i < pageCount; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var suffix = pageCount == 1 ? null : $"_p{(i + 1).ToString().PadLeft(width, '0')}";
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue;
|
||||
|
||||
using (var input = File.OpenRead(sourcePath))
|
||||
{
|
||||
Conversion.SaveJpeg(path, input, page: i, leaveOpen: false, password: null, options: renderOptions);
|
||||
}
|
||||
|
||||
if (options.MaxLongEdgePixels is int maxLong && maxLong > 0)
|
||||
{
|
||||
ResizeIfNeeded(path, maxLong, options.Quality);
|
||||
}
|
||||
|
||||
outputs.Add(path);
|
||||
progress?.Report((i + 1.0) / pageCount);
|
||||
}
|
||||
|
||||
return outputs.Count > 0
|
||||
? ConvertResult.Ok(sourcePath, outputs)
|
||||
: ConvertResult.Skip(sourcePath, "모든 페이지가 이미 존재해 건너뜁니다.");
|
||||
}
|
||||
|
||||
private static void ResizeIfNeeded(string jpegPath, int maxLongEdge, int quality)
|
||||
{
|
||||
using var image = new ImageMagick.MagickImage(jpegPath);
|
||||
if (image.Width <= (uint)maxLongEdge && image.Height <= (uint)maxLongEdge) return;
|
||||
|
||||
var geom = new ImageMagick.MagickGeometry((uint)maxLongEdge, (uint)maxLongEdge) { IgnoreAspectRatio = false };
|
||||
image.Resize(geom);
|
||||
image.Quality = (uint)Math.Clamp(quality, 1, 100);
|
||||
image.Format = ImageMagick.MagickFormat.Jpeg;
|
||||
image.Write(jpegPath);
|
||||
}
|
||||
}
|
||||
25
src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj
Normal file
25
src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<UseWindowsForms>false</UseWindowsForms>
|
||||
<UseWPF>true</UseWPF>
|
||||
<NoWarn>$(NoWarn);NU1901;NU1902;NU1903;NU1904</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.13.0" />
|
||||
<PackageReference Include="PDFtoImage" Version="5.2.1" />
|
||||
<PackageReference Include="PhotoSauce.MagicScaler" Version="0.15.0" />
|
||||
<PackageReference Include="PhotoSauce.NativeCodecs.Libheif" Version="1.19.5-preview1" />
|
||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3912.50" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="System.IO" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
22
src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs
Normal file
22
src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
using EverythingToJpeg.Core.Providers;
|
||||
|
||||
namespace EverythingToJpeg.Core;
|
||||
|
||||
public static class EverythingToJpegBootstrap
|
||||
{
|
||||
public static ConversionEngine CreateDefault()
|
||||
{
|
||||
var magick = new Converters.MagickProvider();
|
||||
var pdf = new Converters.PdfProvider();
|
||||
var providers = new IConverterProvider[]
|
||||
{
|
||||
magick,
|
||||
new Converters.HeicProvider(magick),
|
||||
pdf,
|
||||
new Converters.DocxProvider(pdf),
|
||||
new Converters.HtmlProvider(),
|
||||
new Converters.HwpxProvider(),
|
||||
};
|
||||
return new ConversionEngine(new ProviderRegistry(providers));
|
||||
}
|
||||
}
|
||||
68
src/EverythingToJpeg.Core/HistoryStorage.cs
Normal file
68
src/EverythingToJpeg.Core/HistoryStorage.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EverythingToJpeg.Core;
|
||||
|
||||
public static class HistoryStorage
|
||||
{
|
||||
private static readonly string Dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"EverythingToJpeg");
|
||||
|
||||
private static readonly string FilePath = Path.Combine(Dir, "history.jsonl");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
public static IReadOnlyList<HistoryEntry> Load()
|
||||
{
|
||||
if (!File.Exists(FilePath)) return Array.Empty<HistoryEntry>();
|
||||
|
||||
var list = new List<HistoryEntry>();
|
||||
try
|
||||
{
|
||||
foreach (var line in File.ReadAllLines(FilePath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
try
|
||||
{
|
||||
var entry = JsonSerializer.Deserialize<HistoryEntry>(line, JsonOptions);
|
||||
if (entry is not null) list.Add(entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 손상된 줄은 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<HistoryEntry>();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static void Append(HistoryEntry entry)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Dir);
|
||||
var json = JsonSerializer.Serialize(entry, JsonOptions);
|
||||
File.AppendAllText(FilePath, json + Environment.NewLine);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 영구 저장 실패는 메모리 동작에 영향 없음
|
||||
}
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
try { if (File.Exists(FilePath)) File.Delete(FilePath); } catch { }
|
||||
}
|
||||
|
||||
public static string LocationHint => FilePath;
|
||||
}
|
||||
37
src/EverythingToJpeg.Core/HistoryStore.cs
Normal file
37
src/EverythingToJpeg.Core/HistoryStore.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace EverythingToJpeg.Core;
|
||||
|
||||
public sealed record HistoryEntry(
|
||||
DateTime Timestamp,
|
||||
string SourcePath,
|
||||
string SourceFormat,
|
||||
long SourceSizeBytes,
|
||||
long OutputSizeBytes,
|
||||
int OutputCount,
|
||||
string? MetaLine,
|
||||
ConvertStatus Status,
|
||||
string? Message)
|
||||
{
|
||||
public long SavingsBytes => SourceSizeBytes - OutputSizeBytes;
|
||||
|
||||
public DateOnly Date => DateOnly.FromDateTime(Timestamp);
|
||||
}
|
||||
|
||||
public sealed class HistoryStore
|
||||
{
|
||||
private readonly ObservableCollection<HistoryEntry> _entries = new();
|
||||
|
||||
public ReadOnlyObservableCollection<HistoryEntry> Entries { get; }
|
||||
|
||||
public HistoryStore()
|
||||
{
|
||||
Entries = new ReadOnlyObservableCollection<HistoryEntry>(_entries);
|
||||
}
|
||||
|
||||
public void Add(HistoryEntry entry) => _entries.Insert(0, entry);
|
||||
|
||||
public void Clear() => _entries.Clear();
|
||||
|
||||
public int Count => _entries.Count;
|
||||
}
|
||||
49
src/EverythingToJpeg.Core/OutputPathHelper.cs
Normal file
49
src/EverythingToJpeg.Core/OutputPathHelper.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
namespace EverythingToJpeg.Core;
|
||||
|
||||
internal static class OutputPathHelper
|
||||
{
|
||||
public static string ResolveOutputPath(
|
||||
string outputDirectory,
|
||||
string baseName,
|
||||
string? pageSuffix,
|
||||
NameCollision collision)
|
||||
{
|
||||
var safe = SanitizeFileName(baseName);
|
||||
var fileName = string.IsNullOrEmpty(pageSuffix) ? $"{safe}.jpg" : $"{safe}{pageSuffix}.jpg";
|
||||
var fullPath = Path.Combine(outputDirectory, fileName);
|
||||
|
||||
if (!File.Exists(fullPath)) return fullPath;
|
||||
|
||||
switch (collision)
|
||||
{
|
||||
case NameCollision.Overwrite:
|
||||
return fullPath;
|
||||
case NameCollision.Skip:
|
||||
return fullPath;
|
||||
case NameCollision.AppendNumber:
|
||||
default:
|
||||
for (var i = 1; i < 10000; i++)
|
||||
{
|
||||
var candidate = string.IsNullOrEmpty(pageSuffix)
|
||||
? Path.Combine(outputDirectory, $"{safe} ({i}).jpg")
|
||||
: Path.Combine(outputDirectory, $"{safe}{pageSuffix} ({i}).jpg");
|
||||
if (!File.Exists(candidate)) return candidate;
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ShouldSkip(string finalPath, NameCollision collision)
|
||||
=> collision == NameCollision.Skip && File.Exists(finalPath);
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
Span<char> buffer = stackalloc char[name.Length];
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
buffer[i] = Array.IndexOf(invalid, name[i]) >= 0 ? '_' : name[i];
|
||||
}
|
||||
return new string(buffer);
|
||||
}
|
||||
}
|
||||
128
src/EverythingToJpeg.Core/PreviewService.cs
Normal file
128
src/EverythingToJpeg.Core/PreviewService.cs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
using System.Windows.Media.Imaging;
|
||||
using ImageMagick;
|
||||
using PDFtoImage;
|
||||
using PhotoSauce.MagicScaler;
|
||||
using PhotoSauce.NativeCodecs.Libheif;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace EverythingToJpeg.Core;
|
||||
|
||||
public sealed record PreviewResult(BitmapSource? Image, string? Reason, string? Dimensions, int? PageCount);
|
||||
|
||||
public static class PreviewService
|
||||
{
|
||||
private static int _heifConfigured;
|
||||
|
||||
public static async Task<PreviewResult> CreateAsync(string path, int maxLongEdge = 720, CancellationToken ct = default)
|
||||
{
|
||||
if (!File.Exists(path)) return new PreviewResult(null, "파일을 찾을 수 없습니다.", null, null);
|
||||
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
try
|
||||
{
|
||||
return ext switch
|
||||
{
|
||||
".pdf" => await Task.Run(() => RenderPdf(path, maxLongEdge), ct).ConfigureAwait(false),
|
||||
".heic" or ".heif" => await Task.Run(() => RenderHeic(path, maxLongEdge), ct).ConfigureAwait(false),
|
||||
".html" or ".htm" => new PreviewResult(null, "HTML 미리보기는 변환 시점에 렌더됩니다.", null, null),
|
||||
".doc" or ".docx" or ".hwp" or ".hwpx" =>
|
||||
new PreviewResult(null, "문서 미리보기는 다음 업데이트에서 지원합니다.", null, null),
|
||||
_ => await Task.Run(() => RenderViaMagick(path, maxLongEdge), ct).ConfigureAwait(false),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PreviewResult(null, "미리보기 생성 실패: " + ex.Message, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static PreviewResult RenderViaMagick(string path, int maxLongEdge)
|
||||
{
|
||||
using var image = new MagickImage(path);
|
||||
var w = (int)image.Width;
|
||||
var h = (int)image.Height;
|
||||
try { image.AutoOrient(); } catch { }
|
||||
if (image.HasAlpha)
|
||||
{
|
||||
image.BackgroundColor = MagickColors.White;
|
||||
image.Alpha(AlphaOption.Remove);
|
||||
image.Alpha(AlphaOption.Off);
|
||||
}
|
||||
if (w > maxLongEdge || h > maxLongEdge)
|
||||
{
|
||||
var geom = new MagickGeometry((uint)maxLongEdge, (uint)maxLongEdge) { IgnoreAspectRatio = false };
|
||||
image.Resize(geom);
|
||||
}
|
||||
image.Quality = 88;
|
||||
image.Format = MagickFormat.Jpeg;
|
||||
var bytes = image.ToByteArray();
|
||||
return new PreviewResult(BytesToBitmap(bytes), null, $"{w} × {h}", null);
|
||||
}
|
||||
|
||||
private static PreviewResult RenderHeic(string path, int maxLongEdge)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _heifConfigured, 1) == 0)
|
||||
CodecManager.Configure(c => c.UseLibheif());
|
||||
|
||||
var tempPng = Path.Combine(Path.GetTempPath(), $"e2j_pv_{Guid.NewGuid():N}.png");
|
||||
try
|
||||
{
|
||||
MagicImageProcessor.ProcessImage(path, tempPng, ProcessImageSettings.Default);
|
||||
return RenderViaMagick(tempPng, maxLongEdge);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tempPng)) File.Delete(tempPng); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static PreviewResult RenderPdf(string path, int maxLongEdge)
|
||||
{
|
||||
int pageCount;
|
||||
using (var probe = File.OpenRead(path))
|
||||
{
|
||||
pageCount = Conversion.GetPageCount(probe);
|
||||
}
|
||||
if (pageCount <= 0) return new PreviewResult(null, "PDF 페이지가 없습니다.", null, 0);
|
||||
|
||||
var ms = new MemoryStream();
|
||||
using (var input = File.OpenRead(path))
|
||||
{
|
||||
var renderOptions = new RenderOptions
|
||||
{
|
||||
Dpi = 144,
|
||||
BackgroundColor = SKColors.White,
|
||||
WithAnnotations = true,
|
||||
WithFormFill = true,
|
||||
};
|
||||
Conversion.SaveJpeg(ms, input, page: 0, leaveOpen: false, password: null, options: renderOptions);
|
||||
}
|
||||
ms.Position = 0;
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
// optional resize via Magick
|
||||
using var image = new MagickImage(bytes);
|
||||
var w = (int)image.Width;
|
||||
var h = (int)image.Height;
|
||||
if (w > maxLongEdge || h > maxLongEdge)
|
||||
{
|
||||
var geom = new MagickGeometry((uint)maxLongEdge, (uint)maxLongEdge) { IgnoreAspectRatio = false };
|
||||
image.Resize(geom);
|
||||
image.Quality = 88;
|
||||
image.Format = MagickFormat.Jpeg;
|
||||
bytes = image.ToByteArray();
|
||||
}
|
||||
return new PreviewResult(BytesToBitmap(bytes), null, $"{w} × {h}", pageCount);
|
||||
}
|
||||
|
||||
private static BitmapSource BytesToBitmap(byte[] bytes)
|
||||
{
|
||||
var bmp = new BitmapImage();
|
||||
bmp.BeginInit();
|
||||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bmp.StreamSource = new MemoryStream(bytes);
|
||||
bmp.EndInit();
|
||||
bmp.Freeze();
|
||||
return bmp;
|
||||
}
|
||||
}
|
||||
26
src/EverythingToJpeg.Core/Providers/IConverterProvider.cs
Normal file
26
src/EverythingToJpeg.Core/Providers/IConverterProvider.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
namespace EverythingToJpeg.Core.Providers;
|
||||
|
||||
public interface IConverterProvider
|
||||
{
|
||||
ProviderCapability Capability { get; }
|
||||
|
||||
Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record ProviderAvailability(
|
||||
bool IsReady,
|
||||
string? Reason = null,
|
||||
IReadOnlyList<ExternalDependency>? MissingDependencies = null)
|
||||
{
|
||||
public static ProviderAvailability Ready { get; } = new(true);
|
||||
|
||||
public static ProviderAvailability NotReady(string reason, IReadOnlyList<ExternalDependency>? missing = null)
|
||||
=> new(false, reason, missing);
|
||||
}
|
||||
29
src/EverythingToJpeg.Core/Providers/ProviderCapability.cs
Normal file
29
src/EverythingToJpeg.Core/Providers/ProviderCapability.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
namespace EverythingToJpeg.Core.Providers;
|
||||
|
||||
public enum ProviderStatus
|
||||
{
|
||||
Available,
|
||||
Preview,
|
||||
RequiresExternal,
|
||||
ComingSoon,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
public sealed record ExternalDependency(
|
||||
string Name,
|
||||
string Description,
|
||||
string? DownloadUrl = null,
|
||||
bool IsRequired = true);
|
||||
|
||||
public sealed record ProviderCapability(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
IReadOnlyList<string> Extensions,
|
||||
ProviderStatus Status,
|
||||
string Summary,
|
||||
IReadOnlyList<ExternalDependency> ExternalDependencies,
|
||||
string? RoadmapNote = null)
|
||||
{
|
||||
public bool CanRegisterContextMenu => Status is ProviderStatus.Available or ProviderStatus.Preview or ProviderStatus.RequiresExternal;
|
||||
public bool IsImplemented => Status is not ProviderStatus.ComingSoon and not ProviderStatus.Disabled;
|
||||
}
|
||||
40
src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs
Normal file
40
src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
namespace EverythingToJpeg.Core.Providers;
|
||||
|
||||
public sealed class ProviderRegistry
|
||||
{
|
||||
private readonly List<IConverterProvider> _providers;
|
||||
private readonly Dictionary<string, IConverterProvider> _byExtension = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ProviderRegistry(IEnumerable<IConverterProvider> providers)
|
||||
{
|
||||
_providers = providers.ToList();
|
||||
foreach (var provider in _providers)
|
||||
{
|
||||
if (!provider.Capability.IsImplemented) continue;
|
||||
foreach (var ext in provider.Capability.Extensions)
|
||||
{
|
||||
_byExtension[Normalize(ext)] = provider;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<IConverterProvider> All => _providers;
|
||||
|
||||
public IEnumerable<IConverterProvider> Implemented => _providers.Where(p => p.Capability.IsImplemented);
|
||||
|
||||
public IEnumerable<IConverterProvider> ComingSoon => _providers.Where(p => p.Capability.Status == ProviderStatus.ComingSoon);
|
||||
|
||||
public bool TryGetForFile(string sourcePath, out IConverterProvider? provider)
|
||||
{
|
||||
var ext = Normalize(Path.GetExtension(sourcePath));
|
||||
return _byExtension.TryGetValue(ext, out provider);
|
||||
}
|
||||
|
||||
public IConverterProvider? FindByExtension(string ext)
|
||||
=> _byExtension.TryGetValue(Normalize(ext), out var p) ? p : null;
|
||||
|
||||
public IReadOnlyCollection<string> ImplementedExtensions => _byExtension.Keys;
|
||||
|
||||
private static string Normalize(string ext)
|
||||
=> ext.StartsWith('.') ? ext.ToLowerInvariant() : "." + ext.ToLowerInvariant();
|
||||
}
|
||||
120
src/EverythingToJpeg.Shell/EverythingToJpeg.Shell.vcxproj
Normal file
120
src/EverythingToJpeg.Shell/EverythingToJpeg.Shell.vcxproj
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|x64">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|ARM64">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>ARM64</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>17.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{1A2B3C4D-5E6F-7A8B-9C0D-EF1234567890}</ProjectGuid>
|
||||
<RootNamespace>EverythingToJpegShell</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
|
||||
<ImportGroup Label="PropertySheets">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props"
|
||||
Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')"
|
||||
Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
|
||||
<ItemDefinitionGroup>
|
||||
<ClCompile>
|
||||
<WarningLevel>Level4</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<PrecompiledHeader>Use</PrecompiledHeader>
|
||||
<PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard>
|
||||
<PreprocessorDefinitions>EVERYTHINGTOJPEG_SHELL_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<ModuleDefinitionFile>Source.def</ModuleDefinitionFile>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
|
||||
<ClCompile>
|
||||
<PreprocessorDefinitions>_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
</ClCompile>
|
||||
</ItemDefinitionGroup>
|
||||
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)'=='Release'">
|
||||
<ClCompile>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
|
||||
<ControlFlowGuard>Guard</ControlFlowGuard>
|
||||
<PreprocessorDefinitions>NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<EnableCOMDATFolding>true</EnableCOMDATFolding>
|
||||
<OptimizeReferences>true</OptimizeReferences>
|
||||
<CETCompat>true</CETCompat>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ClInclude Include="framework.h" />
|
||||
<ClInclude Include="pch.h" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ClCompile Include="dllmain.cpp" />
|
||||
<ClCompile Include="pch.cpp">
|
||||
<PrecompiledHeader>Create</PrecompiledHeader>
|
||||
</ClCompile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="packages.config" />
|
||||
<None Include="Source.def" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
|
||||
<ImportGroup Label="ExtensionTargets">
|
||||
<Import Project="..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.260126.7\build\native\Microsoft.Windows.ImplementationLibrary.targets"
|
||||
Condition="Exists('..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.260126.7\build\native\Microsoft.Windows.ImplementationLibrary.targets')" />
|
||||
</ImportGroup>
|
||||
|
||||
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
|
||||
<PropertyGroup>
|
||||
<ErrorText>WIL NuGet 패키지가 복원되지 않았습니다. nuget restore를 먼저 실행하세요.</ErrorText>
|
||||
</PropertyGroup>
|
||||
<Error Condition="!Exists('..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.260126.7\build\native\Microsoft.Windows.ImplementationLibrary.targets')"
|
||||
Text="$(ErrorText)" />
|
||||
</Target>
|
||||
</Project>
|
||||
5
src/EverythingToJpeg.Shell/Source.def
Normal file
5
src/EverythingToJpeg.Shell/Source.def
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
LIBRARY
|
||||
|
||||
EXPORTS
|
||||
DllGetClassObject PRIVATE
|
||||
DllCanUnloadNow PRIVATE
|
||||
188
src/EverythingToJpeg.Shell/dllmain.cpp
Normal file
188
src/EverythingToJpeg.Shell/dllmain.cpp
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// EverythingToJpeg shell extension — IExplorerCommand handlers
|
||||
// Two verbs:
|
||||
// - QuickCommandHandler → "EverythingToJpeg.exe quick "<paths>""
|
||||
// - DialogCommandHandler → "EverythingToJpeg.exe dialog "<paths>""
|
||||
|
||||
#include "pch.h"
|
||||
|
||||
#pragma warning(disable : 4324)
|
||||
|
||||
using Microsoft::WRL::ClassicCom;
|
||||
using Microsoft::WRL::ComPtr;
|
||||
using Microsoft::WRL::InhibitRoOriginateError;
|
||||
using Microsoft::WRL::Module;
|
||||
using Microsoft::WRL::ModuleType;
|
||||
using Microsoft::WRL::RuntimeClass;
|
||||
using Microsoft::WRL::RuntimeClassFlags;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const wchar_t* kExeFileName = L"EverythingToJpeg.exe";
|
||||
|
||||
std::wstring QuoteForCommandLineArg(const std::wstring& arg) {
|
||||
const std::wstring quotable_chars(L" \\\"");
|
||||
if (arg.find_first_of(quotable_chars) == std::wstring::npos) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
std::wstring out;
|
||||
out.push_back(L'"');
|
||||
for (size_t i = 0; i < arg.size(); ++i) {
|
||||
if (arg[i] == L'\\') {
|
||||
const size_t start = i;
|
||||
size_t end = start + 1;
|
||||
for (; end < arg.size() && arg[end] == L'\\'; ++end) {}
|
||||
size_t backslash_count = end - start;
|
||||
if (end == arg.size() || arg[end] == L'"') {
|
||||
backslash_count *= 2;
|
||||
}
|
||||
for (size_t j = 0; j < backslash_count; ++j)
|
||||
out.push_back(L'\\');
|
||||
i = end - 1;
|
||||
}
|
||||
else if (arg[i] == L'"') {
|
||||
out.push_back(L'\\');
|
||||
out.push_back(L'"');
|
||||
}
|
||||
else {
|
||||
out.push_back(arg[i]);
|
||||
}
|
||||
}
|
||||
out.push_back(L'"');
|
||||
return out;
|
||||
}
|
||||
|
||||
std::filesystem::path ResolveExePath() {
|
||||
std::filesystem::path module_path{
|
||||
wil::GetModuleFileNameW<std::wstring>(wil::GetModuleInstanceHandle()) };
|
||||
module_path = module_path.remove_filename();
|
||||
module_path /= kExeFileName;
|
||||
return module_path;
|
||||
}
|
||||
|
||||
HRESULT LaunchAppWithItems(const wchar_t* verb, IShellItemArray* items) {
|
||||
if (!items) return S_OK;
|
||||
|
||||
DWORD count = 0;
|
||||
RETURN_IF_FAILED(items->GetCount(&count));
|
||||
if (count == 0) return S_OK;
|
||||
|
||||
auto exe_path = ResolveExePath();
|
||||
|
||||
auto command = wil::str_printf<std::wstring>(LR"-("%s" %s)-",
|
||||
exe_path.c_str(), verb);
|
||||
|
||||
for (DWORD i = 0; i < count; ++i) {
|
||||
ComPtr<IShellItem> item;
|
||||
if (FAILED(items->GetItemAt(i, &item))) continue;
|
||||
|
||||
wil::unique_cotaskmem_string path;
|
||||
if (FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) continue;
|
||||
|
||||
command = wil::str_printf<std::wstring>(LR"-(%s %s)-",
|
||||
command.c_str(),
|
||||
QuoteForCommandLineArg(path.get()).c_str());
|
||||
}
|
||||
|
||||
wil::unique_process_information process_info;
|
||||
STARTUPINFOW startup_info = { sizeof(startup_info) };
|
||||
RETURN_IF_WIN32_BOOL_FALSE(CreateProcessW(
|
||||
nullptr,
|
||||
command.data(),
|
||||
nullptr,
|
||||
nullptr,
|
||||
FALSE,
|
||||
CREATE_NO_WINDOW,
|
||||
nullptr,
|
||||
nullptr,
|
||||
&startup_info,
|
||||
&process_info));
|
||||
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
template <typename Derived>
|
||||
class CommandHandlerBase : public RuntimeClass<
|
||||
RuntimeClassFlags<ClassicCom | InhibitRoOriginateError>,
|
||||
IExplorerCommand>
|
||||
{
|
||||
public:
|
||||
IFACEMETHODIMP GetTitle(IShellItemArray*, PWSTR* name) override {
|
||||
return SHStrDupW(Derived::Title(), name);
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override {
|
||||
auto exe = ResolveExePath();
|
||||
return SHStrDupW(exe.c_str(), icon);
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* infoTip) override {
|
||||
*infoTip = nullptr;
|
||||
return E_NOTIMPL;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetCanonicalName(GUID* guidCommandName) override {
|
||||
*guidCommandName = GUID_NULL;
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetState(IShellItemArray*, BOOL, EXPCMDSTATE* cmdState) override {
|
||||
*cmdState = ECS_ENABLED;
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override {
|
||||
*flags = ECF_DEFAULT;
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumCommands) override {
|
||||
*enumCommands = nullptr;
|
||||
return E_NOTIMPL;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Invoke(IShellItemArray* items, IBindCtx*) override {
|
||||
return LaunchAppWithItems(Derived::Verb(), items);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
class __declspec(uuid("801B2DD3-632C-4731-9510-AEAE09345264"))
|
||||
QuickCommandHandler final
|
||||
: public CommandHandlerBase<QuickCommandHandler>
|
||||
{
|
||||
public:
|
||||
static constexpr const wchar_t* Title() { return L"JPEG로 빠른 변환"; }
|
||||
static constexpr const wchar_t* Verb() { return L"quick"; }
|
||||
};
|
||||
|
||||
class __declspec(uuid("CEBA1DB7-9175-4DF6-A362-490DEA49B598"))
|
||||
DialogCommandHandler final
|
||||
: public CommandHandlerBase<DialogCommandHandler>
|
||||
{
|
||||
public:
|
||||
static constexpr const wchar_t* Title() { return L"JPEG로 변환…"; }
|
||||
static constexpr const wchar_t* Verb() { return L"dialog"; }
|
||||
};
|
||||
|
||||
CoCreatableClass(QuickCommandHandler)
|
||||
CoCreatableClass(DialogCommandHandler)
|
||||
CoCreatableClassWrlCreatorMapInclude(QuickCommandHandler)
|
||||
CoCreatableClassWrlCreatorMapInclude(DialogCommandHandler)
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE, DWORD, LPVOID) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
_Check_return_
|
||||
STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, LPVOID* ppv) {
|
||||
if (ppv == nullptr) return E_POINTER;
|
||||
*ppv = nullptr;
|
||||
return Module<ModuleType::InProc>::GetModule().GetClassObject(rclsid, riid, ppv);
|
||||
}
|
||||
|
||||
__control_entrypoint(DllExport)
|
||||
STDAPI DllCanUnloadNow(void) {
|
||||
return Module<ModuleType::InProc>::GetModule().GetObjectCount() == 0 ? S_OK : S_FALSE;
|
||||
}
|
||||
4
src/EverythingToJpeg.Shell/framework.h
Normal file
4
src/EverythingToJpeg.Shell/framework.h
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#pragma once
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
4
src/EverythingToJpeg.Shell/packages.config
Normal file
4
src/EverythingToJpeg.Shell/packages.config
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.260126.7" targetFramework="native" />
|
||||
</packages>
|
||||
1
src/EverythingToJpeg.Shell/pch.cpp
Normal file
1
src/EverythingToJpeg.Shell/pch.cpp
Normal file
|
|
@ -0,0 +1 @@
|
|||
#include "pch.h"
|
||||
24
src/EverythingToJpeg.Shell/pch.h
Normal file
24
src/EverythingToJpeg.Shell/pch.h
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#ifndef PCH_H
|
||||
#define PCH_H
|
||||
|
||||
#include "framework.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#include <shlobj_core.h>
|
||||
#include <shlwapi.h>
|
||||
#pragma comment(lib, "shlwapi.lib")
|
||||
|
||||
#include <wrl/client.h>
|
||||
#include <wrl/implements.h>
|
||||
#include <wrl/module.h>
|
||||
#pragma comment(lib, "runtimeobject.lib")
|
||||
|
||||
#pragma warning(push)
|
||||
#pragma warning(disable: 28182)
|
||||
#include <wil/stl.h>
|
||||
#include <wil/win32_helpers.h>
|
||||
#pragma warning(pop)
|
||||
|
||||
#endif
|
||||
Loading…
Add table
Add a link
Reference in a new issue