diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index fe0fc0f2..4a28ad67 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -1,5 +1,11 @@ name: Linux and macOS packages +# Linux packages are built on Ubuntu 24.04 but must run on Ubuntu 22.04 (glibc 2.35). The rhwp CLI release needs a +# newer glibc, so it is built from pinned source on Ubuntu 22.04 first (linux-native, build/build-linux-native.py) and +# replaces the download in the package. SharpHook (>= 8.0.1) ships libuiohook builds for glibc 2.34 and is packaged as +# is. Every ELF file in a Linux package is then checked against glibc 2.35, and the x64 and arm64 packages are tried on +# Ubuntu 22.04, which also catches a SharpHook or engine update that raises the requirement again. + on: workflow_call: inputs: @@ -18,7 +24,80 @@ permissions: contents: read jobs: + linux-native: + name: Linux native ${{ matrix.rid }} (Ubuntu 22.04) + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + rid: linux-x64 + - runner: ubuntu-22.04-arm + rid: linux-arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + defaults: + run: + shell: bash + env: + NATIVE_OUT: ${{ github.workspace }}/build/.cache/linux-native/${{ matrix.rid }} + steps: + - uses: actions/checkout@v4 + + # Fails when engines.json moved to another rhwp version while build/build-linux-native.py still builds the old + # source (also when the build below comes from the cache). + - name: Check pinned sources + run: python3 build/build-linux-native.py check + + - name: Restore native builds + id: native-cache + uses: actions/cache@v4 + with: + path: build/.cache/linux-native/${{ matrix.rid }} + key: linux-native-${{ matrix.rid }}-ubuntu-22.04-${{ hashFiles('build/build-linux-native.py') }} + + # rhwp's default features are pure Rust: a C toolchain for the linker and build scripts is enough. + - name: Build dependencies + if: steps.native-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y build-essential pkg-config git + if ! command -v rustup >/dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + fi + + - name: Restore cargo downloads + if: steps.native-cache.outputs.cache-hit != 'true' + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: cargo-registry-${{ matrix.rid }}-${{ hashFiles('build/build-linux-native.py') }} + restore-keys: cargo-registry-${{ matrix.rid }}- + + - name: Build rhwp + if: steps.native-cache.outputs.cache-hit != 'true' + run: python3 build/build-linux-native.py rhwp --output "$NATIVE_OUT/rhwp" --work "$RUNNER_TEMP/rhwp" + + - name: Check glibc requirements + run: python3 build/check-elf-glibc.py --max 2.35 --needed "$NATIVE_OUT" + + - name: Upload native builds + uses: actions/upload-artifact@v4 + with: + name: linux-native-${{ matrix.rid }} + path: build/.cache/linux-native/${{ matrix.rid }}/ + if-no-files-found: error + portable: + needs: linux-native + # macOS packages don't use the Linux builds: they still run when those failed (the Linux legs then stop at the + # download). + if: ${{ !cancelled() }} strategy: fail-fast: false matrix: @@ -39,6 +118,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Download Linux native builds + if: runner.os == 'Linux' + uses: actions/download-artifact@v4 + with: + name: linux-native-${{ matrix.rid }} + path: ${{ runner.temp }}/linux-native + - uses: actions/setup-dotnet@v4 with: global-json-file: global.json @@ -49,7 +135,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y ca-certificates libc6 libgcc-s1 libgssapi-krb5-2 libicu74 libssl3t64 libstdc++6 tzdata zlib1g \ - libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 xvfb fonts-noto-cjk + libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 libxt6 xvfb fonts-noto-cjk - name: Fonts run: ./build/fetch-fonts.ps1 @@ -77,106 +163,28 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } + # Linux: rhwp from linux-native instead of the download (see the top). - name: Build portable package env: PACKAGE_VERSION: ${{ inputs.version }} - run: ./build/build-portable.ps1 -Runtime '${{ matrix.rid }}' -Version $env:PACKAGE_VERSION -SkipFonts - - - name: Check archive and run native converters run: | - $archives = @(Get-ChildItem Releases -Filter '*-${{ matrix.rid }}.tar.gz') - if ($archives.Count -ne 1) { throw 'Expected one portable archive.' } - $archive = $archives[0] - $expected = (Get-Content (Join-Path Releases ($archive.Name.Replace('.tar.gz', '-SHA256SUMS.txt')))).Split(' ')[0] - if ((Get-FileHash $archive.FullName -Algorithm SHA256).Hash -ne $expected) { throw 'Archive checksum mismatch.' } - $smoke = Join-Path $env:RUNNER_TEMP 'filee-smoke' - New-Item -ItemType Directory -Path $smoke | Out-Null - tar -xzf $archive.FullName -C $smoke - if ($LASTEXITCODE -ne 0) { throw 'Archive extraction failed.' } - $package = Join-Path $smoke ($archive.Name.Replace('.tar.gz', '')) - if ($IsMacOS) { - codesign --verify --deep --strict (Join-Path $package 'Filee.app') - if ($LASTEXITCODE -ne 0) { throw 'Extracted app signature did not verify.' } + $native = @{} + if ($IsLinux) { + $native = @{ RhwpOverride = Join-Path $env:RUNNER_TEMP 'linux-native/rhwp' } } - $bin = if ($IsMacOS) { Join-Path $package 'Filee.app/Contents/MacOS' } else { $package } - $cli = Join-Path $bin 'filee-cli' - & $cli version - if ($LASTEXITCODE -ne 0) { throw 'CLI did not start.' } - & (Join-Path $bin 'engines/rhwp/rhwp') --version - if ($LASTEXITCODE -ne 0) { throw 'rhwp did not start.' } - & (Join-Path $bin 'engines/7zip/7zz') i | Out-Null - if ($LASTEXITCODE -ne 0) { throw '7-Zip did not start.' } - $inputFile = Join-Path $smoke 'sample.txt' - 'Filee 한글 conversion smoke test' | Set-Content $inputFile -Encoding utf8NoBOM - & $cli convert $inputFile --to hwpx -o (Join-Path $smoke 'converted') --json - if ($LASTEXITCODE -ne 0 -or -not (Test-Path (Join-Path $smoke 'converted/sample.hwpx'))) { throw 'Published CLI conversion failed.' } - "FILEE_SMOKE_APP=$(Join-Path $bin 'Filee')" >> $env:GITHUB_ENV + ./build/build-portable.ps1 -Runtime '${{ matrix.rid }}' -Version $env:PACKAGE_VERSION -SkipFonts @native + + - name: Check archive and run native converters + run: ./build/smoke-portable.ps1 -Runtime '${{ matrix.rid }}' + + - name: Check glibc requirements + if: runner.os == 'Linux' + shell: bash + run: python3 build/check-elf-glibc.py --max 2.35 "$FILEE_SMOKE_PACKAGE" - name: Launch native desktop app shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - import os - import pathlib - import signal - import subprocess - import sys - import time - - app = os.environ['FILEE_SMOKE_APP'] - shots = pathlib.Path(os.environ['RUNNER_TEMP']) / 'filee-screens' - shots.mkdir(exist_ok=True) - command = [app, '--settings', '--install-engines=', '--context-menu=off', '--start-with-system=off'] - if os.environ['RUNNER_OS'] == 'Linux': - command = ['xvfb-run', '-a', 'env', '-u', 'WAYLAND_DISPLAY', 'XDG_SESSION_TYPE=x11', *command] - log_path = pathlib.Path(os.environ['RUNNER_TEMP']) / 'filee-startup.log' - with log_path.open('w') as log: - process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) - try: - time.sleep(8) - if process.poll() is not None: - raise RuntimeError('The desktop app exited before the startup check.') - if os.environ['RUNNER_OS'] == 'macOS': - # The real screen with Filee's window, as a person would see it (uploaded with the results). - subprocess.run(['screencapture', '-x', str(shots / 'settings.png')], check=False, timeout=30) - # A second start forwards to the running Filee, which brings its window to the front. - try: - subprocess.run([app], check=False, timeout=30) - except subprocess.TimeoutExpired: - raise RuntimeError('A second start did not hand over to the running Filee.') - time.sleep(4) - subprocess.run(['screencapture', '-x', str(shots / 'second-start.png')], check=False, timeout=30) - if process.poll() is not None: - raise RuntimeError('The desktop app exited after a second start.') - quit_code = 'import os, sys; os.setsid(); os.execv(sys.argv[1], [sys.argv[1], "--quit"])' - # Reap Filee as soon as it ends while --quit waits for it, as launchd or a shell would: a process - # that has ended but was not reaped by its parent (this script) still counts as running. - quitter = subprocess.Popen([sys.executable, '-c', quit_code, app]) - quit_deadline = time.monotonic() + 30 - while quitter.poll() is None: - process.poll() - if time.monotonic() > quit_deadline: - quitter.kill() - raise RuntimeError('filee --quit did not finish.') - time.sleep(0.05) - if quitter.returncode != 0: - raise RuntimeError(f'filee --quit exited with {quitter.returncode}.') - if process.wait(timeout=15) != 0: - raise RuntimeError('The desktop app did not exit cleanly.') - except BaseException: - log.flush() - print(log_path.read_text(errors='replace'), file=sys.stderr) - raise - finally: - if process.poll() is None: - os.killpg(process.pid, signal.SIGTERM) - try: - process.wait(timeout=10) - except subprocess.TimeoutExpired: - os.killpg(process.pid, signal.SIGKILL) - process.wait() - PY + run: python3 build/smoke-launch-app.py - name: Collect Filee logs if: always() @@ -207,3 +215,67 @@ jobs: ${{ runner.temp }}/filee-startup.log ${{ runner.temp }}/filee-screens/ if-no-files-found: ignore + + # The packages built above, unpacked and started on the oldest supported Ubuntu, with only the runtime libraries a + # user installs from installer/linux/INSTALL.txt. + ubuntu-22-04: + name: Package on Ubuntu 22.04 (${{ matrix.rid }}) + needs: portable + # Runs when another platform's package failed; a missing Linux package fails the download. + if: ${{ !cancelled() }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + rid: linux-x64 + - runner: ubuntu-22.04-arm + rid: linux-arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v4 + + - name: Linux desktop dependencies (Ubuntu 22.04) + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y ca-certificates libc6 libgcc-s1 libgssapi-krb5-2 libicu70 libssl3 libstdc++6 tzdata zlib1g \ + libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 libxt6 xvfb fonts-noto-cjk + + - name: Download package + uses: actions/download-artifact@v4 + with: + name: portable-${{ matrix.rid }} + path: Releases + + - name: Check archive and run native converters + run: ./build/smoke-portable.ps1 -Runtime '${{ matrix.rid }}' + + - name: Check glibc requirements + shell: bash + run: python3 build/check-elf-glibc.py --max 2.35 "$FILEE_SMOKE_PACKAGE" + + - name: Launch native desktop app + shell: bash + run: python3 build/smoke-launch-app.py + + - name: Collect Filee logs + if: always() + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/filee-screens" + if [ -d "$HOME/.config/Filee/logs" ]; then cp -R "$HOME/.config/Filee/logs" "$RUNNER_TEMP/filee-screens/"; fi + + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: ubuntu-22-04-results-${{ matrix.rid }} + path: | + ${{ runner.temp }}/filee-startup.log + ${{ runner.temp }}/filee-screens/ + if-no-files-found: ignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de9d21c2..59faee05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,8 +35,13 @@ install/uninstall scripts. `Filee---SHA256SUMS.txt` verifies the a `com.filee.app` and a development ad-hoc signature by default; public distribution still needs Developer ID signing and notarization. `-SigningIdentity` can supply a signing identity, but does not perform notarization. +Linux release packages must run on Ubuntu 22.04 (glibc 2.35). CI builds rhwp from source on Ubuntu 22.04 +(`build/build-linux-native.py`) and passes it with `-RhwpOverride`; a local package without it needs the glibc of the +machine that built it. Check a package with +`python3 build/check-elf-glibc.py --max 2.35 `. See [docs/ENGINES.md](docs/ENGINES.md#linux-builds-for-older-glibc). + - **Linux:** drag gestures require X11. Install the Avalonia desktop dependencies (fontconfig, FreeType, X11, - Xrandr, Xi, Xtst, XkbCommon, ICE and SM) and CJK fonts for document comparisons. File actions are installed for + Xrandr, Xi, Xtst, Xt, XkbCommon, ICE and SM) and CJK fonts for document comparisons. File actions are installed for Thunar, Nautilus, Nemo and Dolphin. The first enabled keyboard shortcut uses Thunar's native custom action; close Thunar before changing or pausing that shortcut and reopen it afterwards. Global selected-file queries and hold gestures are not supported. Wayland can use file actions, the drop zone, watch folders and CLI. @@ -48,7 +53,8 @@ and notarization. `-SigningIdentity` can supply a signing identity, but does not The macOS LibreOffice package does not include H2Orestart; native rhwp/Unhwp provide the supported HWP routes. Use Settings > Engines to inspect actual availability before comparing formats. -The reusable `portable.yml` workflow builds and tests all four targets. Native platform tests skip on other OSes. +The reusable `portable.yml` workflow builds and tests all four targets and starts the Linux packages on Ubuntu 22.04. +Native platform tests skip on other OSes. Before calling a port ready, verify the installed package, CLI conversion, Finder/Thunar selection with Unicode and spaced paths, drag and keyboard gestures, permissions denied/granted, Retina/scaling, tray, login startup, engine downloads, watch folders, and uninstall. CI GUI startup alone does not validate these interactions. diff --git a/Directory.Packages.props b/Directory.Packages.props index d0901cef..18aba06c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,7 @@ - + diff --git a/README.ko.md b/README.ko.md index 75e2cac9..01f2a94c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -49,7 +49,7 @@ 설치할 때 탐색기 메뉴, 로그인 시 자동 실행, `filee` 명령, 추가 엔진을 고를 수 있어요. - **macOS 14 이상** (미리보기): `osx-arm64` 또는 `osx-x64` 패키지를 풀고 `./install.sh`를 실행해요. 아직 공증 전이라, 처음 실행할 때 시스템 설정 → 개인정보 보호 및 보안 → **그래도 열기**로 허용해 주세요. -- **Linux x64·ARM64** (미리보기): 패키지를 풀고 `./install.sh`를 실행해요(root 불필요, Python 3 필요). +- **Linux x64·ARM64** (미리보기, Ubuntu 22.04 이상): 패키지를 풀고 `./install.sh`를 실행해요(root 불필요, Python 3 필요). 드래그 제스처는 X11에서 돼요. Wayland에서는 드롭 영역과 파일 관리자 우클릭 메뉴를 써 주세요. 새 버전이 나오면 메뉴에 표시되고, 설정과 엔진은 그대로 둔 채 업데이트돼요. @@ -57,7 +57,7 @@ ## 기능 - **커서 옆 도넛**: 키를 누른 채 파일을 끌어 형식 위에 놓으면 끝. 파일에 맞는 형식만 나와요. -- **파일 종류마다 툴바**를 드래그로 배치하고, 품질·크기·DPI·PDF 페이지·동영상·오디오·저장 위치·파일 이름을 +- **파일 종류마다 툴바**를 드래그로 배치하고, 품질·크기·DPI·PDF 페이지와 압축·동영상·오디오·저장 위치·파일 이름을 프리셋으로 정해요. - **여러 파일을 한 번에** 병렬로 변환해요. 여러 단계가 필요한 변환(HWP → HWPX → DOCX)도 알아서 찾아요. - **자동 변환 폴더**에 들어온 파일을 바로 변환해요. @@ -83,7 +83,7 @@ |---|---| | **이미지**: JPG, PNG, WEBP, AVIF, TIFF, BMP, GIF, APNG, ICO, ICNS, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, HEIC, QOI, DDS, PCX, MNG, CUR 등; GIMP XCF, 카메라 RAW(CR2, CR3, NEF, ARW, DNG 등), DICOM, OpenEXR, HDR은 읽기 | 위 형식끼리(HEIC⁵), PDF, SVG(벡터화), 크기 조정, 흑백 | | **벡터**: SVG, SVGZ, EMF, WMF, AI · EPS, PS¹ · CDR, VSD, CGM, ODG, FODG² | PDF(벡터), PNG 등 이미지, SVG ↔ SVGZ, SVG → DXF/DWG, PDF → EPS/PS¹, SVG → EMF, WMF, ODG² | -| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, SVG²; 합치기, 나누기, 페이지 범위 | +| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, PPTX(페이지마다 그림 한 장), SVG²; 합치기, 나누기, 페이지 범위, 압축 (더 작은 PDF) | | **문서**: DOCX, DOCM, DOTX, EML, Outlook MSG · DOC, ODT, FODT, OTT, RTF, WPS, WPD, Pages, ClarisWorks, MacWrite, T602 등² | PDF, DOCX, HWPX, EPUB, TXT, HTML; MSG → EML · ODT, FODT, OTT² | | **스프레드시트**: XLSX, XLSM, XLS, XLT, ODS, CSV, TSV · OTS, FODS, Numbers, ET² | PDF, XLSX, ODS, CSV, TSV, JSON, HWPX, HTML · XLT, OTS² | | **프레젠테이션**: PPTX, PPTM, POTX, PPSX · PPT, ODP, OTP, FODP, Keynote² | PDF, PNG, JPG, DOCX, HWPX · OTP² | diff --git a/README.md b/README.md index 08b5b981..4d175151 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Download from [Releases](https://github.com/KnifeLemon/Filee/releases/latest). Setup can add the Explorer menu, start at sign-in, the `filee` command and optional engines. - **macOS 14+** (preview): unpack the `osx-arm64` or `osx-x64` package and run `./install.sh`. The build isn't notarized yet, so allow the first start in System Settings → Privacy & Security → **Open Anyway**. -- **Linux x64/ARM64** (preview): unpack the package and run `./install.sh` (no root, needs Python 3). +- **Linux x64/ARM64** (preview, Ubuntu 22.04+): unpack the package and run `./install.sh` (no root, needs Python 3). Drag gestures need X11. On Wayland, use the drop zone and the file manager's right-click menu. Filee shows new releases in its menu and updates itself, keeping your settings and engines. @@ -57,7 +57,7 @@ Filee shows new releases in its menu and updates itself, keeping your settings a ## Features - **A donut at the cursor**: hold a key, drag files and drop them on a format. Only formats that fit the files appear. -- **One toolbar per file type**, arranged by drag and drop, with presets for quality, size, DPI, PDF pages, +- **One toolbar per file type**, arranged by drag and drop, with presets for quality, size, DPI, PDF pages and compression, video and audio, output folder and file names. - **Many files at once**, in parallel. Routes with several steps (HWP → HWPX → DOCX) are found automatically. - **Watch folders** convert every file that lands in them. @@ -83,7 +83,7 @@ Filee shows new releases in its menu and updates itself, keeping your settings a |---|---| | **Images**: JPG, PNG, WEBP, AVIF, TIFF, BMP, GIF, APNG, ICO, ICNS, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, HEIC, QOI, DDS, PCX, MNG, CUR, …; GIMP XCF, camera RAW (CR2, CR3, NEF, ARW, DNG, …), DICOM, OpenEXR, HDR read | any of these (HEIC⁵), PDF, SVG (traced), resized, grayscale | | **Vector**: SVG, SVGZ, EMF, WMF, AI · EPS, PS¹ · CDR, VSD, CGM, ODG, FODG² | PDF (vector), PNG and other images, SVG ↔ SVGZ, SVG → DXF/DWG, PDF → EPS/PS¹, SVG → EMF, WMF, ODG² | -| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, SVG²; merge, split, page ranges | +| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, PPTX (a picture of each page), SVG²; merge, split, page ranges, compress (smaller PDFs) | | **Documents**: DOCX, DOCM, DOTX, EML, Outlook MSG · DOC, ODT, FODT, OTT, RTF, WPS, WPD, Pages, ClarisWorks, MacWrite, T602, …² | PDF, DOCX, HWPX, EPUB, TXT, HTML; MSG → EML · ODT, FODT, OTT² | | **Spreadsheets**: XLSX, XLSM, XLS, XLT, ODS, CSV, TSV · OTS, FODS, Numbers, ET² | PDF, XLSX, ODS, CSV, TSV, JSON, HWPX, HTML · XLT, OTS² | | **Presentations**: PPTX, PPTM, POTX, PPSX · PPT, ODP, OTP, FODP, Keynote² | PDF, PNG, JPG, DOCX, HWPX · OTP² | diff --git a/README.zh-CN.md b/README.zh-CN.md index 1760ad50..8cf5d8f0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -49,7 +49,7 @@ 安装时可以添加资源管理器菜单、登录时启动、`filee` 命令和可选引擎。 - **macOS 14 及以上**(预览)解压 `osx-arm64` 或 `osx-x64` 包并运行 `./install.sh`。 目前尚未公证,首次启动时请在系统设置 → 隐私与安全性中点 **仍要打开**。 -- **Linux x64、ARM64**(预览)解压后运行 `./install.sh`(无需 root,需要 Python 3)。 +- **Linux x64、ARM64**(预览,Ubuntu 22.04 及以上)解压后运行 `./install.sh`(无需 root,需要 Python 3)。 拖动手势需要 X11,Wayland 下请使用拖放区域和文件管理器的右键菜单。 有新版本时 Filee 会在菜单中提示,更新时保留设置和引擎。 @@ -57,7 +57,7 @@ ## 功能 - **光标旁的圆环**,按住按键拖动文件,放到格式上即可。只显示适合这些文件的格式。 -- **每种文件类型一个工具栏**,拖放排列。质量、尺寸、DPI、PDF 页面、视频音频、保存位置和文件名都能用预设设置。 +- **每种文件类型一个工具栏**,拖放排列。质量、尺寸、DPI、PDF 页面和压缩、视频音频、保存位置和文件名都能用预设设置。 - **一次转换多个文件**,并行处理。需要多步的转换(HWP → HWPX → DOCX)会自动找到路线。 - **监视文件夹**,放入的文件自动转换。 - **其他方式** 右键菜单、键盘快捷键、拖放区域和 [`filee` 命令](#命令行)。 @@ -82,7 +82,7 @@ |---|---| | **图片**:JPG、PNG、WEBP、AVIF、TIFF、BMP、GIF、APNG、ICO、ICNS、JPEG XL、JPEG 2000、PSD/PSB、TGA、PPM、HEIC、QOI、DDS、PCX、MNG、CUR 等;GIMP XCF、相机 RAW(CR2、CR3、NEF、ARW、DNG 等)、DICOM、OpenEXR、HDR 仅读取 | 上述格式互转(HEIC⁵)、PDF、SVG(描摹)、缩放、灰度 | | **矢量图**:SVG、SVGZ、EMF、WMF、AI · EPS、PS¹ · CDR、VSD、CGM、ODG、FODG² | PDF(矢量)、PNG 等图片、SVG ↔ SVGZ、SVG → DXF/DWG、PDF → EPS/PS¹、SVG → EMF、WMF、ODG² | -| **PDF** | DOCX、HWPX、TXT、PNG、JPG、TIFF、CBZ、SVG²;合并、拆分、页码范围 | +| **PDF** | DOCX、HWPX、TXT、PNG、JPG、TIFF、CBZ、PPTX(每页一张图片)、SVG²;合并、拆分、页码范围、压缩(更小的 PDF) | | **文档**:DOCX、DOCM、DOTX、EML、Outlook MSG · DOC、ODT、FODT、OTT、RTF、WPS、WPD、Pages、ClarisWorks、MacWrite、T602 等² | PDF、DOCX、HWPX、EPUB、TXT、HTML、MSG → EML · ODT、FODT、OTT² | | **表格**:XLSX、XLSM、XLS、XLT、ODS、CSV、TSV · OTS、FODS、Numbers、ET² | PDF、XLSX、ODS、CSV、TSV、JSON、HWPX、HTML · XLT、OTS² | | **演示文稿**:PPTX、PPTM、POTX、PPSX · PPT、ODP、OTP、FODP、Keynote² | PDF、PNG、JPG、DOCX、HWPX · OTP² | diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 4e47f4d6..6ca4408c 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -39,6 +39,10 @@ Each component remains under its own license. | Microsoft.Extensions.* | MIT | https://github.com/dotnet/runtime | | cu2qu (fontTools): the cubic-to-quadratic approach followed by `Filee.Engines/Fonts/Cff/CubicToQuadratic.cs` | Apache-2.0, Copyright 2016 Google Inc. | https://github.com/fonttools/fonttools | +libuiohook (the native input hook inside SharpHook) is the fork https://github.com/TolikPylypchuk/libuiohook, licensed +under the GNU LGPL-3.0 (`COPYING.LESSER.md` in its repository). Filee loads SharpHook's unmodified build of it +dynamically; you may replace those libraries with your own build of libuiohook. + Used by the tests only (not redistributed): DocumentFormat.OpenXml (Open XML SDK, MIT, https://github.com/dotnet/Open-XML-SDK) validates the DOCX files Filee writes. ## Engines @@ -50,6 +54,11 @@ Bundled with the installer: | rhwp | MIT | https://github.com/edwardkim/rhwp | | 7-Zip (console: `7z.exe`, `7z.dll`) | GNU LGPL-2.1-or-later; parts of `7z.dll`: LGPL with the unRAR license restriction, BSD-3-Clause, BSD-2-Clause | https://www.7-zip.org | +The Linux packages contain an rhwp built by Filee's CI from the unmodified source of the same release (tag `v0.8.7`, +commit `1a76570e833917d15817415a53c09ad61ab3203f`, `cargo build --release --locked --bin rhwp` on Ubuntu 22.04), so it +runs on older Linux systems than rhwp's own Linux build. It is under the same MIT license; its `LICENSE` is next to it +(`engines/rhwp`). Windows and macOS use rhwp's release builds. + 7-Zip is Copyright (C) 1999-2026 Igor Pavlov; its `License.txt` is installed next to it (`engines/7zip`). The RAR decompression code in `7z.dll` is under the unRAR license restriction: it may be used to unpack RAR archives, but not to develop a RAR (WinRAR) compatible archiver or to re-create the RAR compression algorithm. Filee only reads RAR diff --git a/build/build-linux-native.py b/build/build-linux-native.py new file mode 100644 index 00000000..123b55a6 --- /dev/null +++ b/build/build-linux-native.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Builds the Linux native pieces of the portable package that upstream builds on a newer glibc than Filee supports. + +Filee's Linux packages must start on Ubuntu 22.04 (glibc 2.35). The rhwp CLI (HWP/HWPX -> PDF) release binary is linked +on Ubuntu 24.04 (GLIBC_2.39), so CI builds it from its pinned source on Ubuntu 22.04 and build/build-portable.ps1 puts it +into the package. SharpHook's native libraries (libuiohook) come from its NuGet package: from 8.0.1 on they need glibc +2.34 at most. + +Subcommands: + check verify the rhwp pin below still matches engines.json + rhwp --output DIR build the rhwp CLI into DIR (rhwp, LICENSE, README files, like its release archive) + +Needs git, a C toolchain and rustup. Standard library only. +""" + +import argparse +import json +import os +import platform +import shutil +import subprocess +import sys +import tempfile + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + +# The rhwp release pinned in engines.json for Linux (`check` compares the versions). RUST_TOOLCHAIN is the one in the +# tag's rust-toolchain.toml. Cargo.lock in the tag pins every dependency (--locked). +RHWP_VERSION = "0.8.7" +RHWP_REPOSITORY = "https://github.com/edwardkim/rhwp" +RHWP_COMMIT = "1a76570e833917d15817415a53c09ad61ab3203f" +RUST_TOOLCHAIN = "1.93.1" + + +class BuildError(Exception): + pass + + +def run(command, cwd=None, env=None, capture=False): + print("+ " + " ".join(command), flush=True) + result = subprocess.run(command, cwd=cwd, env=env, check=False, text=True, + stdout=subprocess.PIPE if capture else None) + if result.returncode != 0: + raise BuildError(f"{command[0]} failed with exit code {result.returncode}") + return result.stdout if capture else None + + +def linux_rhwp_versions(): + path = os.path.join(ROOT, "src", "Filee.Engines", "Infrastructure", "engines.json") + with open(path, encoding="utf-8") as stream: + manifest = json.load(stream) + return {rid: manifest["platforms"][rid]["components"]["rhwp"]["version"] for rid in ("linux-x64", "linux-arm64")} + + +def check(): + problems = [] + for rid, rhwp in linux_rhwp_versions().items(): + if rhwp != RHWP_VERSION: + problems.append( + f"engines.json pins rhwp {rhwp} for {rid}, but build/build-linux-native.py builds rhwp {RHWP_VERSION}. " + "Update RHWP_VERSION, RHWP_COMMIT and RUST_TOOLCHAIN (from the tag's rust-toolchain.toml).") + if problems: + raise BuildError("\n".join(problems)) + print(f"Pins match: rhwp {RHWP_VERSION} -> {RHWP_COMMIT} (Rust {RUST_TOOLCHAIN}).") + + +def fetch(repository, commit, destination): + """A shallow checkout of exactly one commit (GitHub serves any reachable commit by its hash).""" + os.makedirs(destination) + run(["git", "init", "-q", destination]) + run(["git", "-C", destination, "fetch", "-q", "--depth", "1", repository, commit]) + run(["git", "-C", destination, "checkout", "-q", "--detach", "FETCH_HEAD"]) + head = run(["git", "-C", destination, "rev-parse", "HEAD"], capture=True).strip() + if head != commit: + raise BuildError(f"{repository}: expected commit {commit}, got {head}") + + +def verify_rhwp_tag(): + """Warns when the tag no longer points at the pinned commit (the pinned commit is built either way).""" + lines = run(["git", "ls-remote", RHWP_REPOSITORY, f"refs/tags/v{RHWP_VERSION}", f"refs/tags/v{RHWP_VERSION}^{{}}"], + capture=True).splitlines() + refs = dict(reversed(line.split("\t", 1)) for line in lines if "\t" in line) + tagged = refs.get(f"refs/tags/v{RHWP_VERSION}^{{}}") or refs.get(f"refs/tags/v{RHWP_VERSION}") + if tagged != RHWP_COMMIT: + print(f"::warning::rhwp tag v{RHWP_VERSION} points at {tagged}, the build uses the pinned {RHWP_COMMIT}.") + + +def build_rhwp(output, work): + if shutil.which("rustup") is None: + raise BuildError("rhwp needs rustup (https://rustup.rs).") + verify_rhwp_tag() + source = os.path.join(work, "rhwp") + fetch(RHWP_REPOSITORY, RHWP_COMMIT, source) + run(["rustup", "toolchain", "install", RUST_TOOLCHAIN, "--profile", "minimal", "--no-self-update"]) + # "+toolchain" takes precedence over the repository's rust-toolchain.toml (whose wasm target the CLI doesn't need). + env = dict(os.environ, CARGO_TARGET_DIR=os.path.join(work, "rhwp-target")) + run(["cargo", f"+{RUST_TOOLCHAIN}", "build", "--release", "--locked", "--bin", "rhwp"], cwd=source, env=env) + program = os.path.join(env["CARGO_TARGET_DIR"], "release", "rhwp") + version = run([program, "--version"], capture=True) + print(version.strip()) + if RHWP_VERSION not in version: + raise BuildError(f"rhwp --version printed {version.strip()!r}, expected {RHWP_VERSION}.") + # The same layout as rhwp's release archive, which build/fetch-portable-engines.ps1 unpacks for local builds. + os.makedirs(output, exist_ok=True) + shutil.copy2(program, os.path.join(output, "rhwp")) + for name in ("LICENSE", "README.md", "README_EN.md"): + if os.path.isfile(os.path.join(source, name)): + shutil.copy2(os.path.join(source, name), os.path.join(output, name)) + write_source_note(output, "rhwp", RHWP_REPOSITORY, RHWP_COMMIT, + f"v{RHWP_VERSION}; cargo +{RUST_TOOLCHAIN} build --release --locked --bin rhwp; {platform.machine()}") + + +def write_source_note(output, name, repository, commit, details): + with open(os.path.join(output, "SOURCE.txt"), "w", encoding="utf-8") as stream: + stream.write(f"{name} built from source by Filee's CI on {distribution()}.\n" + f"Source: {repository}/tree/{commit}\n{details}\n") + + +def distribution(): + try: + with open("/etc/os-release", encoding="utf-8") as stream: + for line in stream: + if line.startswith("PRETTY_NAME="): + return line.split("=", 1)[1].strip().strip('"') + except OSError: + pass + return platform.platform() + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("component", choices=["check", "rhwp"]) + parser.add_argument("--output", help="folder for the built files (must not exist yet)") + parser.add_argument("--work", help="scratch folder (default: a new temporary folder)") + options = parser.parse_args(argv) + try: + check() + if options.component == "check": + return 0 + if not sys.platform.startswith("linux"): + raise BuildError("Build the Linux native pieces on Linux.") + if not options.output: + raise BuildError("--output is required.") + output = os.path.abspath(options.output) + if os.path.exists(output): + raise BuildError(f"{output} already exists. Use a fresh output folder.") + work = os.path.abspath(options.work) if options.work else tempfile.mkdtemp(prefix=f"filee-{options.component}-") + os.makedirs(work, exist_ok=True) + build_rhwp(output, work) + print(f"{options.component} ready in {output}: {', '.join(sorted(os.listdir(output)))}") + return 0 + except BuildError as error: + print(f"::error::{error}" if os.environ.get("GITHUB_ACTIONS") else f"error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/build-portable.ps1 b/build/build-portable.ps1 index 3aa2c007..ec9c5107 100644 --- a/build/build-portable.ps1 +++ b/build/build-portable.ps1 @@ -1,10 +1,18 @@ +# Builds the Linux or macOS portable package (tar.gz + SHA256SUMS) on its own operating system. +# +# Linux packages must run on Ubuntu 22.04 (glibc 2.35). rhwp's Linux release needs a newer glibc, so CI builds it from +# source on Ubuntu 22.04 (build/build-linux-native.py) and passes it in: +# -RhwpOverride a folder with the rhwp program (rhwp's release archive layout) used instead of the rhwp +# download pinned in engines.json +# Without it (local builds) the package uses the download, which needs a newer glibc. [CmdletBinding()] param( [ValidateSet('linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64')][string]$Runtime, [string]$Version, [string]$OutputDir = (Join-Path $PSScriptRoot '../Releases'), [string]$SigningIdentity = '-', - [switch]$SkipFonts + [switch]$SkipFonts, + [string]$RhwpOverride ) $ErrorActionPreference = 'Stop' @@ -17,6 +25,15 @@ if (-not $Runtime) { if (($Runtime.StartsWith('osx-') -and -not $IsMacOS) -or ($Runtime.StartsWith('linux-') -and -not $IsLinux)) { throw "Build $Runtime on its native operating system." } +if ($RhwpOverride -and -not $Runtime.StartsWith('linux-')) { + throw '-RhwpOverride is for Linux packages.' +} +if ($RhwpOverride) { + $RhwpOverride = (Resolve-Path -LiteralPath $RhwpOverride).Path + if (-not (Test-Path -LiteralPath (Join-Path $RhwpOverride 'rhwp') -PathType Leaf)) { + throw "-RhwpOverride: $RhwpOverride has no rhwp program." + } +} if (-not $Version) { $Version = ([xml](Get-Content -LiteralPath (Join-Path $root 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 @@ -52,7 +69,16 @@ foreach ($project in @('Filee.App', 'Filee.Cli')) { "-p:Version=$Version" -p:FileeRelease=true -p:PublishReadyToRun=false -o $publish } } -& (Join-Path $PSScriptRoot 'fetch-portable-engines.ps1') -Runtime $Runtime -Destination (Join-Path $publish 'engines') +$engines = Join-Path $publish 'engines' +if ($RhwpOverride) { + & (Join-Path $PSScriptRoot 'fetch-portable-engines.ps1') -Runtime $Runtime -Destination $engines -Only 7zip + Copy-Item -LiteralPath $RhwpOverride -Destination (Join-Path $engines 'rhwp') -Recurse + Invoke-Checked 'rhwp permissions' { chmod +x (Join-Path $engines 'rhwp/rhwp') } + Write-Host "rhwp from $RhwpOverride" +} +else { + & (Join-Path $PSScriptRoot 'fetch-portable-engines.ps1') -Runtime $Runtime -Destination $engines +} foreach ($name in @('Filee', 'filee-cli')) { $executable = Join-Path $publish $name if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { throw "Publish did not produce $name" } diff --git a/build/check-elf-glibc.py b/build/check-elf-glibc.py new file mode 100644 index 00000000..34a0e8ee --- /dev/null +++ b/build/check-elf-glibc.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Lists the newest GLIBC symbol version every ELF file under the given paths needs, and fails when one needs a +newer glibc than --max allows. + +A Linux package runs only on systems whose glibc is at least as new as the newest GLIBC_x.y version any of its +programs or libraries asks for (the .gnu.version_r section, what `objdump -T` prints). The portable package is +checked against the oldest Ubuntu LTS Filee supports. Standard library only, so it runs on any CI image. + +Usage: check-elf-glibc.py [--max 2.35] [--needed] PATH [PATH ...] +Exit codes: 0 every file is within the limit, 1 a file needs a newer glibc, 2 no ELF files or a file can't be read. +""" + +import argparse +import os +import struct +import sys + +SHT_GNU_VERNEED = 0x6FFFFFFE +PT_LOAD = 1 +PT_DYNAMIC = 2 +DT_NULL = 0 +DT_NEEDED = 1 +DT_STRTAB = 5 +DT_STRSZ = 10 +DT_VERNEED = 0x6FFFFFFE +DT_VERNEEDNUM = 0x6FFFFFFF + + +class ElfError(Exception): + pass + + +class Elf: + """The parts of an ELF file needed to read its version requirements and DT_NEEDED entries.""" + + def __init__(self, data): + if data[:4] != b"\x7fELF": + raise ElfError("not an ELF file") + if data[4] not in (1, 2) or data[5] not in (1, 2): + raise ElfError("unknown ELF class or byte order") + self.data = data + self.is64 = data[4] == 2 + self.endian = "<" if data[5] == 1 else ">" + if self.is64: + (self.phoff, self.shoff) = self._unpack("QQ", 0x20) + (self.phentsize, self.phnum, self.shentsize, self.shnum, self.shstrndx) = self._unpack("HHHHH", 0x36) + else: + (self.phoff, self.shoff) = self._unpack("II", 0x1C) + (self.phentsize, self.phnum, self.shentsize, self.shnum, self.shstrndx) = self._unpack("HHHHH", 0x2A) + + def _unpack(self, fmt, offset): + fmt = self.endian + fmt + end = offset + struct.calcsize(fmt) + if offset < 0 or end > len(self.data): + raise ElfError("truncated file") + return struct.unpack_from(fmt, self.data, offset) + + def _string(self, offset): + if offset < 0 or offset >= len(self.data): + raise ElfError("string outside the file") + end = self.data.find(b"\0", offset) + if end < 0: + raise ElfError("unterminated string") + return self.data[offset:end].decode("ascii", "replace") + + def _sections(self): + for index in range(self.shnum if self.shoff else 0): + offset = self.shoff + index * self.shentsize + if self.is64: + (_, sh_type, _, _, sh_offset, sh_size, sh_link, sh_info) = self._unpack("IIQQQQII", offset) + else: + (_, sh_type, _, _, sh_offset, sh_size, sh_link, sh_info) = self._unpack("IIIIIIII", offset) + yield sh_type, sh_offset, sh_size, sh_link, sh_info + + def _segments(self): + for index in range(self.phnum if self.phoff else 0): + offset = self.phoff + index * self.phentsize + if self.is64: + (p_type, _, p_offset, p_vaddr, _, p_filesz, _, _) = self._unpack("IIQQQQQQ", offset) + else: + (p_type, p_offset, p_vaddr, _, p_filesz, _, _, _) = self._unpack("IIIIIIII", offset) + yield p_type, p_offset, p_vaddr, p_filesz + + def _file_offset(self, address): + for p_type, p_offset, p_vaddr, p_filesz in self._segments(): + if p_type == PT_LOAD and p_vaddr <= address < p_vaddr + p_filesz: + return address - p_vaddr + p_offset + raise ElfError(f"address {address:#x} is not in a loaded segment") + + def _dynamic(self): + """DT_* entries of the PT_DYNAMIC segment as (tag, value) pairs.""" + for p_type, p_offset, _, p_filesz in self._segments(): + if p_type != PT_DYNAMIC: + continue + size = 16 if self.is64 else 8 + for offset in range(p_offset, p_offset + p_filesz - size + 1, size): + tag, value = self._unpack("qQ" if self.is64 else "iI", offset) + if tag == DT_NULL: + return + yield tag, value + return + + def _read_verneed(self, offset, count, strtab): + """Walks Elf_Verneed records and their Elf_Vernaux entries: {library: [version, ...]}.""" + result = {} + for _ in range(count): + (vn_version, vn_cnt, vn_file, vn_aux, vn_next) = self._unpack("HHIII", offset) + if vn_version != 1: + raise ElfError(f"unknown verneed version {vn_version}") + library = self._string(strtab + vn_file) + names = result.setdefault(library, []) + aux = offset + vn_aux + for _ in range(vn_cnt): + (_, _, _, vna_name, vna_next) = self._unpack("IHHII", aux) + names.append(self._string(strtab + vna_name)) + if vna_next == 0: + break + aux += vna_next + if vn_next == 0: + break + offset += vn_next + return result + + def version_needs(self): + """{library: [version names]} from .gnu.version_r, or from the dynamic segment when sections are stripped.""" + sections = list(self._sections()) + for sh_type, sh_offset, _, sh_link, sh_info in sections: + if sh_type == SHT_GNU_VERNEED: + if sh_link >= len(sections): + raise ElfError("bad string table link") + return self._read_verneed(sh_offset, sh_info, sections[sh_link][1]) + dynamic = dict(self._dynamic()) + if DT_VERNEED in dynamic and DT_STRTAB in dynamic: + return self._read_verneed(self._file_offset(dynamic[DT_VERNEED]), dynamic.get(DT_VERNEEDNUM, 0), + self._file_offset(dynamic[DT_STRTAB])) + return {} + + def needed_libraries(self): + entries = list(self._dynamic()) + strtab = next((value for tag, value in entries if tag == DT_STRTAB), None) + if strtab is None: + return [] + base = self._file_offset(strtab) + return [self._string(base + value) for tag, value in entries if tag == DT_NEEDED] + + +def parse_version(text): + try: + return tuple(int(part) for part in text.split(".")) + except ValueError: + return None + + +def glibc_versions(needs): + """The numeric GLIBC_x.y versions in a version_needs() result (GLIBC_PRIVATE is reported separately).""" + versions = [] + for names in needs.values(): + for name in names: + if name.startswith("GLIBC_"): + version = parse_version(name[len("GLIBC_"):]) + if version is not None: + versions.append(version) + return versions + + +def is_elf(path): + try: + with open(path, "rb") as stream: + return stream.read(4) == b"\x7fELF" + except OSError: + return False + + +def elf_files(paths): + for path in paths: + if os.path.isfile(path) and not os.path.islink(path): + if is_elf(path): + yield path, os.path.basename(path) + continue + for directory, folders, files in os.walk(path): + folders.sort() + for name in sorted(files): + full = os.path.join(directory, name) + if not os.path.islink(full) and is_elf(full): + yield full, os.path.relpath(full, path) + + +def format_version(version): + return "GLIBC_" + ".".join(str(part) for part in version) if version else "-" + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--max", default="2.35", help="newest GLIBC version allowed (default 2.35, Ubuntu 22.04)") + parser.add_argument("--needed", action="store_true", help="also list each file's DT_NEEDED libraries") + parser.add_argument("paths", nargs="+") + options = parser.parse_args(argv) + limit = parse_version(options.max) + if limit is None: + parser.error(f"--max must look like 2.35, not {options.max}") + + rows = [] + unreadable = [] + for full, shown in elf_files(options.paths): + try: + with open(full, "rb") as stream: + elf = Elf(stream.read()) + needs = elf.version_needs() + needed = elf.needed_libraries() if options.needed else [] + except (ElfError, OSError, struct.error) as error: + unreadable.append((shown, str(error))) + continue + versions = glibc_versions(needs) + newest = max(versions) if versions else None + private = any("GLIBC_PRIVATE" in names for names in needs.values()) + rows.append((shown, newest, private, needed)) + + if not rows and not unreadable: + print("No ELF files found in: " + ", ".join(options.paths), file=sys.stderr) + return 2 + + width = max([len(shown) for shown, *_ in rows] + [len(shown) for shown, _ in unreadable] + [4]) + print(f"{'File':<{width}} {'Needs':<12} Result") + print(f"{'-' * width} {'-' * 12} ------") + failed = 0 + for shown, newest, private, needed in rows: + too_new = newest is not None and newest > limit + failed += too_new + result = f"needs a newer glibc than {options.max}" if too_new else "ok" + if private: + result += " (also GLIBC_PRIVATE: tied to one exact glibc build)" + print(f"{shown:<{width}} {format_version(newest):<12} {result}") + if needed: + print(f"{'':<{width}} {'':<12} needs {', '.join(needed)}") + for shown, error in unreadable: + print(f"{shown:<{width}} {'?':<12} cannot read: {error}") + + newest_overall = max((newest for _, newest, _, _ in rows if newest), default=None) + print() + print(f"{len(rows)} ELF files, newest requirement {format_version(newest_overall)}, limit GLIBC_{options.max}.") + sys.stdout.flush() + if failed: + print(f"{failed} file(s) need a glibc newer than {options.max}: they would not start on the oldest " + "supported distribution.", file=sys.stderr) + return 1 + if unreadable: + print(f"{len(unreadable)} ELF file(s) could not be read.", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/smoke-launch-app.py b/build/smoke-launch-app.py new file mode 100644 index 00000000..e5f32895 --- /dev/null +++ b/build/smoke-launch-app.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Starts the packaged desktop app (FILEE_SMOKE_APP, set by build/smoke-portable.ps1), checks that it stays up, that a +second start hands over to it (macOS) and that `filee --quit` ends it cleanly. On Linux it runs under Xvfb in an X11 +session and fails when the app logged that the X11 input hook back end could not be loaded. + +Used by .github/workflows/portable.yml. Screenshots and the startup log go to $RUNNER_TEMP. +""" + +import os +import pathlib +import signal +import subprocess +import sys +import time + +app = os.environ['FILEE_SMOKE_APP'] +temp = pathlib.Path(os.environ.get('RUNNER_TEMP', '/tmp')) +shots = temp / 'filee-screens' +shots.mkdir(exist_ok=True) +is_linux = sys.platform.startswith('linux') +is_macos = sys.platform == 'darwin' +command = [app, '--settings', '--install-engines=', '--context-menu=off', '--start-with-system=off'] +if is_linux: + command = ['xvfb-run', '-a', 'env', '-u', 'WAYLAND_DISPLAY', 'XDG_SESSION_TYPE=x11', *command] +log_path = temp / 'filee-startup.log' + + +def filee_logs(): + for folder in (pathlib.Path.home() / '.config/Filee/logs', pathlib.Path.home() / 'Library/Application Support/Filee/logs'): + if folder.is_dir(): + yield from sorted(folder.glob('*.log')) + + +def check_input_hook(): + """The SharpHook loader reports ErrorLinuxLoadBackend when dlopen of libuiohook-xrecord.so fails.""" + lines = [line for path in filee_logs() for line in path.read_text(errors='replace').splitlines()] + for line in lines: + if 'input hook' in line: + print(line) + if any('ErrorLinuxLoadBackend' in line for line in lines): + raise RuntimeError('The X11 input hook back end could not be loaded (ErrorLinuxLoadBackend).') + + +with log_path.open('w') as log: + process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + try: + time.sleep(8) + if process.poll() is not None: + raise RuntimeError('The desktop app exited before the startup check.') + if is_macos: + # The real screen with Filee's window, as a person would see it (uploaded with the results). + subprocess.run(['screencapture', '-x', str(shots / 'settings.png')], check=False, timeout=30) + # A second start forwards to the running Filee, which brings its window to the front. + try: + subprocess.run([app], check=False, timeout=30) + except subprocess.TimeoutExpired: + raise RuntimeError('A second start did not hand over to the running Filee.') + time.sleep(4) + subprocess.run(['screencapture', '-x', str(shots / 'second-start.png')], check=False, timeout=30) + if process.poll() is not None: + raise RuntimeError('The desktop app exited after a second start.') + quit_code = 'import os, sys; os.setsid(); os.execv(sys.argv[1], [sys.argv[1], "--quit"])' + # Reap Filee as soon as it ends while --quit waits for it, as launchd or a shell would: a process + # that has ended but was not reaped by its parent (this script) still counts as running. + quitter = subprocess.Popen([sys.executable, '-c', quit_code, app]) + quit_deadline = time.monotonic() + 30 + while quitter.poll() is None: + process.poll() + if time.monotonic() > quit_deadline: + quitter.kill() + raise RuntimeError('filee --quit did not finish.') + time.sleep(0.05) + if quitter.returncode != 0: + raise RuntimeError(f'filee --quit exited with {quitter.returncode}.') + if process.wait(timeout=15) != 0: + raise RuntimeError('The desktop app did not exit cleanly.') + if is_linux: + check_input_hook() + except BaseException: + log.flush() + print(log_path.read_text(errors='replace'), file=sys.stderr) + raise + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() diff --git a/build/smoke-portable.ps1 b/build/smoke-portable.ps1 new file mode 100644 index 00000000..a89a136f --- /dev/null +++ b/build/smoke-portable.ps1 @@ -0,0 +1,78 @@ +# Checks a portable package the way a user gets it: verifies the archive checksum, unpacks it and runs the CLI, +# rhwp and 7-Zip from it, including conversions through rhwp (TXT -> HWPX -> HWP -> PDF). On Linux it also loads +# the X11 input hook back end (libuiohook-xrecord.so) to prove its libraries and glibc requirement resolve. +# Used by .github/workflows/portable.yml on the build machine and on the oldest supported Ubuntu. +# +# In GitHub Actions it sets FILEE_SMOKE_APP (the desktop app, for build/smoke-launch-app.py) and FILEE_SMOKE_PACKAGE +# (the unpacked package folder). +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64')][string]$Runtime, + [string]$Packages = (Join-Path $PSScriptRoot '../Releases'), + [string]$WorkDir = (Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) 'filee-smoke') +) + +$ErrorActionPreference = 'Stop' + +function Invoke-Checked([string]$name, [scriptblock]$command) { + & $command + if ($LASTEXITCODE -ne 0) { throw "$name failed with exit code $LASTEXITCODE" } +} + +$archives = @(Get-ChildItem -LiteralPath $Packages -Filter "*-$Runtime.tar.gz") +if ($archives.Count -ne 1) { throw "Expected one portable archive for $Runtime in $Packages, found $($archives.Count)." } +$archive = $archives[0] +$expected = (Get-Content -LiteralPath (Join-Path $Packages ($archive.Name.Replace('.tar.gz', '-SHA256SUMS.txt')))).Split(' ')[0] +if ((Get-FileHash -LiteralPath $archive.FullName -Algorithm SHA256).Hash -ne $expected) { throw 'Archive checksum mismatch.' } + +if (Test-Path -LiteralPath $WorkDir) { throw "$WorkDir already exists. Use a fresh folder." } +New-Item -ItemType Directory -Path $WorkDir | Out-Null +$smoke = (Resolve-Path -LiteralPath $WorkDir).Path +Invoke-Checked 'Archive extraction' { tar -xzf $archive.FullName -C $smoke } +$package = Join-Path $smoke ($archive.Name.Replace('.tar.gz', '')) +if ($IsMacOS) { + Invoke-Checked 'Extracted app signature' { codesign --verify --deep --strict (Join-Path $package 'Filee.app') } +} +$bin = if ($IsMacOS) { Join-Path $package 'Filee.app/Contents/MacOS' } else { $package } +$cli = Join-Path $bin 'filee-cli' + +Invoke-Checked 'CLI start' { & $cli version } +Invoke-Checked 'rhwp start' { & (Join-Path $bin 'engines/rhwp/rhwp') --version } +Invoke-Checked '7-Zip start' { & (Join-Path $bin 'engines/7zip/7zz') i | Out-Null } + +function Convert-Smoke([string]$source, [string]$target) { + $folder = Join-Path $smoke "to-$target" + $result = & $cli convert $source --to $target -o $folder --json + $code = $LASTEXITCODE + $output = Join-Path $folder ([IO.Path]::GetFileNameWithoutExtension($source) + ".$target") + if ($code -ne 0 -or -not (Test-Path -LiteralPath $output -PathType Leaf)) { + $result | Write-Host + throw "Published CLI conversion to $target failed (exit code $code)." + } + Write-Host "Converted $([IO.Path]::GetFileName($source)) -> $target ($((Get-Item -LiteralPath $output).Length) bytes)" + return $output +} + +$inputFile = Join-Path $smoke 'sample.txt' +'Filee 한글 conversion smoke test' | Set-Content -LiteralPath $inputFile -Encoding utf8NoBOM +$hwpx = Convert-Smoke $inputFile 'hwpx' +# HWPX -> HWP and HWP -> PDF are rhwp's routes: they run the bundled rhwp from the package. +$hwp = Convert-Smoke $hwpx 'hwp' +$pdf = Convert-Smoke $hwp 'pdf' +$header = [Text.Encoding]::ASCII.GetString([IO.File]::ReadAllBytes($pdf), 0, 5) +if ($header -ne '%PDF-') { throw "rhwp's PDF does not start with %PDF- ($header)." } + +if ($IsLinux) { + # The loader dlopens this back end in an X11 session; loading it here fails like the app would when a library + # or the glibc version it needs is missing. + $backend = Join-Path $bin 'libuiohook-xrecord.so' + Invoke-Checked 'Load libuiohook-xrecord.so' { + python3 -c 'import ctypes, sys; ctypes.CDLL(sys.argv[1]); print("Loaded " + sys.argv[1])' $backend + } +} + +if ($env:GITHUB_ENV) { + "FILEE_SMOKE_APP=$(Join-Path $bin 'Filee')" >> $env:GITHUB_ENV + "FILEE_SMOKE_PACKAGE=$package" >> $env:GITHUB_ENV +} +Write-Host "Package checks passed: $package" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9965fd3d..843fcc0f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,7 +40,9 @@ 3. **Drop** – the slice under the cursor is a preset. `ConversionService.Start` enqueues a `ConversionJob`. The drop is always reported as *Copy* so Explorer never deletes the source files. 4. **Plan** – for every file, `RoutePlanner` finds the cheapest chain of converter edges from the source format to - the target (at most 3 hops). Engine priority (Settings → Engines) breaks ties. + the target (at most 3 hops). Engine priority (Settings → Engines) breaks ties. Some edges are limited: tracing + only starts from raster images (`ImageSourcesOnly`), and PDF → PPTX picture slides are only ever a route of their + own (`DirectOnly`), never a step of a longer one. 5. **Run** – `JobQueue` runs steps with per-engine concurrency limits. Intermediate files live in a temp folder; final outputs are named by `OutputPathResolver` (pattern, folder, conflict policy; the source is never overwritten). "Merge into one PDF" converts each file to PDF and merges with PDFsharp. diff --git a/docs/ENGINES.md b/docs/ENGINES.md index b6c6ca01..549d4cb9 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -8,7 +8,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | ImageMagick (Magick.NET) | built in (library) | JPG, PNG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, MNG, QOI, DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster, MIFF ↔ each other, and **APNG** (Filee's own reader and writer); HEIC (written with FFmpeg), XCF, camera RAW (LibRaw), DICOM (uncompressed ones by Filee's own reader), Photo CD, OpenEXR, Radiance HDR and EMF/WMF (Windows) read | | Vector graphics | built in (Svg.Skia, SkiaSharp, ACadSharp) | **SVG/SVGZ → PDF (vector) and images**, SVG ↔ SVGZ, **SVG → DXF** (→ DWG with the CAD engine), **raster images → SVG (tracing)**, PDF-compatible AI → PDF | | ICNS | built in | images → ICNS (16–1024 px), ICNS → images | -| PDFsharp | built in (library) | images → PDF, merge, split, page ranges | +| PDFsharp | built in (library) | images → PDF, merge, split, page ranges, **compression** (smaller PDFs, see below) | | PDFium | built in (library) | PDF → images (multi-page TIFF/GIF supported) | | Unhwp | built in (library) | HWP/HWPX → TXT, Markdown, HTML | | Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT** (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) | @@ -18,6 +18,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | **HWPX writer** | built in | **DOCX (+ DOCM/DOTX/DOTM), XLSX (+ XLSM/XLTX), XLS, ODS, CSV, TSV, PPTX (+ PPTM/POTX/PPSX), PDF, HTML, EPUB, MOBI/AZW3, FB2, HWPX, TXT, Markdown → HWPX** (and with rhwp → PDF and images); ODT, RTF, reStructuredText, LaTeX → HWPX with Pandoc | | **DOCX writer** | built in | **HWPX (and HWP through rhwp), PDF, HTML, EPUB, MOBI/AZW3, FB2, Markdown, TXT, XLSX, XLS, ODS, CSV, PPTX (+ variants) → DOCX** | | PDF text | built in (PdfPig) | **PDF → TXT** (reading order, also two columns; no OCR) | +| PDF slides | built in (PDFium, PdfPig) | **PDF → PPTX**: a picture of each page on its own slide (looks like the PDF, not editable), the page's text in the speaker notes | | E-mail | built in (MimeKit, MsgReader) | **EML, Outlook MSG → HTML** (headers + body, inline pictures), **TXT**, **ZIP** (the attachments); **MSG → EML** | | Data | built in (System.Text.Json, YamlDotNet, Tomlyn, System.Xml) | **JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other** (CSV for tables); **vCard ↔ CSV**; spreadsheets and contacts reach JSON / YAML / TOML / XML through CSV | | **E-books** | built in | **EPUB, MOBI/AZW/AZW3/PRC, FB2, HTMLZ, TXTZ → EPUB, HWPX (→ PDF), TXT, HTML, Markdown, FB2, HTMLZ, TXTZ**; HTML, Markdown, TXT, DOCX → EPUB/FB2/HTMLZ/TXTZ; HTML → TXT; comics CBZ/CBR/CB7/CBT/CBC → PDF, EPUB, CBZ; PDF → CBZ; AZW4 → PDF; **EPUB ↔ KEPUB** (Kobo) | @@ -28,8 +29,8 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | Archives (7-Zip) | bundled with the installer (`engines/7zip`, ~2.5 MB) + built-in readers | ZIP, 7Z, RAR, TAR (+ GZ/BZ2/XZ/Z/7Z/LZ), CAB, ISO, DMG, … **→ folder, ZIP, 7Z, TAR, TAR.GZ/BZ2/XZ**; ALZ, EGG, lzip read in-process; "Compress into one archive" for any files | | LibreOffice + H2Orestart + Java | **optional download** (~420 MB, 1.3 GB on disk) | older and rare formats only: DOC, XLS, PPT, RTF and OpenDocument → PDF and to each other, output as DOCX/ODT/ODS/ODP; HWP/HWPX → DOCX/ODT; read-only formats (see below) → PDF and their kind's editable formats; FODT, OpenDocument templates (OTT, OTS, OTP) and XLT out; SVG → EMF/WMF/ODG, a PDF page → SVG | | Pandoc | **optional download** (~42 MB, 240 MB on disk) | Markdown ↔ DOCX/ODT/RTF, DOCX/ODT/HTML/RTF → Markdown, HTML ↔ DOCX/ODT, markup languages (reStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter, Textile, man, OPML) ↔ Markdown/HTML/DOCX/ODT and each other (and → RTF, EPUB, TXT), Markdown and markup → PPTX slides | -| Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium) | -| FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG or a still frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, images → HEIC | +| Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium); smaller PDFs when a preset asks for it | +| FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG, a still frame or every frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, images → HEIC | | Calibre | **optional download** (~216 MB, 660 MB on disk) | rare e-book formats: LIT, LRF, CHM, PDB, PML, RB, SNB, TCR, OEB → EPUB; EPUB → MOBI, AZW3, LIT, LRF, PDB, PML, RB, SNB, TCR | DOCX, XLSX, XLS, ODS and PPTX → PDF need neither Microsoft Office nor LibreOffice: they are read in-process, written as HWPX @@ -75,9 +76,30 @@ they can be installed or removed later in Settings → *Engines*. A conversion t (`vcruntime` component) is copied next to `gswin64c.exe`, so it runs on PCs without the VC++ Redistributable. To bump a version, change `url`, `sha256` and `size` in engines.json together, run -`pwsh build/fetch-engines.ps1`, test, and update `THIRD-PARTY-NOTICES.md`. When the LibreOffice team retires a -version from `download.documentfoundation.org/libreoffice/stable/`, its URL stops working: keep the pinned version -current. +`pwsh build/fetch-engines.ps1`, test, and update `THIRD-PARTY-NOTICES.md`. For rhwp, also update `RHWP_VERSION`, +`RHWP_COMMIT` and `RUST_TOOLCHAIN` in `build/build-linux-native.py` (see below); CI fails until they match. When the +LibreOffice team retires a version from `download.documentfoundation.org/libreoffice/stable/`, its URL stops working: +keep the pinned version current. + +## Linux builds for older glibc + +Linux packages run on Ubuntu 22.04 (glibc 2.35). rhwp's Linux release is linked on Ubuntu 24.04 (needs glibc 2.39). +The `linux-native` job of `.github/workflows/portable.yml` builds it from its pinned, unmodified source on Ubuntu 22.04 +(x64 and arm64) with `build/build-linux-native.py`: + +- `rhwp`: `cargo build --release --locked --bin rhwp` at the tag's commit with its pinned Rust toolchain, in the + layout of rhwp's release archive. +- `check` (also run on its own): fails when engines.json pins another rhwp version than the script builds. + +The Linux package jobs pass the result to `build/build-portable.ps1 -RhwpOverride `. Local builds without it use +the downloaded rhwp (fine on the machine that builds it). SharpHook's native libraries are packaged as they come +from NuGet: SharpHook 8.0.0 built its X11 back ends against glibc 2.38, 8.0.1 needs glibc 2.34 at most again. + +`build/check-elf-glibc.py` lists the newest GLIBC version every ELF file in the package needs and fails above 2.35, +and the packages are unpacked and started on Ubuntu 22.04 (`build/smoke-portable.ps1`: CLI, rhwp conversions, loading +the X11 input hook back end; `build/smoke-launch-app.py`: the app under Xvfb). If a SharpHook or engine update fails +these checks, keep the previous version until upstream builds for an older glibc again, or build the binary from its +pinned source in `build/build-linux-native.py` like rhwp (PR #113 did this for SharpHook 8.0.0's libuiohook back ends). ## Images and vector graphics @@ -374,6 +396,76 @@ it. DOCX → DOCX is no edge; the Word variants are the Office Open XML engine's Password-protected PDFs fail with a clear message. Not converted: tables (their cells come out as paragraphs in reading order), vector drawings on text pages, form fields and annotations other than links. +## PDF → PPTX + +`Pdf/PdfSlidesConverter.cs` turns a PDF into a PowerPoint deck of pictures: every page is rendered (PDFium, as for +PDF → images) and becomes one picture on its own slide. The slides look exactly like the PDF but are **not +editable**: there are no text boxes to change, the PDF's layout is not rebuilt. PDF → DOCX is the editable route. + +- **Slide size** is the first page's own size (an A4 portrait PDF gives A4 portrait slides, a PDF exported from a + 16:9 deck gives 16:9 slides), within PowerPoint's limits of 1 to 56 inches a side. Every page, also one of another + size or orientation (pages turned in the PDF are shown turned), is fitted onto the slide without cropping and + centred on white. +- **Resolution** is the preset's render DPI, at least 150 and at most 600; a page larger than 25 megapixels at that + resolution (a poster) is rendered at a lower one. The preset's page range picks the pages. +- **Pictures** are JPEG at the preset's quality (90 by default, as the "PPTX (slides)" preset sets it). Pages are + rendered on white, as PDF viewers show them, so they have no transparency that would need PNG, and JPEG keeps a + long deck at a size that is easy to send (an A4 text page at 150 dpi is a few hundred KB). +- **Speaker notes** hold the page's text, read like PDF → TXT (reading order, the invisible OCR layer of scans + included), so it can still be searched and copied. Pages without text get no notes; when PdfPig cannot read a PDF + that PDFium renders, the deck is written without notes. +- The package (`Pdf/PictureDeckWriter.cs`) is written by hand: presentation, one slide master with a blank layout, + a theme, the slides with their pictures, a notes master and notes slides, and the document properties. The tests + validate it with the Open XML SDK and read it back with Filee's own PPTX reader. +- The edge is `DirectOnly`: the route planner uses it only for a PDF converted to PPTX. A Word document or an image is + never turned into picture slides through PDF, and a picture deck is never a step towards ODP, PPSX or another + format (a test checks that every other pair of formats keeps its route). + +Password-protected PDFs fail with the same message as PDF → DOCX. + +## PDF compression + +"Compress PDF" (and any PDF → PDF preset with *Compress* set, also with a page range or one file per page) makes a +PDF smaller while it stays a PDF, for e-mail attachments and upload limits. `Pdf/PdfCompressor.cs` does it with +PDFsharp and Magick.NET; nothing has to be installed. + +| Level | Pictures scaled down to | JPEG quality | +|---|---|---| +| Smallest (`Screen`) | 72 dpi | 55 | +| Balanced (`Ebook`, the preset's) | 150 dpi | 65 | +| High quality (`Print`) | 300 dpi | 85 | + +- **Pictures**: `Pdf/PdfPictureUses.cs` follows the content streams of the pages, of the forms they draw and of + annotation appearances (`q`/`Q`/`cm`/`Do`) to find the largest size each picture is drawn at. A picture drawn at more + than 1.5 × the level's resolution is scaled down to it (as Ghostscript does); a picture whose drawing can't be + followed is taken as large as the page holds it, so it is never scaled down too far. Photos are saved as JPEG at the + level's quality, pictures with up to 256 colours (charts, screenshots, logos) losslessly (Flate with PNG + prediction). A JPEG that keeps its size is encoded again only when that saves at least 10 %. A picture is replaced + only when the result is smaller. +- **Which pictures**: 8 bits per component, gray, RGB (also ICC-based and calibrated) and CMYK, stored as JPEG, Flate + (with or without PNG prediction), RunLength or uncompressed. CMYK JPEGs, 1-bit pictures and masks, indexed + (palette) pictures, spot colours, JPEG 2000, JBIG2, CCITT and pictures with a colour-key mask stay as they are. +- **Transparency**: a soft mask is scaled with its picture and kept lossless, so transparent parts stay transparent. +- **Duplicates**: a picture stored more than once with the same data (a logo embedded again on every page) is kept once. +- **Everything else**: every stream is deflated at the highest level when that's smaller; page thumbnails and other + applications' private data (`PieceInfo`, such as Illustrator's editing data) are removed, as are unused objects. + When the preset doesn't keep metadata, the XMP metadata of pages and pictures goes too. The document's own XMP is + written afresh from its document information by PDFsharp, as for every PDF it saves. +- **Unchanged**: text, fonts, vector drawings, links, bookmarks and form fields. The whole file is compressed as it + is; a page range or single pages are first copied into new PDFs, as without compression (which leaves bookmarks and + form fields behind), and so is a PDF locked only against changes. +- **Never bigger**: when nothing gets smaller (a PDF of text only, one compressed before), the PDF is written as it was + and the toast (and `filee convert`) says that it couldn't be made smaller. Password-protected PDFs fail with the same + message as the other PDF conversions. +- **Ghostscript** (optional, *Also try Ghostscript* in the preset): when it is installed, the PDF is also run through + `pdfwrite` with `-dPDFSETTINGS=/screen`, `/ebook` or `/printer` (the same 72, 150 and 300 dpi) and + `-dCompatibilityLevel=1.6`, and the smallest of the two results is kept; when Ghostscript fails, the built-in result + stands. It is off by default: Ghostscript rewrites the whole file (fonts are subset again, structure and form fields + may change), so it is the user's choice. It also writes object streams, which PDFsharp can't, and its JPEGs are + somewhat smaller at the Balanced level. +- **Not done**: object streams (PDFsharp writes none), font subsetting or removal of unused glyphs, converting + colour to gray, flattening transparency or form fields, recompressing JBIG2/CCITT scans. + ## Office Open XML variants `Office/OoxmlConverter.cs` converts between the variants of a family (Word, Excel, PowerPoint) by copying the @@ -752,8 +844,8 @@ Filee (with the GPL source offer), then an `engines.json` component and an optio ## FFmpeg `Media/FfmpegConverter.cs` converts every video format to every video format, to animated GIF or APNG, to a still PNG -frame, to every audio format (the sound track) and to SRT, ASS and WebVTT (the subtitles); every audio format to every -audio format; and GIF, APNG and PNG to MP4, WEBM and MOV. +frame or a folder of every frame, to every audio format (the sound track) and to SRT, ASS and WebVTT (the subtitles); +every audio format to every audio format; and GIF, APNG and PNG to MP4, WEBM and MOV. Same-format edges exist too, so "MP4 720p" works on MP4 files and "MP3 128k" on MP3 files. - The download is the gyan.dev **9.0.2 "full_build-shared"** Windows build (GPL-3.0, run as a separate program). @@ -807,6 +899,29 @@ Same-format edges exist too, so "MP4 720p" works on MP4 files and "MP3 128k" on for the rest. The encoders are looked up with `ffmpeg -encoders` once per FFmpeg, so a copy the user picked without x265 or AV1 fails with a clear message instead of an FFmpeg error. x265 and SVT-AV1 log only errors (`-x265-params log-level=error`, `SVT_LOG=1`). +- **Hardware encoding** (*Hardware acceleration* in the video card, `MediaOptions.HardwareEncoding`; off by default and + for older presets, so nobody's results change without asking). H.264 and HEVC encodes (not VP9, AV1 and the + MPEG-style codecs, not stream copies, not pictures → video) go to the graphics chip (`Media/HardwareVideo.cs`, + `Media/HardwareProbe.cs`): + + | System | Tried in this order | + |---|---| + | Windows | `*_nvenc` (NVIDIA), `*_qsv` (Intel Quick Sync), `*_amf` (AMD) | + | macOS | `*_videotoolbox` with constant quality (Apple Silicon), then with a bitrate (Intel Macs reject `-q:v`) | + | Linux | `*_nvenc`, then `*_vaapi` on `/dev/dri/renderD128` (`-vaapi_device`, frames uploaded with `format=nv12,hwupload`) | + + FFmpeg lists encoders the computer can't run (NVENC without an NVIDIA card), so only the ones `ffmpeg -encoders` + lists are tried, each first with a test encode with the real options (0.1 s of `testsrc` at 256 × 144 to the null + muxer, 15 s limit). The first that succeeds is used; results are kept per FFmpeg executable, encoder, rate control + and bit depth until Filee restarts, and written to the log. Quality: *High/Balanced/Small* become quantizer 19/24/29 + for H.264 and 23/27/31 for HEVC (one above x264's and x265's CRF: hardware encoders spend bits less wisely) as NVENC + `-rc vbr -cq N -b:v 0 -preset p5`, Quick Sync `-global_quality N -preset medium` (ICQ), AMF `-rc cqp -qp_p N -qp_i N−2` + (`-quality balanced`) and VA-API `-rc_mode CQP -qp N`; VideoToolbox gets `-q:v` 70/60/50, or on Intel Macs + `-b:v` from 0.14/0.09/0.05 bits per pixel per frame (× 0.65 for HEVC). 8-bit video is NV12 (H.264 High, HEVC Main); + 10-bit and HDR sources stay P010 HEVC Main 10 with their colour tags when the encoder passes a 10-bit test encode, + otherwise x265 encodes them. HEVC keeps the `hvc1` tag in MP4/M4V/MOV. When a hardware encode fails anyway (a + picture too large for the chip, a driver error), the partial output is deleted and the file is encoded again with + x264/x265; that is logged, not shown as a failure. - DSD (DSF, DFF) decodes at 2.8 MHz or more: lossless and PCM targets get 88.2 kHz, Vorbis and WMA 44.1 kHz, the other lossy encoders their nearest supported rate. - Subtitle extraction takes the first text subtitle track (SubRip, ASS/SSA, WebVTT, MP4 timed text, CEA-608, …). @@ -818,12 +933,34 @@ Same-format edges exist too, so "MP4 720p" works on MP4 files and "MP3 128k" on FLAC. Portrait phone videos are turned upright. Odd sizes are evened out, frame rates that MPEG-2/MXF/MPEG-4 part 2 cannot store are converted, and channels WMA or AMR cannot hold are mixed down. - Progress comes from `-progress` (position ÷ duration from ffprobe); the GIF palette pass reports per-frame stats of - a discarded copy of its frames. Cancelling kills the process tree and deletes the partial output. There is no - time limit, but FFmpeg is stopped when it reports nothing for 10 minutes. At most two conversions run at a time - (FFmpeg's encoders use every core themselves). + a discarded copy of its frames. Cancelling kills the process tree, waits (up to 10 seconds) until it has exited and + deletes the partial output. There is no time limit, but FFmpeg is stopped when it reports nothing for 10 minutes. + At most two conversions run at a time (FFmpeg's encoders use every core themselves). - Video → PNG writes one still frame (a tenth into the video, where the picture has usually faded in); the image engines turn it into JPG, WEBP, PDF, …. The GIF edge costs more, so these routes never go through an animated GIF of the whole video. +- **Every frame** (*Save every frame of a video* on a PNG or JPG preset, `MediaOptions.SaveEveryFrame`; the presets + "Extract frames (PNG)" and "Extract frames (JPG)", on no donut): `Media/FrameSequenceWriter.cs` writes + `frame_000001.png`, `frame_000002.png`, … into one folder, named by the output rule with `_frames` added + (`clip_frames`, `clip_frames (2)`; skipped, or filled over like an extracted archive, by the conflict rule). + - Routes: the frame edges (`ConversionEdge.FrameSequence`, video → PNG and video → JPG) are taken only when + `Preset.SavesFramesOf` the file (the option is set, the target is PNG or JPG and the file is a video), and then as + the whole route (`RoutePlanner.Plan(from, to, frames: true)`). Without the option video → JPG stays the still frame + above, which the image engines turn, resize and so on; other files and targets ignore the option. + - *Every Nth frame* (1–1000) keeps frames 1, N + 1, 2N + 1, … (`select`, `-fps_mode vfr`). Otherwise every decoded + frame is written once (`-fps_mode passthrough`): a variable frame rate phone video gives as many pictures as it + has frames, not one per 1/30 s with the slow parts repeated. The pictures are numbered, not named by time. + - Only the first video stream is read (`-map 0:V:0`). Pixels are made square and portrait videos are turned + upright, as for the still frame; nothing else of the image options is applied (no resizing, rotating or + metadata). JPG uses the preset's quality (100 → `-q:v 2`, 90 → 5, 1 → 31). HDR video (PQ, HLG) is not + tone-mapped, so its frames look flat and grey; 10-bit video becomes 16-bit PNG. + - Before anything is written the pictures are estimated (duration × frame rate ÷ interval). More than 100,000 + (`MediaOptions.MaxFrames`, about an hour at 30 fps), or more than the free space of the output drive (estimated at + 1.5 bytes a pixel for PNG, 3 for 10-bit video, 0.25 for JPG), fails with a reason in the user's language + (`ConversionRefusedException`); a file that doesn't say its length or frame rate is not checked. The editor shows + how many pictures a minute of 30 fps video gives at the chosen interval. + - The frames go into a hidden folder next to the output and are moved into place when FFmpeg has finished, so a + cancelled or failed run leaves no half-filled folder. - Pictures → MP4/WEBM/MOV go through a full-size PNG from the image engines (HEIC → PNG → MOV): a 5-second still at 30 fps, one quality step above the preset (identical frames cost almost nothing; x264 `-tune stillimage`). The PNG's ICC profile is read (`-flags2 +icc_profiles`), so primaries and transfer are tagged as the photo's (Display diff --git a/installer/linux/INSTALL.txt b/installer/linux/INSTALL.txt index 6f854bff..09def096 100644 --- a/installer/linux/INSTALL.txt +++ b/installer/linux/INSTALL.txt @@ -7,16 +7,24 @@ Your settings and converted files are kept. If integration cleanup fails, the installation is kept so you can fix the error and run the uninstaller again. An existing installation is never overwritten; uninstall it before replacing it. -The .NET runtime is bundled; its native system libraries are still required. -On Ubuntu 24.04, install the desktop and runtime dependencies with: +Filee runs on Ubuntu 22.04 or newer and other distributions with glibc 2.35 or +newer. The .NET runtime is bundled; its native system libraries are still +required. Install the desktop and runtime dependencies with: -sudo apt-get install ca-certificates libc6 libgcc-s1 libgssapi-krb5-2 libicu74 libssl3t64 libstdc++6 tzdata zlib1g libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 fonts-noto-cjk python3 +Ubuntu 24.04: +sudo apt-get install ca-certificates libc6 libgcc-s1 libgssapi-krb5-2 libicu74 libssl3t64 libstdc++6 tzdata zlib1g libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 libxt6 fonts-noto-cjk python3 -Other distributions use different package names. The desktop needs an X11 -server (or XWayland), fontconfig, FreeType and the X11 libraries above. -X11 supports the global gesture toolbar. A native Wayland session has additional -input restrictions; use the drop window and file-manager actions if needed. -Ubuntu 24.04 is the automated build and smoke-test baseline. +Ubuntu 22.04: +sudo apt-get install ca-certificates libc6 libgcc-s1 libgssapi-krb5-2 libicu70 libssl3 libstdc++6 tzdata zlib1g libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 libxt6 fonts-noto-cjk python3 + +Later Ubuntu releases ship a newer libicu (find its name with apt-cache search +libicu). Other distributions use different package names. The desktop needs an +X11 server (or XWayland), fontconfig, FreeType and the X11 libraries above. +X11 supports the global gesture toolbar; it needs libXtst and libXt (libxtst6, +libxt6). A native Wayland session has additional input restrictions; use the +drop window and file-manager actions if needed. +Packages are built and smoke-tested on Ubuntu 24.04 and started on Ubuntu 22.04 +by the automated builds. rhwp and 7-Zip are bundled. Other engines are installed from Filee settings or discovered in the system. Format support depends on available native engines. @@ -24,6 +32,6 @@ Optional engines can require additional system libraries. In particular, calibre may require libxcb-cursor0, libxcb-xinerama0 and other Qt/XCB dependencies. Dependency references: -https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install#ubuntu-2404 +https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install https://docs.avaloniaui.net/docs/deployment/linux https://calibre-ebook.com/download_linux diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 9ad8ebec..ea70f0a1 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -5,6 +5,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Threading; +using Filee.App.Controls; using Filee.App.Services; using Filee.App.Services.Triggers; using Filee.Core.Conversion; @@ -22,7 +23,14 @@ public partial class App : Application private IDisposable? _convertDebounce; private bool _selectionPending; - public override void Initialize() => AvaloniaXamlLoader.Load(this); + /// Forwarded launches and empty selections can come in bursts: a few lines a minute are enough. + private static readonly LogThrottle Throttle = new(TimeSpan.FromSeconds(30)); + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + SingleSelectionGuard.Register(); + } public override void OnFrameworkInitializationCompleted() { @@ -34,6 +42,9 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } + /// Ends Filee as the tray's Quit does. + public static void Quit() => (Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); + private void Start(IClassicDesktopStyleApplicationLifetime desktop) { // macOS: "Convert with Filee" in Finder's right-click menu. Registered first, so a service picked while @@ -42,12 +53,20 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) Filee.Platform.MacOS.MacOSServicesProvider.Register(files => Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(["--convert", .. files])))); var log = AppHost.Get>(); + // Exceptions in UI event handlers end Filee; write them down first (they are not marked handled). + Dispatcher.UIThread.UnhandledException += (_, e) => + log.LogCritical(e.Exception, "Unhandled exception on the UI thread"); + var watchdog = new UiWatchdog(action => Dispatcher.UIThread.Post(action), AppHost.Get>()); + watchdog.Start(); var store = AppHost.Get(); store.Load(); var loc = AppHost.Get(); loc.Attach(this); ApplySettings(store.Settings); + // Every engine's availability, checked once off the UI thread: the first donut or drop otherwise starts + // every engine's check (FFmpeg -version, folder searches) on the UI thread and freezes it meanwhile. + _ = AppHost.Get().CheckInBackgroundAsync(); // Global gestures → donut toolbar var triggers = AppHost.Get(); @@ -66,6 +85,11 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) ? await Task.Run(platform.GetFileManagerSelection) : platform.GetFileManagerSelection(); if (files.Count > 0 && !store.Settings.Paused) radial.ShowForFiles(e.X, e.Y, files); + else if (files.Count == 0 && Throttle.TryEnter("empty-selection", out var held)) + log.LogInformation("The selection shortcut found no selected files in {Window}{Elevation}{Held}", + platform.DescribeForegroundWindow() is { Length: > 0 } window ? window : "the foreground window", + Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator and can't read Explorer)" : "", + LogThrottle.HeldText(held)); } catch (Exception ex) when (ex is not OutOfMemoryException) { @@ -104,13 +128,12 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) // Later launches (context menu, Send To, double-clicking the exe) forward their arguments here. if (Program.Instance is { } instance) - { - instance.ArgumentsReceived += args => Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(args))); - instance.StartListening(); - } + ListenForLaterLaunches(instance, log); + Program.InstanceAcquired += again => ListenForLaterLaunches(again, log); desktop.Exit += (_, _) => { + watchdog.Dispose(); AppHost.Get().Dispose(); AppHost.Get().Dispose(); AppHost.Get().DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); @@ -118,6 +141,21 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) HandleCommandLine(Program.Options, firstLaunch: true); WatchForUpdates(store); + // Filee runs as administrator while Explorer doesn't (an installer started "as administrator" started it): + // say so once, next to the tray, and on the Home page for as long as it lasts. + if (AppHost.Get().ShellCannotReachFilee) + DispatcherTimer.RunOnce(() => AppHost.Get().ShowElevationNotice(), TimeSpan.FromSeconds(2)); + } + + private void ListenForLaterLaunches(SingleInstance instance, ILogger log) + { + instance.ArgumentsReceived += args => + { + if (Throttle.TryEnter("forwarded", out var held)) + log.LogInformation("A later launch handed over {Count} argument(s){Held}", args.Length, LogThrottle.HeldText(held)); + Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(args), forwarded: true)); + }; + instance.StartListening(); } /// @@ -193,8 +231,13 @@ private static void ApplySettings(AppSettings settings) AppHost.Get().Apply(settings); } - private void HandleCommandLine(CommandLine options, bool firstLaunch = false) + private void HandleCommandLine(CommandLine options, bool firstLaunch = false, bool forwarded = false) { + // Pairs with "A later launch handed over …": the request reached the UI thread too. + if (forwarded && Throttle.TryEnter("forwarded-handled", out var held)) + AppHost.Get>().LogInformation("Handling a later launch: {Request}{Held}", + options.Quit ? "quit" : options.ConvertFiles.Count > 0 ? $"convert {options.ConvertFiles.Count} file(s)" : "show Filee", + LogThrottle.HeldText(held)); if (options.Quit) { // The installer is about to replace or remove Filee's files (Program.RunInstallerCommand waits for this). diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index df514816..13a5e2f4 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -25,6 +25,13 @@ "home.drop_hint_macos": "Or hold {0} while dragging files in Finder. The donut toolbar opens at your cursor.", "home.drop_hint_linux": "Or hold {0} while dragging files in your file manager. The donut toolbar opens at your cursor.", "home.accessibility_needed": "To open the donut by dragging with ⌥ Option, allow Filee under Accessibility.\nIt works as soon as you switch it on.", + "home.elevated": "Filee is running as administrator, so Explorer can't drop files on it or use its menu.\nThe shortcut can't read Explorer's selection either.\nRestart Filee without administrator rights to fix this.", + "home.elevated_restart": "Restart without administrator rights", + "home.elevated_restart_failed": "Filee couldn't restart itself.\nQuit Filee from the tray, then start it again.", + "elevation.notice_title": "Filee is running as administrator", + "elevation.notice_text": "Explorer can't drop files on Filee or use its menu while it runs this way.\nRestart it without administrator rights.", + "startup.elevated_other": "Another Filee is running as administrator.\nExplorer can't hand files to it.\nQuit Filee from the tray, then start it again.", + "startup.no_answer": "Filee is running but didn't answer.\nIf it runs as administrator, quit it from the tray and start it again.", "home.recent": "Recent conversions", "home.recent_empty": "Your converted files will show up here.", "home.open_folder": "Open folder", @@ -58,7 +65,10 @@ "error.unsupported_source": "This file type isn't supported", "error.no_route": "No engine can do this conversion", "error.cannot_turn": "Rotate and flip work on images only", + "error.too_many_frames": "Too many frames to save as pictures, raise \"Every Nth frame\"", + "error.not_enough_space": "Not enough free disk space for the pictures", "error.conversion_failed": "The engine reported an error", + "notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is", "engine.reason.not_installed": "Not installed", "engine.reason.error": "Error while checking", @@ -66,7 +76,7 @@ "engine.reason.needs_package": "Needs {0}. Install it in Settings → Engines", "engine.magick.description": "JPG, PNG, WEBP, AVIF, HEIC, TIFF, GIF, APNG, PSD, camera RAW and more.\nQuality, size and DPI follow your preset.", "engine.pdfium.description": "Renders PDF pages to PNG, JPG and TIFF (also multi-page TIFF and GIF) at the preset's resolution.", - "engine.pdfsharp.description": "Makes PDFs from images and merges, splits or cuts page ranges out of PDFs.", + "engine.pdfsharp.description": "Makes PDFs from images and merges, splits or cuts page ranges out of PDFs.\nAlso makes PDFs smaller by scaling down their pictures.", "engine.rhwp.description": "Renders HWP/HWPX to PDF and converts HWP ↔ HWPX.\nIt also draws documents that reach PDF through HWPX (DOCX, XLSX, PPTX, e-books…).", "engine.unhwp.description": "Extracts text, Markdown and HTML from HWP and HWPX.", "engine.markdown.description": "Markdown → HTML and plain text.", @@ -75,6 +85,7 @@ "engine.hwpx-writer.description": "Writes HWPX from Word, Excel, PowerPoint, PDF, HTML, e-books and Markdown.\nThe first step of turning documents into PDF without Office.", "engine.docx-writer.description": "Writes Word documents (DOCX) from PDF, HWP/HWPX, Markdown, HTML, spreadsheets, slides and e-books.", "engine.pdf-text.description": "Extracts the text of PDFs in reading order, also from two-column pages (no OCR for scans).", + "engine.pdf-slides.description": "Turns PDFs into PowerPoint slides: each page becomes a picture on its own slide (looks like the PDF, not editable), with the page text in the speaker notes.", "engine.email.description": "E-mails (EML, Outlook MSG) to HTML and text with sender, recipients and date, pictures included; attachments into a ZIP.\nMSG to EML.", "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML and CSV into each other.\nvCard contacts to CSV and back.\nComments are dropped. CSV holds tables only.", "engine.vector.description": "SVG and SVGZ to vector PDF, images and DXF; pictures traced into SVG; Illustrator files saved with PDF content.", @@ -127,12 +138,16 @@ "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Merge PDF", "preset.split_pdf": "Split PDF", + "preset.compress_pdf": "Compress PDF", "preset.long_image": "Long image", + "preset.pdf_slides": "PPTX (slides)", "preset.extract": "Extract", "preset.zip_all": "One ZIP", "preset.extract_subtitles": "Extract subtitles", "preset.trace_svg": "Trace to SVG", "preset.trace_line_art": "Line art to SVG", + "preset.extract_frames_png": "Extract frames (PNG)", + "preset.extract_frames_jpg": "Extract frames (JPG)", "format.folder": "Folder", "format.category.Image": "Image", @@ -227,6 +242,7 @@ "triggers.hook_running": "Global input detection is running.", "triggers.hook_stopped": "Global input detection is stopped.\nFile manager actions and the drop zone remain available.", "triggers.hook_failed": "Global input detection failed: {0}", + "triggers.hook_linux_backend": "Gestures couldn't start because the X11 input libraries didn't load.\nUse an X11 session and install libxtst6 and libxt6 (Ubuntu/Debian: sudo apt install libxtst6 libxt6), then retry.\nFile manager actions and the drop zone remain available.", "triggers.add": "Add gesture", "triggers.remove": "Remove", "triggers.enabled": "On", @@ -361,6 +377,10 @@ "presets.webp_lossless": "Lossless WEBP", "presets.multipage_tiff": "Put every file into one multi-page TIFF", "presets.multipage_tiff_hint": "Each image and each PDF or TIFF page becomes a page, in order.\nOne PDF is enough for a multi-page TIFF.", + "presets.save_every_frame": "Save every frame of a video", + "presets.save_every_frame_hint": "A video becomes a folder with a picture of each frame instead of one still frame.\nA long video gives many files.", + "presets.frame_interval": "Every Nth frame", + "presets.frame_estimate": "One minute of 30 fps video gives {0} pictures, at most {1} per video.", "presets.ico_sizes": "Icon sizes", "presets.group.pdf": "PDF", "presets.merge": "Merge all files into one PDF", @@ -373,6 +393,15 @@ "presets.page_range": "Pages", "presets.page_range_hint": "All pages, or e.g. 1-3, 5", "presets.split": "One file per page", + "presets.pdf_compression": "Compress", + "presets.pdf_compression.None": "Don't compress", + "presets.pdf_compression.Screen": "Smallest (pictures at 72 dpi)", + "presets.pdf_compression.Ebook": "Balanced (pictures at 150 dpi)", + "presets.pdf_compression.Print": "High quality (pictures at 300 dpi)", + "presets.pdf_compression_hint": "Pictures are scaled down and saved as JPEG.\nText, fonts and drawings stay as they are.\nIf the result isn't smaller, the original is kept.", + "presets.pdf_ghostscript": "Also try Ghostscript (when installed)", + "presets.pdf_ghostscript_hint": "Often smaller, but it rewrites the whole file.\nThe smallest result is kept.", + "presets.pdf_keep_metadata": "Keep page and picture metadata (XMP)", "presets.combine_pages_hint": "Puts all pages one under another in a single image.\nVery long results are scaled to the format's limit (JPG 65,500 px, WebP 16,383 px).", "presets.combine_pages": "Stack the pages into one tall image (when a PDF is converted)", "presets.trim_page_margins": "Cut the blank space above and below each page", @@ -410,6 +439,8 @@ "presets.video_codec.vp9": "VP9", "presets.video_codec.hevc": "HEVC / H.265 (smaller)", "presets.video_codec.av1": "AV1 (smallest, slow)", + "presets.hardware_encoding": "Use hardware acceleration (graphics chip)", + "presets.hardware_encoding_hint": "Encodes H.264 and HEVC on the graphics chip.\nMuch faster and uses less power.\nSlightly lower quality at the same file size.\nWithout a usable chip, the video is encoded as usual.", "presets.audio_codec": "Audio codec", "presets.audio_codec.aac": "AAC", "presets.audio_codec.alac": "ALAC (lossless)", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 911d0f3b..14f920ca 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -25,6 +25,13 @@ "home.drop_hint_macos": "또는 Finder에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.", "home.drop_hint_linux": "또는 파일 관리자에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.", "home.accessibility_needed": "⌥ Option 드래그로 원형 툴바를 열려면 손쉬운 사용에서 Filee를 허용해 주세요.\n켜는 즉시 동작해요.", + "home.elevated": "Filee가 관리자 권한으로 실행 중이에요.\n그래서 탐색기에서 파일을 끌어 놓거나 메뉴를 쓸 수 없어요.\n단축키도 탐색기에서 선택한 파일을 읽지 못해요.\n관리자 권한 없이 다시 시작해 주세요.", + "home.elevated_restart": "관리자 권한 없이 다시 시작", + "home.elevated_restart_failed": "Filee를 다시 시작하지 못했어요.\n트레이에서 Filee를 종료한 뒤 다시 실행해 주세요.", + "elevation.notice_title": "Filee가 관리자 권한으로 실행 중이에요", + "elevation.notice_text": "이 상태에서는 탐색기에서 파일을 끌어 놓거나 메뉴를 쓸 수 없어요.\n관리자 권한 없이 다시 시작해 주세요.", + "startup.elevated_other": "다른 Filee가 관리자 권한으로 실행 중이에요.\n그래서 탐색기가 파일을 넘겨줄 수 없어요.\n트레이에서 Filee를 종료한 뒤 다시 실행해 주세요.", + "startup.no_answer": "Filee가 실행 중이지만 응답하지 않아요.\n관리자 권한으로 실행 중이라면 트레이에서 종료한 뒤 다시 실행해 주세요.", "home.recent": "최근 변환", "home.recent_empty": "변환한 파일이 여기에 표시됩니다.", "home.open_folder": "폴더 열기", @@ -58,7 +65,10 @@ "error.unsupported_source": "지원하지 않는 파일 형식이에요", "error.no_route": "이 변환을 할 수 있는 엔진이 없어요", "error.cannot_turn": "회전·반전은 이미지에만 할 수 있어요", + "error.too_many_frames": "프레임이 너무 많아요. 'N번째 프레임마다' 값을 늘려 주세요", + "error.not_enough_space": "이미지를 저장할 디스크 공간이 부족해요", "error.conversion_failed": "엔진에서 오류가 발생했어요", + "notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요", "engine.reason.not_installed": "설치되지 않음", "engine.reason.error": "확인 중 오류", @@ -66,7 +76,7 @@ "engine.reason.needs_package": "{0} 필요 (설정 → 변환 엔진에서 설치)", "engine.magick.description": "JPG, PNG, WEBP, AVIF, HEIC, TIFF, GIF, APNG, PSD, 카메라 RAW 등 이미지를 변환해요.\n품질·크기·DPI는 프리셋대로 맞춰요.", "engine.pdfium.description": "PDF 페이지를 PNG, JPG, TIFF(여러 페이지 TIFF·GIF 포함)로 프리셋 해상도에 맞춰 그려요.", - "engine.pdfsharp.description": "이미지로 PDF를 만들고, PDF를 합치거나 나누거나 원하는 페이지만 잘라내요.", + "engine.pdfsharp.description": "이미지로 PDF를 만들고, PDF를 합치거나 나누거나 원하는 페이지만 잘라내요.\nPDF 속 이미지를 줄여 파일을 작게 만들기도 해요.", "engine.rhwp.description": "HWP/HWPX를 PDF로 그리고, HWP ↔ HWPX를 바꿔요.\nHWPX를 거쳐 PDF가 되는 문서(DOCX, XLSX, PPTX, 전자책 등)도 그려요.", "engine.unhwp.description": "HWP와 HWPX에서 텍스트, 마크다운, HTML을 뽑아내요.", "engine.markdown.description": "마크다운 → HTML, 텍스트.", @@ -75,6 +85,7 @@ "engine.hwpx-writer.description": "워드, 엑셀, 파워포인트, PDF, HTML, 전자책, 마크다운을 HWPX로 써요.\n오피스 없이 문서를 PDF로 바꾸는 첫 단계예요.", "engine.docx-writer.description": "PDF, HWP/HWPX, 마크다운, HTML, 스프레드시트, 슬라이드, 전자책으로 워드 문서(DOCX)를 만들어요.", "engine.pdf-text.description": "PDF의 글자를 읽는 순서대로 뽑아내요.\n2단 편집도 순서대로 읽어요(스캔 이미지 글자 인식은 안 해요).", + "engine.pdf-slides.description": "PDF를 파워포인트 슬라이드로 바꿔요. 페이지마다 그림 한 장으로 슬라이드에 넣어서 PDF와 똑같이 보여요(편집은 안 돼요).\n페이지 글자는 슬라이드 노트에 넣어요.", "engine.email.description": "이메일(EML, Outlook MSG)을 보낸 사람·받는 사람·날짜와 본문 그림까지 담아 HTML·텍스트로 바꾸고, 첨부 파일은 ZIP으로 모아요.\nMSG는 EML로도 바꿔요.", "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML, CSV를 서로 바꿔요.\nvCard 연락처를 CSV로, CSV를 vCard로 바꿔요.\n주석은 빠져요. CSV는 표 모양 데이터만 담아요.", "engine.vector.description": "SVG·SVGZ를 벡터 PDF, 이미지, DXF로 바꾸고 그림을 SVG로 벡터화해요. PDF 내용이 들어 있는 일러스트레이터 파일도 변환해요.", @@ -127,12 +138,16 @@ "preset.flip_vertical.short": "상하", "preset.merge_pdf": "PDF 병합", "preset.split_pdf": "PDF 분할", + "preset.compress_pdf": "PDF 압축", "preset.long_image": "긴 이미지", + "preset.pdf_slides": "PPTX (슬라이드)", "preset.extract": "압축 풀기", "preset.zip_all": "ZIP 하나로", "preset.extract_subtitles": "자막 추출", "preset.trace_svg": "벡터화", "preset.trace_line_art": "흑백 선화", + "preset.extract_frames_png": "프레임 추출 (PNG)", + "preset.extract_frames_jpg": "프레임 추출 (JPG)", "format.folder": "폴더", "format.category.Image": "이미지", @@ -227,6 +242,7 @@ "triggers.hook_running": "전역 입력을 감지하고 있어요.", "triggers.hook_stopped": "전역 입력 감지가 멈춰 있어요.\n파일 관리자 메뉴와 드롭 영역은 사용할 수 있어요.", "triggers.hook_failed": "전역 입력을 감지하지 못했어요: {0}", + "triggers.hook_linux_backend": "X11 입력 라이브러리를 불러오지 못해서 제스처를 시작할 수 없어요.\nX11 세션을 사용하고 libxtst6과 libxt6을 설치한 뒤(Ubuntu/Debian: sudo apt install libxtst6 libxt6) 다시 시도해 주세요.\n파일 관리자 메뉴와 드롭 영역은 계속 사용할 수 있어요.", "triggers.add": "제스처 추가", "triggers.remove": "삭제", "triggers.enabled": "사용", @@ -361,6 +377,10 @@ "presets.webp_lossless": "무손실 WEBP", "presets.multipage_tiff": "모든 파일을 여러 페이지짜리 TIFF 하나로 합치기", "presets.multipage_tiff_hint": "이미지 한 장, PDF·TIFF 한 페이지가 순서대로 한 페이지씩 들어가요.\nPDF 하나만 넣어도 여러 페이지 TIFF가 돼요.", + "presets.save_every_frame": "동영상의 모든 프레임 저장하기", + "presets.save_every_frame_hint": "동영상에서 정지 이미지 한 장 대신 프레임마다 이미지를 만들어 폴더 하나에 담아요.\n긴 동영상은 파일이 아주 많이 생겨요.", + "presets.frame_interval": "N번째 프레임마다", + "presets.frame_estimate": "30fps 동영상 1분이면 이미지 {0}장이 생겨요. 동영상 하나에 최대 {1}장까지예요.", "presets.ico_sizes": "아이콘 크기", "presets.group.pdf": "PDF", "presets.merge": "모든 파일을 하나의 PDF로 합치기", @@ -373,6 +393,15 @@ "presets.page_range": "페이지", "presets.page_range_hint": "전체, 또는 예: 1-3, 5", "presets.split": "페이지마다 파일 하나", + "presets.pdf_compression": "압축", + "presets.pdf_compression.None": "압축 안 함", + "presets.pdf_compression.Screen": "가장 작게 (이미지 72 dpi)", + "presets.pdf_compression.Ebook": "균형 (이미지 150 dpi)", + "presets.pdf_compression.Print": "고화질 (이미지 300 dpi)", + "presets.pdf_compression_hint": "이미지를 줄이고 JPEG로 저장해요.\n글자, 글꼴, 도형은 그대로예요.\n결과가 더 작지 않으면 원본을 그대로 둬요.", + "presets.pdf_ghostscript": "Ghostscript로도 해 보기 (설치된 경우)", + "presets.pdf_ghostscript_hint": "더 작아질 때가 많지만 파일 전체를 다시 써요.\n가장 작은 결과를 남겨요.", + "presets.pdf_keep_metadata": "페이지·이미지 메타데이터(XMP) 유지", "presets.combine_pages_hint": "모든 페이지를 위아래로 이어 이미지 한 장으로 만들어요.\n너무 길면 형식의 최대 높이에 맞게 줄여요(JPG 65,500px, WebP 16,383px).", "presets.combine_pages": "모든 페이지를 세로로 이어 한 장으로 (PDF를 변환할 때)", "presets.trim_page_margins": "페이지마다 위아래 빈 여백 잘라내기", @@ -410,6 +439,8 @@ "presets.video_codec.vp9": "VP9", "presets.video_codec.hevc": "HEVC / H.265 (더 작게)", "presets.video_codec.av1": "AV1 (가장 작게, 느림)", + "presets.hardware_encoding": "하드웨어 가속 사용 (그래픽 칩)", + "presets.hardware_encoding_hint": "H.264와 HEVC를 그래픽 칩으로 인코딩해요.\n훨씬 빠르고 전력도 덜 써요.\n같은 용량이면 화질이 조금 낮아요.\n쓸 수 있는 칩이 없으면 평소처럼 인코딩해요.", "presets.audio_codec": "오디오 코덱", "presets.audio_codec.aac": "AAC", "presets.audio_codec.alac": "ALAC (무손실)", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index d6f4f5b8..1d885b09 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -25,6 +25,13 @@ "home.drop_hint_macos": "或在访达中按住 {0} 拖动文件,环形工具栏会在光标处打开。", "home.drop_hint_linux": "或在文件管理器中按住 {0} 拖动文件,环形工具栏会在光标处打开。", "home.accessibility_needed": "要按住 ⌥ Option 拖动打开环形工具栏,请在“辅助功能”中允许 Filee。\n打开后立即生效。", + "home.elevated": "Filee 正以管理员身份运行。\n因此无法从资源管理器拖放文件,也无法使用右键菜单。\n快捷键也无法读取资源管理器中选中的文件。\n请以非管理员身份重新启动 Filee。", + "home.elevated_restart": "以非管理员身份重新启动", + "home.elevated_restart_failed": "Filee 无法自行重新启动。\n请从托盘退出 Filee,然后再次启动。", + "elevation.notice_title": "Filee 正以管理员身份运行", + "elevation.notice_text": "此时无法从资源管理器拖放文件,也无法使用右键菜单。\n请以非管理员身份重新启动。", + "startup.elevated_other": "另一个 Filee 正以管理员身份运行。\n资源管理器无法把文件交给它。\n请从托盘退出 Filee,然后再次启动。", + "startup.no_answer": "Filee 正在运行,但没有响应。\n如果它以管理员身份运行,请从托盘退出,然后再次启动。", "home.recent": "最近的转换", "home.recent_empty": "转换后的文件会显示在这里。", "home.open_folder": "打开文件夹", @@ -58,7 +65,10 @@ "error.unsupported_source": "不支持此文件类型", "error.no_route": "没有引擎可以完成此转换", "error.cannot_turn": "只能旋转和翻转图片", + "error.too_many_frames": "帧数太多,请调大“每 N 帧”", + "error.not_enough_space": "磁盘空间不足,无法保存这些图片", "error.conversion_failed": "转换引擎出错", + "notice.pdf_not_smaller": "无法再缩小,已原样复制", "engine.reason.not_installed": "未安装", "engine.reason.error": "检查时出错", @@ -66,7 +76,7 @@ "engine.reason.needs_package": "需要 {0},请在“设置 → 转换引擎”中安装", "engine.magick.description": "JPG、PNG、WEBP、AVIF、HEIC、TIFF、GIF、APNG、PSD、相机 RAW 等图片。\n质量、尺寸和 DPI 按预设处理。", "engine.pdfium.description": "按预设分辨率将 PDF 页面渲染为 PNG、JPG、TIFF(也支持多页 TIFF 和 GIF)。", - "engine.pdfsharp.description": "用图片生成 PDF,合并、拆分 PDF 或截取指定页码。", + "engine.pdfsharp.description": "用图片生成 PDF,合并、拆分 PDF 或截取指定页码。\n还能缩小 PDF 中的图片来减小文件。", "engine.rhwp.description": "将 HWP/HWPX 渲染为 PDF,并在 HWP ↔ HWPX 之间转换。\n经 HWPX 转为 PDF 的文档(DOCX、XLSX、PPTX、电子书等)也由它渲染。", "engine.unhwp.description": "从 HWP 和 HWPX 中提取文本、Markdown 和 HTML。", "engine.markdown.description": "Markdown → HTML 和纯文本。", @@ -75,6 +85,7 @@ "engine.hwpx-writer.description": "把 Word、Excel、PowerPoint、PDF、HTML、电子书和 Markdown 写成 HWPX。\n这是不用 Office 把文档转为 PDF 的第一步。", "engine.docx-writer.description": "用 PDF、HWP/HWPX、Markdown、HTML、表格、幻灯片和电子书生成 Word 文档(DOCX)。", "engine.pdf-text.description": "按阅读顺序提取 PDF 中的文字,双栏页面也能正确排序(不对扫描件做文字识别)。", + "engine.pdf-slides.description": "将 PDF 转换为 PowerPoint 幻灯片。每页作为一张图片放在单独的幻灯片上,外观与 PDF 相同(不可编辑),页面文字放入演讲者备注。", "engine.email.description": "将电子邮件(EML、Outlook MSG)连同发件人、收件人、日期和图片转为 HTML 和文本,附件打包为 ZIP。\nMSG 也可转为 EML。", "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSV 互相转换。\nvCard 联系人与 CSV 互转。\n注释不保留。CSV 仅支持表格数据。", "engine.vector.description": "将 SVG、SVGZ 转为矢量 PDF、图片和 DXF,并可将图片描摹为 SVG。也支持包含 PDF 内容的 Illustrator 文件。", @@ -127,12 +138,16 @@ "preset.flip_vertical.short": "垂直", "preset.merge_pdf": "合并 PDF", "preset.split_pdf": "拆分 PDF", + "preset.compress_pdf": "压缩 PDF", "preset.long_image": "长图", + "preset.pdf_slides": "PPTX(幻灯片)", "preset.extract": "解压", "preset.zip_all": "打包成一个 ZIP", "preset.extract_subtitles": "提取字幕", "preset.trace_svg": "描摹", "preset.trace_line_art": "黑白描摹", + "preset.extract_frames_png": "提取帧 (PNG)", + "preset.extract_frames_jpg": "提取帧 (JPG)", "format.folder": "文件夹", "format.category.Image": "图片", @@ -227,6 +242,7 @@ "triggers.hook_running": "正在检测全局输入。", "triggers.hook_stopped": "全局输入检测已停止。\n仍可使用文件管理器操作和拖放区域。", "triggers.hook_failed": "全局输入检测失败:{0}", + "triggers.hook_linux_backend": "无法加载 X11 输入库,手势未能启动。\n请使用 X11 会话并安装 libxtst6 和 libxt6(Ubuntu/Debian 使用 sudo apt install libxtst6 libxt6),然后重试。\n仍可使用文件管理器操作和拖放区域。", "triggers.add": "添加手势", "triggers.remove": "删除", "triggers.enabled": "启用", @@ -361,6 +377,10 @@ "presets.webp_lossless": "无损 WEBP", "presets.multipage_tiff": "将所有文件合并为一个多页 TIFF", "presets.multipage_tiff_hint": "每张图片以及 PDF、TIFF 的每一页按顺序各成一页。\n只放一个 PDF 也能得到多页 TIFF。", + "presets.save_every_frame": "保存视频的每一帧", + "presets.save_every_frame_hint": "视频的每一帧各保存为一张图片,放在一个文件夹中,而不是只保存一张静态图片。\n长视频会生成大量文件。", + "presets.frame_interval": "每 N 帧", + "presets.frame_estimate": "30 fps 的视频每分钟生成 {0} 张图片,每个视频最多 {1} 张。", "presets.ico_sizes": "图标尺寸", "presets.group.pdf": "PDF", "presets.merge": "将所有文件合并为一个 PDF", @@ -373,6 +393,15 @@ "presets.page_range": "页面", "presets.page_range_hint": "全部,或如 1-3, 5", "presets.split": "每页一个文件", + "presets.pdf_compression": "压缩", + "presets.pdf_compression.None": "不压缩", + "presets.pdf_compression.Screen": "最小(图片 72 dpi)", + "presets.pdf_compression.Ebook": "均衡(图片 150 dpi)", + "presets.pdf_compression.Print": "高质量(图片 300 dpi)", + "presets.pdf_compression_hint": "图片会缩小并存为 JPEG。\n文字、字体和图形保持不变。\n结果没有变小时保留原文件。", + "presets.pdf_ghostscript": "也试试 Ghostscript(已安装时)", + "presets.pdf_ghostscript_hint": "通常更小,但会重写整个文件。\n保留最小的结果。", + "presets.pdf_keep_metadata": "保留页面和图片元数据 (XMP)", "presets.combine_pages_hint": "把所有页面上下相接,生成一张图片。\n过长时会缩小到该格式的最大高度(JPG 65,500 像素,WebP 16,383 像素)。", "presets.combine_pages": "把所有页面上下拼成一张长图(转换 PDF 时)", "presets.trim_page_margins": "裁掉每页上下的空白", @@ -410,6 +439,8 @@ "presets.video_codec.vp9": "VP9", "presets.video_codec.hevc": "HEVC / H.265(更小)", "presets.video_codec.av1": "AV1(最小,较慢)", + "presets.hardware_encoding": "使用硬件加速(显卡)", + "presets.hardware_encoding_hint": "用显卡编码 H.264 和 HEVC。\n速度快得多,也更省电。\n相同文件大小时画质略低。\n没有可用的显卡时照常编码。", "presets.audio_codec": "音频编码", "presets.audio_codec.aac": "AAC", "presets.audio_codec.alac": "ALAC(无损)", diff --git a/src/Filee.App/Controls/SingleSelectionGuard.cs b/src/Filee.App/Controls/SingleSelectionGuard.cs new file mode 100644 index 00000000..6b48ef51 --- /dev/null +++ b/src/Filee.App/Controls/SingleSelectionGuard.cs @@ -0,0 +1,76 @@ +// Keeps combo boxes and single-selection lists from losing their selection to the toggle modifier. Avalonia treats +// Ctrl+Space, Ctrl+Enter and Ctrl+click (Cmd on macOS) on the selected item as "toggle", which leaves a combo box +// empty and pushes null into a setting that always has a value (#107). Multi-selection lists keep the toggle. +// +// A selection cleared some other way is refused by the view models (Choices.Restore); the control then shows the +// bound value again. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; + +namespace Filee.App.Controls; + +public static class SingleSelectionGuard +{ + private static bool _registered; + + /// Installs the class handlers once for every combo box and list box of the application. + public static void Register() + { + if (_registered) + return; + _registered = true; + // Tunnel: the item's own handlers (which toggle) run in the bubble phase. + InputElement.KeyDownEvent.AddClassHandler(OnKeyDown, RoutingStrategies.Tunnel); + InputElement.PointerPressedEvent.AddClassHandler(OnPointer, RoutingStrategies.Tunnel); + InputElement.PointerReleasedEvent.AddClassHandler(OnPointer, RoutingStrategies.Tunnel); + SelectingItemsControl.SelectedItemProperty.Changed.AddClassHandler(OnSelectedItemChanged); + } + + /// + /// The view model puts its value back while the control is still clearing its selection, which the control misses: + /// once it is done, it reads the bound value again (still null when the view model took the null). + /// + private static void OnSelectedItemChanged(SelectingItemsControl control, AvaloniaPropertyChangedEventArgs e) + { + if (e.NewValue is null && e.OldValue is not null && control is ComboBox or ListBox + && BindingOperations.GetBindingExpressionBase(control, SelectingItemsControl.SelectedItemProperty) is { } binding) + Dispatcher.UIThread.Post(binding.UpdateTarget); + } + + private static void OnKeyDown(ListBoxItem item, KeyEventArgs e) + { + if (ItemSelectionEventTriggers.ShouldTriggerSelection(item, e) && WouldDeselect(item, e)) + Keep(item, e); + } + + private static void OnPointer(ListBoxItem item, PointerEventArgs e) + { + if (e.GetCurrentPoint(item).Properties.PointerUpdateKind is PointerUpdateKind.LeftButtonPressed or PointerUpdateKind.LeftButtonReleased + && WouldDeselect(item, e)) + Keep(item, e); + } + + /// The selected item of a single-selection control, picked again with the toggle modifier held. + private static bool WouldDeselect(ListBoxItem item, RoutedEventArgs e) => + !e.Handled && item.IsSelected && ItemSelectionEventTriggers.HasToggleSelectionModifier(item, e) + && ItemsControl.ItemsControlFromItemContainer(item) switch + { + ComboBox => true, + ListBox list => (list.SelectionMode & (SelectionMode.Multiple | SelectionMode.Toggle)) == 0, + _ => false, + }; + + /// Picking the item that is already selected: nothing changes, an open combo box closes as after any pick. + private static void Keep(ListBoxItem item, RoutedEventArgs e) + { + e.Handled = true; + if (e.RoutedEvent != InputElement.PointerPressedEvent && ItemsControl.ItemsControlFromItemContainer(item) is ComboBox combo) + combo.SetCurrentValue(ComboBox.IsDropDownOpenProperty, false); + } +} diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs index 45d9e23c..bcd7fa63 100644 --- a/src/Filee.App/Program.cs +++ b/src/Filee.App/Program.cs @@ -1,6 +1,8 @@ // Entry point. Order matters: +// 0. The log and the handlers for unhandled exceptions, so even a start that fails leaves a line; then one line +// describing this launch (version, path, rights, OS). // 1. Single instance: a second launch (e.g. from the Explorer context menu) forwards its arguments -// to the running instance and exits. +// to the running instance and exits. When it can't (the running Filee is elevated, or hung) it says so. // 2. Installer commands (--quit, --uninstall-cleanup) run without any window. // 3. Start Avalonia. The app lives in the tray, so it only exits via the tray "Quit" command (or --quit). @@ -9,6 +11,10 @@ using Avalonia.Controls; using Avalonia.Media; using Filee.App.Services; +using Filee.Core.Platform; +using Filee.Core.Settings; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Filee.App; @@ -26,20 +32,67 @@ internal static class Program /// How long a later launch waits for a Filee that is still starting to take its arguments. private static readonly TimeSpan ForwardTimeout = TimeSpan.FromSeconds(30); + /// Integrity levels of this process and Explorer, read at start-up (Unknown off Windows). + public static ElevationState Elevation { get; private set; } = ElevationState.Unknown; + + /// Program's own log, set up before anything else so even a failed start leaves a line. + internal static ILogger Log { get; private set; } = NullLogger.Instance; + + /// Raised on the UI thread when the single-instance lock was taken again after a failed restart. + public static event Action? InstanceAcquired; + [STAThread] public static int Main(string[] args) { Options = CommandLine.Parse(args); - Instance = SingleInstance.Acquire(); - if (Options.Quit || Options.UninstallCleanup) - return RunInstallerCommand(Instance); - if (!Instance.IsFirst) + Log = StartupDiagnostics.CreateEarlyLogger(Path.Combine(UserDataStore.DefaultDirectory, "logs")); + StartupDiagnostics.LogUnhandledExceptions(Log); + Elevation = Filee.Platform.Windows.Elevation.Probe(); + var startup = StartupDiagnostics.Describe(UpdateService.CurrentVersion, Environment.ProcessPath, + StartupDiagnostics.InstallKind(UpdateService.IsInstalled, UpdateService.IsReleaseBuild), Elevation, + StartupDiagnostics.OperatingSystemText(), args.Length); + var installerCommand = Options.Quit || Options.UninstallCleanup; + + SingleInstance instance; + try { - Instance.ForwardToFirst(args, ForwardTimeout); - Instance.Dispose(); - return 0; + instance = SingleInstance.Acquire(); + } + catch (UnauthorizedAccessException ex) + { + // The lock belongs to a Filee running as administrator: this process may not even open it. Before this + // was caught, Filee ended here without a word (Explorer's menu showed a busy cursor, then nothing). + Log.LogWarning("{Startup}", startup); + Log.LogWarning(ex, "Another Filee is running with administrator rights; this launch can't reach it"); + if (!installerCommand) + TellUserOnce("startup.elevated_other"); + return 1; + } + Instance = instance; + if (installerCommand) + { + Log.LogInformation("{Startup}", startup); + return RunInstallerCommand(instance); + } + if (!instance.IsFirst) + { + var result = instance.Forward(args, ForwardTimeout); + if (result != ForwardResult.Delivered) + { + // Logged only when it failed: Explorer starts one Filee per selected file. + Log.LogWarning("{Startup}", startup); + Log.LogWarning("Could not hand {Count} argument(s) to the running Filee: {Result}", args.Length, result); + TellUserOnce(result == ForwardResult.AccessDenied ? "startup.elevated_other" : "startup.no_answer"); + } + ReleaseInstance(); + return result == ForwardResult.Delivered ? 0 : 1; } + Log.LogInformation("{Startup}", startup); + if (Elevation.ShellCannotReachFilee) + Log.LogWarning("Filee runs as administrator ({Elevation}) while Explorer doesn't: Explorer can't drop files on it, " + + "use its menu or have its selection read", Elevation); + try { return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown); @@ -55,7 +108,66 @@ public static int Main(string[] args) } finally { - Instance.Dispose(); + ReleaseInstance(); + } + } + + /// Lets go of the single-instance lock (at exit, or before restarting without administrator rights). + public static void ReleaseInstance() + { + Instance?.Dispose(); + Instance = null; + } + + /// Takes the lock back after a restart that didn't happen, so later launches find this Filee again. + public static void ReacquireInstance() + { + if (Instance is not null) + return; + try + { + var instance = SingleInstance.Acquire(); + if (!instance.IsFirst) + { + // Another Filee started meanwhile (perhaps the restart did happen after all): it is the running one. + Log.LogWarning("Another Filee took the single-instance lock during the restart"); + instance.Dispose(); + return; + } + Instance = instance; + InstanceAcquired?.Invoke(instance); + } + catch (UnauthorizedAccessException ex) + { + Log.LogWarning(ex, "Could not take the single-instance lock back"); + } + } + + /// + /// Shows a launch problem in a system message box (Filee's own windows aren't available in this process). Explorer + /// starts one Filee per selected file: a lock shared between them lets only the first one show it. + /// + private static void TellUserOnce(string key) + { + if (!OperatingSystem.IsWindows()) + return; + try + { + using var shown = new Mutex(initiallyOwned: true, @"Local\" + SingleInstance.DefaultName + "-notice", out var createdNew); + if (!createdNew) + return; + try + { + Filee.Platform.Windows.Elevation.ShowWarning(LocalizationService.EarlyText(key)); + } + finally + { + shown.ReleaseMutex(); + } + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or WaitHandleCannotBeOpenedException) + { + Log.LogWarning(ex, "Could not show the launch problem"); } } diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index 5b03168f..b8878730 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -44,10 +44,11 @@ public static IServiceProvider Build(string? dataDirectory = null) services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Conversion engines services.AddSingleton(_ => new EngineEnvironment(dataDir)); - services.AddSingleton(sp => EngineRegistry.CreateAll(sp.GetRequiredService())); + services.AddSingleton(sp => EngineRegistry.CreateAll(sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(sp => new ConverterCatalog(sp.GetRequiredService>())); services.AddSingleton(sp => new JobQueue( sp.GetRequiredService(), diff --git a/src/Filee.App/Services/ElevationService.cs b/src/Filee.App/Services/ElevationService.cs new file mode 100644 index 00000000..33291e8c --- /dev/null +++ b/src/Filee.App/Services/ElevationService.cs @@ -0,0 +1,81 @@ +// Filee running as administrator while Explorer doesn't (see ElevationState): the Home page shows why gestures, drops +// and the Explorer menu do nothing, and offers to restart Filee with Explorer's rights. The restart lets go of the +// single-instance lock first, so the new Filee becomes the running one instead of handing its arguments to this one +// (which it couldn't, being refused by an elevated process anyway), then quits this Filee. + +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Platform; +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +/// Elevation state of this run and the restart without administrator rights. +public sealed partial class ElevationService : ObservableObject +{ + private readonly ILogger _log; + private readonly Func _relaunch; + private readonly Action _releaseInstance; + private readonly Action _reacquireInstance; + private readonly Action _shutdown; + + /// The app's service: the state Program read at start-up, the real relaunch and shutdown. + public ElevationService(ILogger log) : this(log, Program.Elevation, RelaunchThroughShell, + Program.ReleaseInstance, Program.ReacquireInstance, App.Quit) + { + } + + /// Starts the executable with Explorer's rights; returns null when started, else why not. + /// Lets go of the single-instance lock. + /// Takes the lock back when the restart failed. + /// Ends this Filee. + internal ElevationService(ILogger log, ElevationState state, Func relaunch, + Action releaseInstance, Action reacquireInstance, Action shutdown) + { + _log = log; + State = state; + _relaunch = relaunch; + _releaseInstance = releaseInstance; + _reacquireInstance = reacquireInstance; + _shutdown = shutdown; + } + + public ElevationState State { get; } + + /// Explorer can't drop files on Filee, use its menu, or have its selection read: show the warning. + public bool ShellCannotReachFilee => State.ShellCannotReachFilee; + + /// The last restart attempt failed (the Home page says so). + [ObservableProperty] private bool _restartFailed; + + /// + /// Starts Filee again through Explorer and quits this one. Returns false (and sets ) + /// when Explorer couldn't start it; this Filee then keeps running as before. + /// + public bool RestartWithoutAdministratorRights(string? executable = null) + { + executable ??= Environment.ProcessPath; + if (string.IsNullOrEmpty(executable)) + { + _log.LogWarning("Restart without administrator rights: the path of Filee.exe is unknown"); + RestartFailed = true; + return false; + } + + _log.LogInformation("Restarting without administrator rights through Explorer"); + _releaseInstance(); + var error = _relaunch(executable); + if (error is null) + { + RestartFailed = false; + _shutdown(); + return true; + } + _log.LogWarning("Restart without administrator rights failed: {Error}", error); + _reacquireInstance(); + RestartFailed = true; + return false; + } + + private static string? RelaunchThroughShell(string executable) => + Filee.Platform.Windows.Elevation.RelaunchThroughShell(executable, "", out var error) ? null : error ?? "unknown error"; +} diff --git a/src/Filee.App/Services/FileLogger.cs b/src/Filee.App/Services/FileLogger.cs index 4de17498..6a26a98e 100644 --- a/src/Filee.App/Services/FileLogger.cs +++ b/src/Filee.App/Services/FileLogger.cs @@ -6,8 +6,9 @@ namespace Filee.App.Services; public sealed class FileLoggerProvider : ILoggerProvider { + // Shared by every provider: Program's early logger and AppHost's write to the same file. + private static readonly Lock WriteLock = new(); private readonly string _directory; - private readonly object _lock = new(); public FileLoggerProvider(string directory) { @@ -16,7 +17,7 @@ public FileLoggerProvider(string directory) foreach (var old in Directory.EnumerateFiles(directory, "filee-*.log") .Where(f => File.GetLastWriteTime(f) < DateTime.Now.AddDays(-7))) { - try { File.Delete(old); } catch (IOException) { } + try { File.Delete(old); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } } } @@ -26,13 +27,13 @@ public void Dispose() { } private void Write(string line) { - lock (_lock) + lock (WriteLock) { try { File.AppendAllText(Path.Combine(_directory, $"filee-{DateTime.Now:yyyyMMdd}.log"), line + Environment.NewLine); } - catch (IOException) { } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } } } diff --git a/src/Filee.App/Services/LocalizationService.cs b/src/Filee.App/Services/LocalizationService.cs index f09685a0..9d1dbf83 100644 --- a/src/Filee.App/Services/LocalizationService.cs +++ b/src/Filee.App/Services/LocalizationService.cs @@ -100,13 +100,35 @@ public static string Resolve(string setting) return "en"; } - /// Reads a translation file. Missing files yield an empty dictionary (English is used instead). - internal static Dictionary LoadStrings(string code) + /// + /// Reads a translation file. Missing files yield an empty dictionary (English is used instead). + /// reads assets before Avalonia is set up (a message shown by Program). + /// + internal static Dictionary LoadStrings(string code, IAssetLoader? loader = null) { var uri = new Uri($"avares://Filee/Assets/i18n/{code}.json"); - if (!AssetLoader.Exists(uri)) + if (!(loader?.Exists(uri) ?? AssetLoader.Exists(uri))) return []; - using var stream = AssetLoader.Open(uri); + using var stream = loader?.Open(uri) ?? AssetLoader.Open(uri); return JsonSerializer.Deserialize(stream, AppJsonContext.Default.DictionaryStringString) ?? []; } + + /// + /// One string in the system's language, before Avalonia (and the settings) are loaded: for the messages a launch + /// shows when it can't reach the running Filee. Falls back to English, then to the key. + /// + internal static string EarlyText(string key) + { + try + { + var loader = new StandardAssetLoader(typeof(LocalizationService).Assembly); + var code = Resolve("auto"); + if (LoadStrings(code, loader).TryGetValue(key, out var text) || LoadStrings("en", loader).TryGetValue(key, out text)) + return text; + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException or ArgumentException) + { + } + return key; + } } diff --git a/src/Filee.App/Services/LogThrottle.cs b/src/Filee.App/Services/LogThrottle.cs new file mode 100644 index 00000000..d539fecb --- /dev/null +++ b/src/Filee.App/Services/LogThrottle.cs @@ -0,0 +1,37 @@ +// Keeps diagnostics that can come in bursts to a few log lines: Explorer starts one Filee per selected file, and a +// gesture or shortcut can repeat many times a minute. The first event of a kind is logged, then at most one per +// interval, saying how many were held back in between. + +namespace Filee.App.Services; + +/// Rate limit for log lines, per kind of event. Thread-safe. +internal sealed class LogThrottle(TimeSpan interval, TimeProvider? time = null) +{ + private readonly TimeProvider _time = time ?? TimeProvider.System; + private readonly Dictionary _kinds = new(StringComparer.Ordinal); + private readonly Lock _lock = new(); + + /// + /// True when an event of kind may be logged now. is how many of + /// that kind were held back since the last one logged (0 when it returns false). + /// + public bool TryEnter(string kind, out int held) + { + lock (_lock) + { + var now = _time.GetUtcNow(); + if (_kinds.TryGetValue(kind, out var entry) && now - entry.Last < interval) + { + _kinds[kind] = (entry.Last, entry.Held + 1); + held = 0; + return false; + } + held = entry.Held; + _kinds[kind] = (now, 0); + return true; + } + } + + /// " (3 more since the last one)", or "" when none were held back. + public static string HeldText(int held) => held > 0 ? $" ({held} more since the last one)" : ""; +} diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index d3ffc447..742786eb 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -35,10 +35,11 @@ bool Possible(RoutePlanner planner) => formats.Any(f => var target = TargetFor(f); if (merge && f.Id == "pdf" && !turns) return true; // merged as it is + var frames = preset.SavesFramesOf(f.Id); if (!turns) - return planner.CanConvert(f.Id, target); + return planner.CanConvert(f.Id, target, frames); // Rotate / flip: a route only counts when an engine on it turns images (not a PDF or a video copied). - if (planner.Plan(f.Id, target) is not { } route) + if (planner.Plan(f.Id, target, frames) is not { } route) return false; if (route.FirstTransformStep() >= 0) return true; diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 202d9bc4..789759aa 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -46,6 +46,10 @@ public sealed class RadialController( private JobViewModel? _lastRingJob; // for "click the ✓ to open the folder" private IDisposable? _handOffTimer; private int _dragGeneration; + private bool _dragReachedDonut; + + /// A gesture over something that isn't a file drag (a rubber-band selection) is common: a line now and then. + private static readonly LogThrottle Throttle = new(TimeSpan.FromMinutes(1)); /// /// How long a drag that never brought files may keep the invisible window up, in case the button release is @@ -69,6 +73,7 @@ public void ShowForDrag(int x, int y) // A ring from an earlier conversion (finished or handed to the toast) must not keep this gesture's window up. window.DonutControl.LeaveProgressMode(); _dropped = false; + _dragReachedDonut = false; viewModel.ResetForDrag(); window.DonutControl.AllowContextEdit = false; PlaceAndShow(window, x, y, activate: false); @@ -101,6 +106,12 @@ public void OnDragGestureEnded() { if (!_visible || viewModel.IsClickMode) return; + // No drag ever entered the donut's window: nothing was dragged (a rubber-band selection), or Windows kept + // Explorer's drag out (Filee runs as administrator). Tells "the donut didn't react" reports apart. + if (!_dragReachedDonut && Throttle.TryEnter("no-drag-enter", out var held)) + log.LogInformation("Drag gesture ended without a drag entering the donut{Elevation}{Held}", + Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator: Windows blocks drops from Explorer)" : "", + LogThrottle.HeldText(held)); // Nothing was brought to the donut: the window is invisible but catches clicks, so go right away. if (!viewModel.HasFiles && !_window!.DonutControl.IsInProgressMode) { @@ -154,6 +165,7 @@ private RadialWindow EnsureWindow() var window = new RadialWindow { DataContext = viewModel }; var donut = window.DonutControl; + window.DragEntered += () => _dragReachedDonut = true; window.FilesEntered += files => { if (viewModel.IsClickMode || SameFiles(files, viewModel.Files)) diff --git a/src/Filee.App/Services/SingleInstance.cs b/src/Filee.App/Services/SingleInstance.cs index 64285de3..4f1c1247 100644 --- a/src/Filee.App/Services/SingleInstance.cs +++ b/src/Filee.App/Services/SingleInstance.cs @@ -10,7 +10,7 @@ namespace Filee.App.Services; public sealed class SingleInstance : IDisposable { // string.GetHashCode() is randomized per process, so a stable hash is required for both processes to agree. - private static readonly string DefaultName = "Filee-" + Convert.ToHexString( + internal static readonly string DefaultName = "Filee-" + Convert.ToHexString( System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Environment.UserName)))[..12]; private readonly Mutex _mutex; private readonly string _name; @@ -67,6 +67,19 @@ public void StartListening() { // A client disconnected half-way; keep serving. } + catch (UnauthorizedAccessException) + { + // The pipe name is still held by a Filee with other rights (one that just restarted this one + // without administrator rights and is closing): try again once it is gone. + try + { + await Task.Delay(500, _cts.Token); + } + catch (OperationCanceledException) + { + return; + } + } } }); } @@ -75,11 +88,19 @@ public void StartListening() /// Sends this process's arguments to the first instance. It only listens once its services are up, which can take /// over ten seconds on a slow or busy computer: a second launch (or the installer's --quit) right after the first /// lost its arguments after one 3-second try. Tries again until has passed; false when the - /// first instance never answered (it is shutting down, or hung). + /// first instance never answered (it is shutting down, or hung) or refused this process. + /// + public bool ForwardToFirst(string[] args, TimeSpan wait) => Forward(args, wait) == ForwardResult.Delivered; + + /// + /// Like , telling why it failed. A pipe that keeps refusing access belongs to a Filee + /// running with other rights (as administrator, while this one isn't): that never changes, so it gives up after a + /// few tries instead of waiting the whole time. /// - public bool ForwardToFirst(string[] args, TimeSpan wait) + public ForwardResult Forward(string[] args, TimeSpan wait) { var deadline = DateTime.UtcNow + wait; + var refusals = 0; while (true) { var left = deadline - DateTime.UtcNow; @@ -89,17 +110,29 @@ public bool ForwardToFirst(string[] args, TimeSpan wait) client.Connect((int)Math.Clamp(left.TotalMilliseconds, 100, 1000)); Filee.Platform.Windows.Foreground.AllowOtherProcess(); // its donut takes the focus (Esc, click away) JsonSerializer.Serialize(client, args, AppJsonContext.Default.StringArray); - return true; + return ForwardResult.Delivered; + } + catch (UnauthorizedAccessException) + { + // Also thrown for a moment while the pipe is being recreated between two launches: try a few times. + if (++refusals >= RefusalsBeforeGivingUp) + return ForwardResult.AccessDenied; + if (DateTime.UtcNow >= deadline) + return ForwardResult.NoAnswer; + Thread.Sleep(100); } - catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) + catch (Exception ex) when (ex is IOException or TimeoutException) { if (DateTime.UtcNow >= deadline) - return false; + return ForwardResult.NoAnswer; Thread.Sleep(100); // the pipe is busy with another launch, or not there yet } } } + /// Access-denied answers in a row after which stops trying. + private const int RefusalsBeforeGivingUp = 5; + /// /// Waits until the first instance has exited (it releases the mutex then), e.g. after forwarding --quit. /// Returns false on timeout. @@ -121,8 +154,12 @@ public bool WaitForFirstToExit(TimeSpan timeout) return true; } + /// Stops listening and releases the mutex. Safe to call twice (a restart releases it before shutting down). public void Dispose() { + if (_disposed) + return; + _disposed = true; _cts.Cancel(); if (IsFirst) { @@ -134,4 +171,19 @@ public void Dispose() } _mutex.Dispose(); } + + private bool _disposed; +} + +/// Outcome of . +public enum ForwardResult +{ + /// The running Filee has the arguments. + Delivered, + + /// Nobody took them in time: the running Filee is starting, shutting down or hung. + NoAnswer, + + /// The running Filee's pipe refuses this process: it runs with other rights (as administrator). + AccessDenied, } diff --git a/src/Filee.App/Services/StartupDiagnostics.cs b/src/Filee.App/Services/StartupDiagnostics.cs new file mode 100644 index 00000000..aa6df551 --- /dev/null +++ b/src/Filee.App/Services/StartupDiagnostics.cs @@ -0,0 +1,56 @@ +// What Filee writes to its log before and around start-up: one line describing this launch, and every exception that +// would otherwise end the process (or vanish in a task) without a trace. A report of a Filee that ignored every +// gesture came with an empty log; these lines are what tells such cases apart. + +using System.Runtime.InteropServices; +using Filee.Core.Platform; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.App.Services; + +internal static class StartupDiagnostics +{ + /// + /// A logger for Program, before the service container exists. It writes to the same daily file. A log folder that + /// can't be created gives a logger that drops everything: logging must never stop Filee from starting. + /// + public static ILogger CreateEarlyLogger(string logDirectory) + { + try + { + return new FileLoggerProvider(logDirectory).CreateLogger("Filee.App.Program"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return NullLogger.Instance; + } + } + + /// + /// The start-up line: "Filee 1.11.0 starting: C:\…\Filee.exe (installed), integrity Medium, Explorer Medium, + /// Microsoft Windows 10.0.26200 X64, 2 argument(s)". Arguments are counted, not written: they are file paths. + /// + public static string Describe(string version, string? executable, string installKind, ElevationState elevation, + string operatingSystem, int argumentCount) => + $"Filee {version} starting: {executable ?? "(unknown path)"} ({installKind}), {elevation}, {operatingSystem}, " + + $"{argumentCount} argument(s)"; + + /// How this copy got here: Setup, a release archive, or a build from source. + public static string InstallKind(bool installed, bool release) => + installed ? "installed" : release ? "portable" : "development build"; + + /// "Microsoft Windows 10.0.26200 X64". + public static string OperatingSystemText() => + $"{RuntimeInformation.OSDescription.Trim()} {RuntimeInformation.OSArchitecture}"; + + /// Writes exceptions from any thread and unobserved task failures to . + public static void LogUnhandledExceptions(ILogger log) + { + AppDomain.CurrentDomain.UnhandledException += (_, e) => + log.LogCritical(e.ExceptionObject as Exception, "Unhandled exception (process ending: {Terminating}): {Object}", + e.IsTerminating, e.ExceptionObject is Exception ? "see below" : e.ExceptionObject); + TaskScheduler.UnobservedTaskException += (_, e) => + log.LogError(e.Exception, "A background task failed and nobody looked at the error"); + } +} diff --git a/src/Filee.App/Services/Triggers/TriggerService.cs b/src/Filee.App/Services/Triggers/TriggerService.cs index 0f3ea557..b438ce3b 100644 --- a/src/Filee.App/Services/Triggers/TriggerService.cs +++ b/src/Filee.App/Services/Triggers/TriggerService.cs @@ -54,6 +54,13 @@ public TriggerService(IPlatformServices platform, ILogger log) public bool IsRunning => _hook?.IsRunning == true; public string? Error { get; private set; } + + /// + /// Linux: libuiohook could not load its X11 back end (libuiohook-xrecord.so), usually a missing X library + /// (libXtst, libXt) or not an X11 session. keeps the raw message. + /// + public bool LinuxBackendMissing { get; private set; } + public event EventHandler? StatusChanged; private void NotifyStatus() => Post(() => StatusChanged?.Invoke(this, EventArgs.Empty)); @@ -87,6 +94,7 @@ public void Start() NotifyStatus(); } Error = null; + LinuxBackendMissing = false; try { // Background threads: a hook that fails to stop can never keep Filee running after it quits. @@ -108,8 +116,7 @@ public void Start() // e.g. macOS without Accessibility permission. Drop zone and context menu keep working. if (t.Exception is not null) { - Error = t.Exception.GetBaseException().Message; - _log.LogError(t.Exception, "Global input hook failed; gestures are unavailable"); + ReportFailure(t.Exception.GetBaseException(), "Global input hook failed; gestures are unavailable"); NotifyStatus(); } }, TaskScheduler.Default); @@ -117,14 +124,26 @@ public void Start() catch (Exception ex) { // e.g. macOS without Accessibility permission. The app keeps working via the drop zone / context menu. - _log.LogError(ex, "Could not start the global input hook"); - Error = ex.Message; + ReportFailure(ex, "Could not start the global input hook"); _hook?.Dispose(); _hook = null; NotifyStatus(); } } + private void ReportFailure(Exception exception, string message) + { + // The raw libuiohook result goes to the log either way (the packaged-app smoke test looks for it). + var result = (exception as HookException)?.Result.ToString() ?? "none"; + _log.LogError(exception, "{Message} (libuiohook result: {Result})", message, result); + LinuxBackendMissing = OperatingSystem.IsLinux() && IsMissingLinuxBackend(exception); + Error = exception.Message; + } + + /// libuiohook's loader could not dlopen its Linux back end (ErrorLinuxLoadBackend). + internal static bool IsMissingLinuxBackend(Exception exception) => + exception is HookException { Result: UioHookResult.ErrorLinuxLoadBackend }; + private void OnMove(MouseHookEventArgs e) { LastCursor = (e.Data.X, e.Data.Y); diff --git a/src/Filee.App/Services/UiWatchdog.cs b/src/Filee.App/Services/UiWatchdog.cs new file mode 100644 index 00000000..8d397c18 --- /dev/null +++ b/src/Filee.App/Services/UiWatchdog.cs @@ -0,0 +1,86 @@ +// Notices when the UI thread stops answering. A report said every gesture, shortcut and drop did nothing while the log +// stayed empty; a line saying the UI thread was stuck (and for how long) tells a hang from events that never arrived. +// A background timer posts a ping to the UI thread every second. While a ping waits longer than the threshold, one +// warning is written for that episode; when the ping finally runs, one line says how long the UI thread was gone. + +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +/// Logs UI thread stalls longer than a threshold, once per episode. +internal sealed class UiWatchdog : IDisposable +{ + /// How often the UI thread is pinged. + public static readonly TimeSpan Interval = TimeSpan.FromSeconds(1); + + /// Stalls shorter than this aren't worth a line (a big folder list, a first window). + public static readonly TimeSpan DefaultThreshold = TimeSpan.FromSeconds(5); + + private readonly Action _post; + private readonly ILogger _log; + private readonly TimeProvider _time; + private readonly TimeSpan _threshold; + private readonly Lock _lock = new(); + private DateTimeOffset? _pingSentAt; + private bool _reported; + private ITimer? _timer; + + /// Queues an action on the UI thread (Dispatcher.UIThread.Post in the app). + public UiWatchdog(Action post, ILogger log, TimeProvider? time = null, TimeSpan? threshold = null) + { + _post = post; + _log = log; + _time = time ?? TimeProvider.System; + _threshold = threshold ?? DefaultThreshold; + } + + /// Starts pinging every on a thread-pool timer. + public void Start() => _timer ??= _time.CreateTimer(_ => Tick(), null, Interval, Interval); + + /// One timer tick: sends a ping, or checks how long the last one has been waiting. + internal void Tick() + { + lock (_lock) + { + if (_pingSentAt is { } sent) + { + var waited = _time.GetUtcNow() - sent; + if (!_reported && waited > _threshold) + { + _reported = true; + _log.LogWarning("UI thread blocked for {Seconds:0} s", waited.TotalSeconds); + } + return; + } + _pingSentAt = _time.GetUtcNow(); + } + try + { + _post(Pong); + } + catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException) + { + // The dispatcher is shutting down: nothing left to watch. + lock (_lock) + _pingSentAt = null; + } + } + + /// Runs on the UI thread when it got to the ping. + private void Pong() + { + lock (_lock) + { + if (_reported && _pingSentAt is { } sent) + _log.LogInformation("UI thread responsive again after {Seconds:0.0} s", (_time.GetUtcNow() - sent).TotalSeconds); + _pingSentAt = null; + _reported = false; + } + } + + public void Dispose() + { + _timer?.Dispose(); + _timer = null; + } +} diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 1c23eec7..be501452 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -130,6 +130,17 @@ public void ShowFeedbackNotice() _feedbackNotice.Show(); } + /// Shows the "Filee is running as administrator" card in the bottom-right corner (once per run). + public void ShowElevationNotice() + { + if (_elevationNoticeShown) + return; + _elevationNoticeShown = true; + new ElevationNoticeWindow(services.GetRequiredService()).Show(); + } + + private bool _elevationNoticeShown; + /// Opens the preset popup. Returns true when the user saved (the preset object was updated). public async Task EditPresetAsync(Preset preset, Window? owner) { diff --git a/src/Filee.App/ViewModels/JobViewModel.cs b/src/Filee.App/ViewModels/JobViewModel.cs index 3044df29..f7f9f72c 100644 --- a/src/Filee.App/ViewModels/JobViewModel.cs +++ b/src/Filee.App/ViewModels/JobViewModel.cs @@ -41,6 +41,10 @@ public JobViewModel(ConversionJob job, ILocalizer loc, IPlatformServices platfor [ObservableProperty] private string? _errorDetails; [ObservableProperty] private bool _canRetry; + /// Notes on files that converted (e.g. a PDF that couldn't be made smaller and was copied), one per line. + [ObservableProperty] private string? _notices; + [ObservableProperty] private bool _hasNotices; + /// Source files that failed. public IEnumerable FailedSources => Job.Files.Where(f => f.State == FileState.Failed).Select(f => f.SourcePath); @@ -61,6 +65,11 @@ public void Refresh() failed.Select(f => $"{Path.GetFileName(f.SourcePath)} — {FailureText.Reason(_loc, f.ErrorKey, f.ErrorDetail)}")); CanRetry = _retry is not null && !IsRunning && failed.Count > 0; + var noted = Job.Files.Where(f => f.State == FileState.Done && f.NoticeKey is not null).ToList(); + HasNotices = noted.Count > 0; + Notices = noted.Count == 0 ? null : string.Join(Environment.NewLine, + noted.Select(f => $"{Path.GetFileName(f.SourcePath)} — {_loc[f.NoticeKey!]}")); + Status = Job.State switch { JobState.Completed when Job.Files.All(f => f.State == FileState.Skipped) => _loc["toast.skipped"], diff --git a/src/Filee.App/ViewModels/MainWindowViewModel.cs b/src/Filee.App/ViewModels/MainWindowViewModel.cs index c64dcc4f..629d9a81 100644 --- a/src/Filee.App/ViewModels/MainWindowViewModel.cs +++ b/src/Filee.App/ViewModels/MainWindowViewModel.cs @@ -109,8 +109,13 @@ public void Navigate(string key) SelectedItem = item; } - // Pages are recreated on every visit so they always reflect the latest settings. - partial void OnSelectedItemChanged(NavItem value) => CurrentPage = CreatePage(value); + // Pages are recreated on every visit so they always reflect the latest settings. A navigation list that loses its + // selection keeps the open page (Choices.Restore). + partial void OnSelectedItemChanged(NavItem? oldValue, NavItem newValue) + { + if (!Choices.Restore(oldValue, newValue, v => SelectedItem = v)) + CurrentPage = CreatePage(newValue); + } // Old pages may hold event subscriptions on long-lived services. partial void OnCurrentPageChanged(ObservableObject? oldValue, ObservableObject newValue) => (oldValue as IDisposable)?.Dispose(); diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs index cd03b1fe..623d5b34 100644 --- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs @@ -65,7 +65,11 @@ public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServic public bool ShowDefaultSubfolder => DefaultLocation.Value == OutputLocation.Subfolder; public bool ShowDefaultFolder => DefaultLocation.Value == OutputLocation.CustomFolder; - partial void OnDefaultLocationChanged(Choice value) => Save(s => s.DefaultOutput.Location = value.Value); + partial void OnDefaultLocationChanged(Choice? oldValue, Choice newValue) + { + if (!Choices.Restore(oldValue, newValue, v => DefaultLocation = v)) + Save(s => s.DefaultOutput.Location = newValue.Value); + } partial void OnDefaultSubfolderChanged(string value) => Save(s => s.DefaultOutput.SubfolderName = string.IsNullOrWhiteSpace(value) ? "converted" : value.Trim()); @@ -96,7 +100,11 @@ partial void OnDefaultSubfolderChanged(string value) => public bool CanAddModernMenu => !ModernMenuBusy && ModernMenuState is ModernContextMenuState.Off or ModernContextMenuState.Outdated; public bool CanRemoveModernMenu => !ModernMenuBusy && ModernMenuState is ModernContextMenuState.On or ModernContextMenuState.Outdated; - partial void OnLanguageChanged(Choice value) => Save(s => s.Language = value.Value); + partial void OnLanguageChanged(Choice? oldValue, Choice newValue) + { + if (!Choices.Restore(oldValue, newValue, v => Language = v)) + Save(s => s.Language = newValue.Value); + } partial void OnStartWithSystemChanged(bool value) => Save(s => s.StartWithSystem = value); partial void OnContextMenuChanged(bool value) diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs index 274e7c6d..189d3d26 100644 --- a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs @@ -83,17 +83,21 @@ public sealed partial class HomePageViewModel : ObservableObject, IDisposable private readonly IPlatformServices _platform; private readonly Services.Triggers.TriggerService? _triggers; private readonly ConversionService? _conversions; + private readonly ElevationService? _elevation; public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, Services.Triggers.TriggerService? triggers = null, - ConversionService? conversions = null) + ConversionService? conversions = null, ElevationService? elevation = null) { _store = store; _loc = loc; _platform = platform; _triggers = triggers; _conversions = conversions; + _elevation = elevation; if (triggers is not null) triggers.StatusChanged += OnTriggerStatusChanged; + if (elevation is not null) + elevation.PropertyChanged += OnElevationChanged; var drag = store.Settings.Triggers.FirstOrDefault(t => t.Enabled && t.Kind == TriggerKind.Drag); DropHint = loc.Format(OperatingSystem.IsMacOS() ? "home.drop_hint_macos" : OperatingSystem.IsLinux() ? "home.drop_hint_linux" : "home.drop_hint", drag is null ? "—" : GestureText.Modifiers(drag.Modifiers)); store.HistoryChanged += OnHistoryChanged; @@ -107,10 +111,27 @@ public void Dispose() _store.HistoryChanged -= OnHistoryChanged; if (_triggers is not null) _triggers.StatusChanged -= OnTriggerStatusChanged; + if (_elevation is not null) + _elevation.PropertyChanged -= OnElevationChanged; } private void OnTriggerStatusChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(NeedsAccessibility)); + private void OnElevationChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => + OnPropertyChanged(nameof(ElevationRestartFailed)); + + /// + /// Windows: Filee runs as administrator while Explorer doesn't, so drops, the Explorer menu and the shortcut can't + /// reach it (the warning above the drop zone). + /// + public bool IsElevated => _elevation?.ShellCannotReachFilee == true; + + /// The restart button didn't work: the warning says what to do instead. + public bool ElevationRestartFailed => _elevation?.RestartFailed == true; + + [RelayCommand] + private void RestartWithoutAdministratorRights() => _elevation?.RestartWithoutAdministratorRights(); + /// macOS: gestures wait for the Accessibility permission (the reminder above the drop zone). public bool NeedsAccessibility => _triggers?.NeedsPermission == true; diff --git a/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs b/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs index c93ecbd4..badc7bc3 100644 --- a/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs @@ -51,7 +51,11 @@ public ThemePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices [ObservableProperty] private double _cornerRadius; [ObservableProperty] private bool _reduceMotion; - partial void OnModeChanged(Choice value) => Save(t => t.Mode = value.Value); + partial void OnModeChanged(Choice? oldValue, Choice newValue) + { + if (!Choices.Restore(oldValue, newValue, v => Mode = v)) + Save(t => t.Mode = newValue.Value); + } partial void OnAccentChanged(Color value) { var hex = $"#{value.R:X2}{value.G:X2}{value.B:X2}"; diff --git a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs index cf1c8bd2..b5ad96d5 100644 --- a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs @@ -85,6 +85,11 @@ public TriggerGestureViewModel(TriggerGesture gesture, ILocalizer loc, Action sa public string ThresholdText => DragThreshold == 0 ? SystemLabel : $"{DragThreshold}px"; public string Summary => GestureText.Describe(_gesture); + // Runs before OnPropertyChanged below, which reads every choice. + partial void OnKindChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Kind = v); + partial void OnButtonChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Button = v); + partial void OnScopeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Scope = v); + protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e) { base.OnPropertyChanged(e); @@ -172,9 +177,16 @@ LinuxPlatformServices when OperatingSystem.IsLinux() && LinuxPlatformServices.Is LinuxPlatformServices => _loc["triggers.linux_x11"], _ => null, }; - public string? HookStatus => _triggers?.Error is { } error ? _loc.Format("triggers.hook_failed", error) + public string? HookStatus => _triggers?.Error is { } error ? HookFailure(_loc, error, _triggers.LinuxBackendMissing) : _triggers is null ? null : _loc[_triggers.IsRunning ? "triggers.hook_running" : "triggers.hook_stopped"]; + /// + /// A Linux back end that doesn't load is explained (X11 session, X libraries) instead of showing libuiohook's + /// result code; the code is in the log. + /// + internal static string HookFailure(ILocalizer loc, string error, bool linuxBackendMissing) => + linuxBackendMissing ? loc["triggers.hook_linux_backend"] : loc.Format("triggers.hook_failed", error); + private void OnTriggerStatusChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(HookStatus)); private void OnTriggerSettingsChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(IntegrationError)); diff --git a/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs index 8e60dd37..e32635cd 100644 --- a/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs @@ -333,10 +333,19 @@ internal void RefreshResults() partial void OnEnabledChanged(bool value) => Change(r => r.Enabled = value); partial void OnFolderChanged(string value) => Change(r => r.Folder = value.Trim()); - partial void OnPresetChanged(Choice? value) => Change(r => r.PresetId = value?.Value ?? ""); + // Null only while no preset was found for the rule (deleted); the combo box clearing itself doesn't forget the preset. + partial void OnPresetChanged(Choice? oldValue, Choice? newValue) + { + if (!Choices.Restore(oldValue, newValue, v => Preset = v)) + Change(r => r.PresetId = newValue?.Value ?? ""); + } partial void OnIncludeSubfoldersChanged(bool value) => Change(r => r.IncludeSubfolders = value); partial void OnOutputFolderChanged(string value) => Change(r => r.OutputFolder = value.Trim()); - partial void OnOriginalsChanged(Choice value) => Change(r => r.Originals = value.Value); + partial void OnOriginalsChanged(Choice? oldValue, Choice newValue) + { + if (!Choices.Restore(oldValue, newValue, v => Originals = v)) + Change(r => r.Originals = newValue.Value); + } partial void OnIncludeTextChanged(string value) { diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 45382072..f3c2423e 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -25,6 +25,23 @@ public sealed record Choice(T Value, string Label, string? Detail = null) : I public override string ToString() => Label; } +/// Selections bound to properties that always hold a value (a choice, the open page). +public static class Choices +{ + /// + /// For the OnXxxChanged(oldValue, newValue) hook of such a property. A combo box or list box sets null when it + /// loses its selection (the selected item toggled off, its items replaced): the previous value is put back through + /// , which also shows it in the control again. Returns true then, so the hook stops. + /// + public static bool Restore(T? oldValue, T? newValue, Action restore) where T : class + { + if (newValue is not null || oldValue is null) + return false; + restore(oldValue); + return true; + } +} + public sealed partial class PresetEditorViewModel : ObservableObject { private static readonly HashSet LossyTargets = ["jpg", "webp", "avif", "heic"]; @@ -63,6 +80,7 @@ .. FormatRegistry.Known Rotations = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.rotation.{v}"])).ToList(); Flips = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.flip.{v}"])).ToList(); PageSizes = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.page_size.{v}"])).ToList(); + CompressionLevels = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.pdf_compression.{v}"])).ToList(); Locations = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.location.{v}"])).ToList(); Conflicts = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.conflict.{v}"])).ToList(); TiffCompressions = Enum.GetValues().Select(v => new Choice(v, v.ToString().ToUpperInvariant())).ToList(); @@ -82,6 +100,8 @@ .. FormatRegistry.Known _rotation = Rotations.FirstOrDefault(r => r.Value == _edit.Image.Rotation) ?? Rotations[0]; _flip = Flips.FirstOrDefault(f => f.Value == _edit.Image.Flip) ?? Flips[0]; _pageSize = PageSizes.First(p => p.Value == _edit.Pdf.PageSize); + _compressionLevel = CompressionLevels.FirstOrDefault(c => c.Value == _edit.Pdf.Compression) ?? CompressionLevels[0]; + _compressWithGhostscript = _edit.Pdf.CompressWithGhostscript; _location = Locations.First(l => l.Value == _edit.Output.Location); _conflict = Conflicts.First(c => c.Value == _edit.Output.Conflict); _tiffCompression = TiffCompressions.First(t => t.Value == _edit.Image.TiffCompression); @@ -94,8 +114,11 @@ .. FormatRegistry.Known _wavFormat = WavFormats.First(f => f.Value == _edit.Media.WavFormat); _videoCodecValue = _edit.Media.VideoCodec; _audioCodecValue = _edit.Media.AudioCodec; + _hardwareEncoding = _edit.Media.HardwareEncoding; _archiveLevel = ArchiveLevels.First(l => l.Value == _edit.Archive.Level); _combineIntoOne = _edit.Archive.CombineIntoOne; + _saveEveryFrame = _edit.Media.SaveEveryFrame; + _frameInterval = _edit.Media.FrameInterval > 0 ? _edit.Media.FrameInterval : 1; _traceMode = TraceModes.First(m => m.Value == _edit.Trace.Mode); _traceColors = Math.Clamp(_edit.Trace.Colors, TracingOptions.MinColors, TracingOptions.MaxColors); _traceDetail = Math.Clamp(_edit.Trace.Detail, 0, 100); @@ -139,6 +162,9 @@ .. FormatRegistry.Known public IReadOnlyList> Rotations { get; } public IReadOnlyList> Flips { get; } public IReadOnlyList> PageSizes { get; } + + /// PDF → PDF: don't compress, or the level (pictures at 72, 150 or 300 dpi). + public IReadOnlyList> CompressionLevels { get; } public IReadOnlyList> Locations { get; } public IReadOnlyList> Conflicts { get; } public IReadOnlyList> TiffCompressions { get; } @@ -157,10 +183,12 @@ .. FormatRegistry.Known [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowImage), nameof(ShowTurnForPdf), nameof(ShowQuality), nameof(ShowTiff), nameof(ShowMultiPageTiff), nameof(ShowWebp), nameof(ShowIco), nameof(ShowPdf), nameof(ShowPdfBuild), nameof(ShowDocument), nameof(ShowPdfSplit), nameof(ShowPdfCombine), + nameof(ShowPdfCompression), nameof(ShowPdfCompressionOptions), nameof(ShowMedia), nameof(ShowMediaQuality), nameof(ShowMaxHeight), nameof(ShowAudioBitrate), nameof(ShowRemoveAudio), - nameof(ShowAudioChannels), nameof(ShowWav), nameof(VideoCodecs), nameof(VideoCodecChoice), nameof(ShowVideoCodec), + nameof(ShowAudioChannels), nameof(ShowWav), nameof(VideoCodecs), nameof(VideoCodecChoice), nameof(ShowVideoCodec), nameof(ShowHardwareEncoding), nameof(AudioCodecs), nameof(AudioCodecChoice), nameof(ShowAudioCodec), - nameof(ShowArchive), nameof(ShowArchiveOptions), nameof(ShowExtractNote), nameof(ShowTrace))] + nameof(ShowArchive), nameof(ShowArchiveOptions), nameof(ShowExtractNote), + nameof(ShowSaveEveryFrame), nameof(ShowTrace))] private Choice _target; private VideoCodec _videoCodecValue; @@ -181,11 +209,18 @@ public Choice VideoCodecChoice get => VideoCodecs.FirstOrDefault(c => c.Value == _videoCodecValue) ?? VideoCodecs[0]; set { - // The combo box clears its selection while its list is replaced. - if (value is null || value.Value == _videoCodecValue) + // The combo box clears its selection while its list is replaced (or the selected item is toggled off): + // announcing the value shows it again. + if (value is null) + { + OnPropertyChanged(); + return; + } + if (value.Value == _videoCodecValue) return; _videoCodecValue = value.Value; OnPropertyChanged(); + OnPropertyChanged(nameof(ShowHardwareEncoding)); } } @@ -199,7 +234,12 @@ public Choice AudioCodecChoice get => AudioCodecs.FirstOrDefault(c => c.Value == _audioCodecValue) ?? AudioCodecs[0]; set { - if (value is null || value.Value == _audioCodecValue) + if (value is null) + { + OnPropertyChanged(); + return; + } + if (value.Value == _audioCodecValue) return; _audioCodecValue = value.Value; OnPropertyChanged(); @@ -265,6 +305,11 @@ public bool ColorProfileChecked [ObservableProperty] private string _pageRange; [ObservableProperty] private bool _splitPages; + [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowPdfCompressionOptions))] + private Choice _compressionLevel; + + [ObservableProperty] private bool _compressWithGhostscript; + /// PDF → image: one tall image of all pages. [ObservableProperty] private bool _combinePages; [ObservableProperty] private bool _trimPageMargins; @@ -281,8 +326,19 @@ public bool ColorProfileChecked [ObservableProperty] private Choice _sampleRate; [ObservableProperty] private Choice _wavFormat; [ObservableProperty] private bool _removeAudio; + + /// Encode H.264 / HEVC on the graphics chip when one works (MediaOptions.HardwareEncoding). + [ObservableProperty] private bool _hardwareEncoding; [ObservableProperty] private Choice _archiveLevel; [ObservableProperty] private bool _combineIntoOne; + [ObservableProperty] private bool _saveEveryFrame; + + [ObservableProperty, NotifyPropertyChangedFor(nameof(FrameEstimate))] + private int _frameInterval = 1; + + /// How many pictures a minute of 30 fps video gives at the chosen interval, and the limit per video. + public string FrameEstimate => _loc.Format("presets.frame_estimate", + (int)Math.Ceiling(1800.0 / Math.Clamp(FrameInterval, 1, 1000)), MediaOptions.MaxFrames.ToString("N0", System.Globalization.CultureInfo.InvariantCulture)); [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowTraceColors))] private Choice _traceMode; @@ -290,6 +346,25 @@ public bool ColorProfileChecked [ObservableProperty] private int _traceColors; [ObservableProperty] private int _traceDetail; + // A combo box that loses its selection sets null: every choice keeps its value (see Choices.Restore). + partial void OnTargetChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Target = v); + partial void OnResizeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Resize = v); + partial void OnRotationChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Rotation = v); + partial void OnFlipChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Flip = v); + partial void OnPageSizeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => PageSize = v); + partial void OnLocationChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Location = v); + partial void OnConflictChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Conflict = v); + partial void OnTiffCompressionChanged(Choice? oldValue, Choice newValue) => + Choices.Restore(oldValue, newValue, v => TiffCompression = v); + partial void OnMediaQualityChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => MediaQuality = v); + partial void OnMaxHeightChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => MaxHeight = v); + partial void OnAudioBitrateChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => AudioBitrate = v); + partial void OnAudioChannelsChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => AudioChannels = v); + partial void OnSampleRateChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => SampleRate = v); + partial void OnWavFormatChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => WavFormat = v); + partial void OnArchiveLevelChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => ArchiveLevel = v); + partial void OnTraceModeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => TraceMode = v); + private string TargetId => Target.Value; private FormatCategory? TargetCategory => FormatRegistry.FindById(TargetId)?.Category; private bool TargetIsImage => TargetId == BuiltInData.SameAsSource || TargetCategory == FormatCategory.Image; @@ -314,6 +389,10 @@ public bool ColorProfileChecked public bool ShowPdfBuild => TargetId == "pdf"; public bool ShowPdfSplit => TargetId == "pdf"; + /// Compression is for PDF → PDF; Ghostscript and metadata only matter while it is on. + public bool ShowPdfCompression => TargetId == "pdf"; + public bool ShowPdfCompressionOptions => ShowPdfCompression && CompressionLevel.Value != PdfCompression.None; + /// PDF → a single-image format: the pages can be stacked into one tall image (TIFF, GIF, APNG and MNG keep pages). public bool ShowPdfCombine => TargetCategory == FormatCategory.Image && TargetId is not ("tiff" or "gif" or "apng" or "mng" or "ico" or "cur" or "icns" or "svg" or "pdf"); @@ -325,6 +404,8 @@ public bool ColorProfileChecked public bool ShowWav => TargetId is "wav" or "w64"; /// MP4, MKV, MOV and WebM can be written with HEVC or AV1 besides their usual codec. public bool ShowVideoCodec => TargetIsVideo && VideoCodecs.Count > 1; + /// Hardware encoding applies to H.264 and HEVC only (not VP9, AV1, MPEG-2, …). + public bool ShowHardwareEncoding => TargetIsVideo && MediaCodecs.TakesHardwareEncoding(TargetId, _videoCodecValue); /// M4A / M4B can hold Apple Lossless besides AAC. public bool ShowAudioCodec => AudioCodecs.Count > 1; public bool ShowMediaQuality => TargetIsLossyMedia; @@ -335,6 +416,8 @@ public bool ColorProfileChecked public bool ShowArchiveOptions => ShowArchive && TargetId != FormatRegistry.Folder; /// "Extract" has nothing to set; a line says what it does instead. public bool ShowExtractNote => TargetId == FormatRegistry.Folder; + /// "Save every frame" of a video: PNG and JPG (format ids, ). + public bool ShowSaveEveryFrame => MediaOptions.FrameTargets.Contains(TargetId); /// /// Tracing options: raster images reach SVG (and DXF / DWG through it) only by tracing. For other sources (a /// drawing, a CAD file) the options are ignored. @@ -401,6 +484,8 @@ public bool Apply() pdf.CombinePages = CombinePages; pdf.TrimPageMargins = TrimPageMargins; pdf.SplitTallImage = SplitTallImage; + pdf.Compression = CompressionLevel.Value; + pdf.CompressWithGhostscript = CompressWithGhostscript; _original.Document.PdfA = PdfA; var media = _original.Media; @@ -413,6 +498,10 @@ public bool Apply() media.WavFormat = WavFormat.Value; media.VideoCodec = MediaCodecs.Effective(TargetId, _videoCodecValue); media.AudioCodec = MediaCodecs.Effective(TargetId, _audioCodecValue); + media.HardwareEncoding = HardwareEncoding; + // Only where the option is shown: a box ticked for PNG and left behind by another target saves nothing. + media.SaveEveryFrame = ShowSaveEveryFrame && SaveEveryFrame; + media.FrameInterval = media.SaveEveryFrame ? Math.Clamp(FrameInterval, 1, 1000) : 1; _original.Archive.Level = ArchiveLevel.Value; _original.Archive.CombineIntoOne = CombineIntoOne; _original.Trace.Mode = TraceMode.Value; diff --git a/src/Filee.App/ViewModels/RenameToolViewModel.cs b/src/Filee.App/ViewModels/RenameToolViewModel.cs index c26b5d32..adc373aa 100644 --- a/src/Filee.App/ViewModels/RenameToolViewModel.cs +++ b/src/Filee.App/ViewModels/RenameToolViewModel.cs @@ -187,9 +187,11 @@ public RenameStepViewModel(RenameStep step, RenameToolViewModel tool) _ => "watch.ph.replace", }); - partial void OnKindChanged(Choice value) + partial void OnKindChanged(Choice? oldValue, Choice newValue) { - Step.Kind = value.Value; + if (Choices.Restore(oldValue, newValue, v => Kind = v)) + return; + Step.Kind = newValue.Value; OnPropertyChanged(nameof(ShowFind)); OnPropertyChanged(nameof(ShowReplace)); OnPropertyChanged(nameof(ShowArrow)); diff --git a/src/Filee.App/Views/ElevationNoticeWindow.axaml b/src/Filee.App/Views/ElevationNoticeWindow.axaml new file mode 100644 index 00000000..6eaa795a --- /dev/null +++ b/src/Filee.App/Views/ElevationNoticeWindow.axaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + private static int _dragEnterLogged; + public HomePage() { InitializeComponent(); DragDrop.AddDragEnterHandler(DropZone, (_, e) => { DropZone.Classes.Add("over"); - e.DragEffects = e.DataTransfer.Contains(DataFormat.File) ? DragDropEffects.Copy : DragDropEffects.None; + var files = e.DataTransfer.Contains(DataFormat.File); + e.DragEffects = files ? DragDropEffects.Copy : DragDropEffects.None; + if (Interlocked.Exchange(ref _dragEnterLogged, 1) == 0) + AppHost.Get>().LogInformation("First drag over the drop zone (files: {Files})", files); }); DragDrop.AddDragOverHandler(DropZone, (_, e) => e.DragEffects = e.DataTransfer.Contains(DataFormat.File) ? DragDropEffects.Copy : DragDropEffects.None); diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml index 01686024..cebecfbd 100644 --- a/src/Filee.App/Views/PresetEditorView.axaml +++ b/src/Filee.App/Views/PresetEditorView.axaml @@ -115,6 +115,17 @@ + + + + + + + + + + @@ -163,6 +174,10 @@ + + + + @@ -216,6 +231,21 @@ + + + + + + + + + + + + + + + diff --git a/src/Filee.App/Views/RadialWindow.axaml.cs b/src/Filee.App/Views/RadialWindow.axaml.cs index cf7b0407..0cfaab79 100644 --- a/src/Filee.App/Views/RadialWindow.axaml.cs +++ b/src/Filee.App/Views/RadialWindow.axaml.cs @@ -24,6 +24,9 @@ public RadialWindow() public DonutMenu DonutControl => Donut; + /// A drag entered the window, with or without files (for the log: did the drag reach the donut at all). + public event Action? DragEntered; + /// Files entered the window during a drag. public event Action>? FilesEntered; @@ -44,6 +47,7 @@ public RadialWindow() private void OnDragEnter(object? sender, DragEventArgs e) { + DragEntered?.Invoke(); var files = LocalFiles(e); if (files.Count == 0) { diff --git a/src/Filee.App/Views/ToastWindow.axaml b/src/Filee.App/Views/ToastWindow.axaml index 390c9f91..c0ac3544 100644 --- a/src/Filee.App/Views/ToastWindow.axaml +++ b/src/Filee.App/Views/ToastWindow.axaml @@ -83,6 +83,8 @@ + diff --git a/src/Filee.Cli/Cli.cs b/src/Filee.Cli/Cli.cs index 1e8c94a6..23e928ce 100644 --- a/src/Filee.Cli/Cli.cs +++ b/src/Filee.Cli/Cli.cs @@ -152,7 +152,8 @@ private static string Describe(FileResult file, ILocalizer texts) var source = Shorten(file.SourcePath); return file.State switch { - FileState.Done => $"ok {source} -> {string.Join(", ", file.Outputs.Select(Shorten))}", + FileState.Done => $"ok {source} -> {string.Join(", ", file.Outputs.Select(Shorten))}" + + (file.NoticeKey is null ? "" : $" ({texts[file.NoticeKey]})"), FileState.Skipped => $"skip {source} (the output file already exists)", FileState.Cancelled => $"stop {source} (cancelled)", _ => $"fail {source}: {Error(file, texts)}", @@ -195,6 +196,8 @@ private static void WriteJson(ConversionJob job, ILocalizer texts, TextWriter ou json.WriteEndArray(); if (file.State is FileState.Failed or FileState.Cancelled) json.WriteString("error", Error(file, texts)); + else if (file.NoticeKey is not null) + json.WriteString("notice", texts[file.NoticeKey]); json.WriteEndObject(); } json.WriteEndArray(); diff --git a/src/Filee.Core/Conversion/ConversionJob.cs b/src/Filee.Core/Conversion/ConversionJob.cs index 7ef352c3..fcd1f06c 100644 --- a/src/Filee.Core/Conversion/ConversionJob.cs +++ b/src/Filee.Core/Conversion/ConversionJob.cs @@ -37,6 +37,12 @@ public sealed class FileResult /// Raw error detail for logs / tooltips. public string? ErrorDetail { get; set; } + + /// + /// Localization key of a note on a file that converted (e.g. notice.pdf_not_smaller: compressing saved + /// nothing, so the PDF was copied as it is), if any. + /// + public string? NoticeKey { get; set; } } /// A batch of files converted with one preset. diff --git a/src/Filee.Core/Conversion/ConverterCatalog.cs b/src/Filee.Core/Conversion/ConverterCatalog.cs index 584765fc..886923e1 100644 --- a/src/Filee.Core/Conversion/ConverterCatalog.cs +++ b/src/Filee.Core/Conversion/ConverterCatalog.cs @@ -6,7 +6,9 @@ namespace Filee.Core.Conversion; public sealed class ConverterCatalog { private readonly IReadOnlyList _all; - private Dictionary? _statusCache; + private volatile Dictionary? _statusCache; + private int _generation; + private readonly Lock _statusLock = new(); private IReadOnlyList _priority = []; public ConverterCatalog(IEnumerable converters) => _all = converters.ToList(); @@ -36,19 +38,48 @@ public IReadOnlyList Priority _all.OrderBy(c => PriorityIndex(c)).ThenBy(c => _all.ToList().IndexOf(c)).ToList(); /// Cached availability of a converter. - public EngineStatus StatusOf(IConverter converter) - { - _statusCache ??= _all.ToDictionary(c => c.Id, SafeStatus); - return _statusCache[converter.Id]; - } + public EngineStatus StatusOf(IConverter converter) => Statuses()[converter.Id]; /// Re-checks every engine (e.g. after the user changed an engine path). public void Refresh() { + Interlocked.Increment(ref _generation); _statusCache = null; Changed?.Invoke(this, EventArgs.Empty); } + /// + /// Checks every engine on a background thread, so the first conversion menu doesn't wait for them (some start a + /// program to read its version). A caller that needs a status meanwhile waits for this check instead of starting + /// its own. + /// + public Task CheckInBackgroundAsync() => Task.Run(() => Statuses()); + + /// + /// The statuses, checked once and shared between threads. A during a check makes it check + /// again, so a result from before a settings change is never kept. + /// + private Dictionary Statuses() + { + if (_statusCache is { } cached) + return cached; + lock (_statusLock) + { + while (true) + { + if (_statusCache is { } done) + return done; + var generation = Volatile.Read(ref _generation); + var statuses = _all.ToDictionary(c => c.Id, SafeStatus); + if (generation == Volatile.Read(ref _generation)) + { + _statusCache = statuses; + return statuses; + } + } + } + } + /// Creates a planner over the currently available converters. public RoutePlanner CreatePlanner() => new(_all.Where(c => StatusOf(c).IsAvailable), c => Math.Min(PriorityIndex(c), 9)); diff --git a/src/Filee.Core/Conversion/IConverter.cs b/src/Filee.Core/Conversion/IConverter.cs index aac27e41..fbaee33f 100644 --- a/src/Filee.Core/Conversion/IConverter.cs +++ b/src/Filee.Core/Conversion/IConverter.cs @@ -22,8 +22,33 @@ namespace Filee.Core.Conversion; /// The opposite: never used in routes that start from a raster image. PDF → SVG through LibreOffice keeps a PDF's /// vectors, but a photo that became a PDF would only come back as a picture inside an SVG, so images are traced. /// +/// +/// The edge is a route of its own only: the file being converted is a file and +/// is what the user asked for. PDF → PPTX puts pictures of the pages on slides; a Word document +/// rendered to PDF must not become pictures on slides, and a picture deck must not be a step towards ODP or PPSX. +/// +/// +/// The edge writes every frame of a video as numbered pictures into a folder (). +/// It is never part of an ordinary route (a video → JPG stays one still frame), and with +/// asking for frames it is the whole route. +/// public readonly record struct ConversionEdge( - string From, string To, int Cost = 10, bool ImageSourcesOnly = false, bool NotForImageSources = false); + string From, string To, int Cost = 10, bool ImageSourcesOnly = false, bool NotForImageSources = false, + bool DirectOnly = false, bool FrameSequence = false); + +/// +/// A conversion an engine refuses with a reason the user can act on, shown in their language (the file's +/// ) instead of "The engine reported an error". +/// +/// Localization key of the reason, e.g. error.too_many_frames. +/// Non-localized detail shown after it in brackets (numbers, a name), or null. +public sealed class ConversionRefusedException(string errorKey, string? detail = null) + : Exception(detail is null ? errorKey : $"{errorKey} ({detail})") +{ + public string ErrorKey { get; } = errorKey; + + public string? Detail { get; } = detail; +} /// Whether an engine can currently be used. /// True if conversions can run. @@ -58,13 +83,22 @@ public interface IOutputAllocator /// Options chosen by the user. /// Where to write results. /// Private scratch directory for this job; deleted afterwards. +/// +/// The step takes a edge: every frame of the video becomes a picture of +/// format in one output folder. +/// +/// Records a note on a step that worked, as a localization key (e.g. notice.pdf_not_smaller): shown with the +/// file's result. Null when nobody listens. +/// public sealed record ConversionStep( string InputPath, string From, string To, Preset Preset, IOutputAllocator Output, - string WorkDirectory); + string WorkDirectory, + bool FrameSequence = false, + Action? Notice = null); /// /// A conversion engine (ImageMagick, LibreOffice, rhwp, ...). diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index 590f3337..dbf0fbe7 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -331,7 +331,9 @@ private async Task> RunFileAsync( : job.Preset.TargetFormat == BuiltInData.SameAsSource ? source.Id : job.Preset.TargetFormat; - var route = planner.Plan(source.Id, target); + // "Save every frame": a video becomes a folder of pictures in one step of its own. Never for an intermediate + // file, and never on the way to another format (video → JPG is otherwise one still frame). + var route = planner.Plan(source.Id, target, frames: finalOutput && job.Preset.SavesFramesOf(source.Id)); if (route is null) return Fail(file, "error.no_route", $"{source.Id} → {target}"); @@ -378,7 +380,8 @@ private async Task> RunFileAsync( // Turned once, in step turnAt: every other step gets the preset without rotation and flip, // also one before it whose engine applies image options but doesn't say it turns. var preset = s == turnAt ? job.Preset : unturned; - var stepInfo = new ConversionStep(input, step.From, step.To, preset, allocator, workDir); + var stepInfo = new ConversionStep(input, step.From, step.To, preset, allocator, workDir, step.FrameSequence, + key => file.NoticeKey = key); outputs.AddRange(await step.Converter.ConvertAsync(stepInfo, progress, ct)); } finally @@ -406,6 +409,11 @@ private async Task> RunFileAsync( Notify(job); return []; } + catch (ConversionRefusedException ex) + { + _log.LogInformation("Converting {File} was refused: {Reason}", file.SourcePath, ex.Message); + return Fail(file, ex.ErrorKey, ex.Detail); + } catch (Exception ex) { _log.LogWarning(ex, "Converting {File} failed", file.SourcePath); diff --git a/src/Filee.Core/Conversion/RoutePlanner.cs b/src/Filee.Core/Conversion/RoutePlanner.cs index dcec4a72..c9c6a7f4 100644 --- a/src/Filee.Core/Conversion/RoutePlanner.cs +++ b/src/Filee.Core/Conversion/RoutePlanner.cs @@ -9,7 +9,8 @@ namespace Filee.Core.Conversion; /// One hop of a route. -public sealed record RouteStep(IConverter Converter, string From, string To); +/// The hop saves every frame of a video (). +public sealed record RouteStep(IConverter Converter, string From, string To, bool FrameSequence = false); /// A planned conversion from one format to another. public sealed record Route(IReadOnlyList Steps, int Cost) @@ -51,16 +52,32 @@ public RoutePlanner(IEnumerable converters, Func? p } /// Returns the cheapest route, or null if the conversion is impossible. - public Route? Plan(string from, string to) + /// + /// Save every frame of a video (): the route is one + /// edge from to , or none. + /// Without it those edges are never taken. + /// + public Route? Plan(string from, string to, bool frames = false) { from = from.ToLowerInvariant(); to = to.ToLowerInvariant(); + if (frames) + { + var sequence = EdgesFrom(from) + .Where(e => e.Edge.FrameSequence && e.Edge.To == to) + .OrderBy(e => e.Cost) + .FirstOrDefault(); + return sequence.Converter is null + ? null + : new Route([new RouteStep(sequence.Converter, from, to, FrameSequence: true)], sequence.Cost); + } + // Same-format "conversions" (re-encode, split, compress) must use an explicit self edge. if (from == to) { var best = EdgesFrom(from) - .Where(e => e.Edge.To == to) + .Where(e => e.Edge.To == to && !e.Edge.FrameSequence) .OrderBy(e => e.Cost) .FirstOrDefault(); return best.Converter is null @@ -89,7 +106,9 @@ public RoutePlanner(IEnumerable converters, Func? p foreach (var (converter, edge, edgeCost) in EdgesFrom(node)) { - if (edge.To == edge.From || (edge.ImageSourcesOnly && !imageSource) || (edge.NotForImageSources && imageSource)) + if (edge.To == edge.From || edge.FrameSequence + || (edge.ImageSourcesOnly && !imageSource) || (edge.NotForImageSources && imageSource) + || (edge.DirectOnly && (node != from || edge.To != to))) continue; var next = cost + edgeCost; if (dist.TryGetValue(edge.To, out var known) && known <= next) @@ -113,7 +132,8 @@ public RoutePlanner(IEnumerable converters, Func? p } /// True if can be converted to . - public bool CanConvert(string from, string to) => Plan(from, to) is not null; + /// Every frame of a video as pictures (see ). + public bool CanConvert(string from, string to, bool frames = false) => Plan(from, to, frames) is not null; private IReadOnlyList<(IConverter Converter, ConversionEdge Edge, int Cost)> EdgesFrom(string format) => _edgesFrom.TryGetValue(format, out var edges) ? edges : []; diff --git a/src/Filee.Core/Platform/ElevationState.cs b/src/Filee.Core/Platform/ElevationState.cs new file mode 100644 index 00000000..26b6a94b --- /dev/null +++ b/src/Filee.Core/Platform/ElevationState.cs @@ -0,0 +1,57 @@ +// Whether Filee runs with more rights than the desktop shell. Windows keeps a lower process (Explorer) from sending +// input to a higher one (User Interface Privilege Isolation): drops from Explorer are refused, the second Filee its +// menu starts can't open the running Filee's mutex or pipe, and the running Filee can't read Explorer's selection. +// The decision is plain logic over integrity levels, so it is tested everywhere; reading the levels is Windows code. + +namespace Filee.Core.Platform; + +/// Windows mandatory integrity levels, lowest first. +public enum IntegrityLevel +{ + /// Not known: another operating system, or the token could not be read. + Unknown, + Untrusted, + Low, + Medium, + MediumPlus, + High, + System, +} + +/// Integrity levels of this process and of the desktop shell (Explorer). +/// This process. +/// The process that owns the desktop window (Explorer), or Unknown when there is none. +/// True when this process runs with a full administrator token (UAC "Run as administrator"). +public sealed record ElevationState(IntegrityLevel Process, IntegrityLevel Shell, bool Elevated) +{ + /// Nothing known (any OS but Windows). + public static ElevationState Unknown { get; } = new(IntegrityLevel.Unknown, IntegrityLevel.Unknown, false); + + /// + /// True when Filee runs as administrator while Explorer doesn't: Explorer can't drop files on Filee or hand files + /// over through its menu. Unknown levels never count, and neither does a PC where Explorer runs elevated too + /// (UAC switched off). + /// + public bool ShellCannotReachFilee => + Process >= IntegrityLevel.High && Shell != IntegrityLevel.Unknown && Shell < Process; + + /// + /// Maps the last sub-authority of a mandatory label SID (S-1-16-rid) to a level. Values between the + /// well-known ones belong to the level below them, as Windows compares them numerically. + /// + public static IntegrityLevel FromRid(uint rid) => rid switch + { + >= 0x4000 => IntegrityLevel.System, + >= 0x3000 => IntegrityLevel.High, + >= 0x2100 => IntegrityLevel.MediumPlus, + >= 0x2000 => IntegrityLevel.Medium, + >= 0x1000 => IntegrityLevel.Low, + _ => IntegrityLevel.Untrusted, + }; + + /// For the log: "integrity High (elevated), Explorer Medium". + public override string ToString() => + Process == IntegrityLevel.Unknown && Shell == IntegrityLevel.Unknown && !Elevated + ? "integrity unknown" + : $"integrity {Process}{(Elevated ? " (elevated)" : "")}, Explorer {Shell}"; +} diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs index 7a440a48..5a1b0d9f 100644 --- a/src/Filee.Core/Platform/IPlatformServices.cs +++ b/src/Filee.Core/Platform/IPlatformServices.cs @@ -31,6 +31,12 @@ static IReadOnlyList AppNames(IEnumerable names) /// Files currently selected in the foreground file manager window (empty if none). IReadOnlyList GetFileManagerSelection(); + /// + /// For the log when a shortcut found no selected files: the foreground window's class and process + /// ("CabinetWClass / explorer"). Empty where the platform can't tell. + /// + string DescribeForegroundWindow() => ""; + /// System drag threshold in the same units as screen coordinates. int SystemDragThreshold { get; } diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs index c47d818d..7ec96870 100644 --- a/src/Filee.Core/Presets/BuiltInData.cs +++ b/src/Filee.Core/Presets/BuiltInData.cs @@ -65,6 +65,9 @@ .. ImageToolPresets(), Id = "pdf-split", NameKey = "preset.split_pdf", TargetFormat = "pdf", Pdf = { SplitPages = true }, Output = { Location = OutputLocation.Subfolder, SubfolderName = "{name}" }, }, + .. PdfCompressionPresets(), + .. PdfSlidesPresets(), + .. FramePresets(), new() { Id = "to-docx", Name = "DOCX", TargetFormat = "docx" }, new() { Id = "to-hwpx", Name = "HWPX", TargetFormat = "hwpx" }, .. HwpPresets(), @@ -81,6 +84,38 @@ .. ModelPresets(), .. TracePresets(), ]; + /// + /// "Compress PDF": pictures at 150 dpi, the rest compressed losslessly (added in settings schema 20). On no default + /// donut, the PDF donut is full: users add it in Settings → Toolbar. + /// + public static List PdfCompressionPresets() => + [ + new() + { + Id = "compress-pdf", NameKey = "preset.compress_pdf", TargetFormat = "pdf", + Pdf = { Compression = PdfCompression.Ebook }, Output = { FileNamePattern = "{name}_compressed" }, + }, + ]; + + /// + /// PDF → PowerPoint with a picture of each page on its own slide (added in settings schema 18). JPEG at 90 like the + /// JPG preset; the render DPI is the PDF default (150), which the converter never goes below. + /// + public static List PdfSlidesPresets() => + [ + new() { Id = "pdf-slides", NameKey = "preset.pdf_slides", TargetFormat = "pptx", Image = { Quality = 90 } }, + ]; + + /// + /// Every frame of a video as PNG or JPG pictures in a folder (added in settings schema 19), on no donut: a long + /// video gives thousands of files, so users add them in Settings → Toolbar. + /// + public static List FramePresets() => + [ + new() { Id = "extract-frames-png", NameKey = "preset.extract_frames_png", TargetFormat = "png", Media = { SaveEveryFrame = true } }, + new() { Id = "extract-frames-jpg", NameKey = "preset.extract_frames_jpg", TargetFormat = "jpg", Media = { SaveEveryFrame = true } }, + ]; + /// Raster → SVG tracing, in colour and as black line art (added in settings schema 16). public static List TracePresets() => [ @@ -289,7 +324,7 @@ public static List CreateProfiles() => { Id = "pdf", NameKey = "profile.pdf", Extensions = ["pdf"], - PresetIds = ["to-png", "to-jpg", "pdf-long-image", "to-docx", "to-txt", "to-md", "to-epub", "to-hwpx", "to-hwp", "to-tiff", "pdf-split", "merge-pdf"], + PresetIds = [.. PdfDonut], }, new() { @@ -316,6 +351,17 @@ .. WithSubtitleProfile(CatalogProfiles()), }, ]; + /// + /// The PDF donut. Settings schema 18 put "PPTX (slides)" after DOCX in place of TIFF (a donut holds 12 slices); TIFF + /// stays a preset to add in Settings → Toolbar. + /// + public static readonly IReadOnlyList PdfDonut = + ["to-png", "to-jpg", "pdf-long-image", "to-docx", "pdf-slides", "to-txt", "to-md", "to-epub", "to-hwpx", "to-hwp", "pdf-split", "merge-pdf"]; + + /// The PDF donut of settings schema 9 to 17, which schema 18 replaces when the user never changed it. + public static readonly IReadOnlyList PdfDonutBefore18 = + ["to-png", "to-jpg", "pdf-long-image", "to-docx", "to-txt", "to-md", "to-epub", "to-hwpx", "to-hwp", "to-tiff", "pdf-split", "merge-pdf"]; + /// Excel, OpenDocument and CSV sheets (split from "office" in settings schema 5). public static ToolbarProfile SpreadsheetProfile() => new() { diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index 72550948..305d9b27 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -120,6 +120,16 @@ public sealed class Preset [JsonIgnore] public bool IsGroup => Kind == PresetKind.Group; + /// + /// True if converting a file saves every frame as a picture + /// (): the file is a video and the target one of + /// . Other files, and other targets, convert as usual. + /// + public bool SavesFramesOf(string sourceFormat) => + Media.SaveEveryFrame + && MediaOptions.FrameTargets.Contains(TargetFormat) + && Formats.FormatRegistry.FindById(sourceFormat)?.Category == Formats.FormatCategory.Video; + /// Creates a new random preset id. public static string NewId() => Guid.NewGuid().ToString("N")[..12]; @@ -277,6 +287,22 @@ public enum PdfPageSize Letter, } +/// +/// How hard a PDF → PDF conversion shrinks the file: pictures are scaled down to the level's resolution and saved as +/// JPEG at its quality, the rest is compressed losslessly. Text, fonts and drawings stay as they are. +/// +public enum PdfCompression +{ + /// Pages are copied as they are. + None, + /// Smallest: pictures at 72 dpi (reading on screen). + Screen, + /// Balanced: pictures at 150 dpi (e-mail, uploads). + Ebook, + /// High quality: pictures at 300 dpi (printing). + Print, +} + /// PDF related options. public sealed class PdfOptions { @@ -312,6 +338,19 @@ public sealed class PdfOptions /// public bool SplitTallImage { get; set; } + /// + /// For PDF → PDF: make the file smaller (also with a page range or one file per page). A level from a newer Filee + /// reads as . + /// + [JsonConverter(typeof(LenientEnumJsonConverter))] + public PdfCompression Compression { get; set; } + + /// + /// With : also let Ghostscript rewrite the PDF when it is installed, and keep whichever + /// result is smaller. Off by default: it rewrites the whole file (fonts, structure), not only the pictures. + /// + public bool CompressWithGhostscript { get; set; } + public PdfOptions Clone() => (PdfOptions)MemberwiseClone(); } @@ -367,6 +406,33 @@ public sealed class MediaOptions /// Audio codec for targets that can hold several (). public AudioCodec AudioCodec { get; set; } = AudioCodec.Default; + /// + /// Encode H.264 and HEVC video with the graphics chip's encoder (NVENC, Quick Sync, AMF, VideoToolbox or VA-API) + /// when one works on this computer, else with x264 / x265 as usual. Much faster and lighter on power, a little + /// larger at the same quality. Off by default (and for presets saved before it existed), so results never change + /// without the user asking for it. + /// + public bool HardwareEncoding { get; set; } + + /// Picture formats a video's frames can be saved as (). + public static readonly IReadOnlyList FrameTargets = ["png", "jpg"]; + + /// + /// Video → PNG or JPG: save every frame as a picture, in a folder, instead of one still frame. Only for video + /// sources and a target in (). + /// + public bool SaveEveryFrame { get; set; } + + /// With : extract every Nth frame (1 = every frame). + public int FrameInterval { get; set; } = 1; + + /// + /// Most pictures one video may become (): about an hour at 30 fps, already more than + /// anyone looks through. A longer video needs a larger . + /// + public const long MaxFrames = 100_000; + + public MediaOptions Clone() => (MediaOptions)MemberwiseClone(); } @@ -413,6 +479,20 @@ public static class MediaCodecs public static VideoCodec Effective(string target, VideoCodec codec) => VideoCodecsFor(target).Contains(codec) ? codec : VideoCodec.Default; + /// Video targets whose usual codec is H.264 (the others have their container's own: VP9, MPEG-2, …). + public static IReadOnlySet H264Targets { get; } = new HashSet { "mp4", "m4v", "mov", "mkv", "flv", "ts", "m2ts", "3gp", "3g2" }; + + /// + /// True when with is written as H.264 or HEVC, the codecs + /// applies to. + /// + public static bool TakesHardwareEncoding(string target, VideoCodec codec) => Effective(target, codec) switch + { + VideoCodec.Hevc => true, + VideoCodec.Default => H264Targets.Contains(target), + _ => false, + }; + /// The codec is written with: the chosen one if the target takes it, else Default. public static AudioCodec Effective(string target, AudioCodec codec) => AudioCodecsFor(target).Contains(codec) ? codec : AudioCodec.Default; diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 1701b12f..ecf109a1 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -50,7 +50,7 @@ public sealed class DonutSettings public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 17; + public const int CurrentSchemaVersion = 20; public int SchemaVersion { get; set; } = CurrentSchemaVersion; @@ -89,7 +89,7 @@ public sealed class AppSettings public List EnginePriority { get; set; } = [ "magick", "pdfium", "pdfsharp", "rhwp", "unhwp", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", - "pdf-text", "email", "data", "vector", "icns", "font", "cad", "model3d", "archive", "ebook", "subtitle", "ffmpeg", + "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "archive", "ebook", "subtitle", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice", ]; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 9b0f3880..6cf958d2 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -292,6 +292,16 @@ public static void Apply(AppSettings settings) InsertBefore(settings.EnginePriority, "model3d", settings.EnginePriority.Contains("archive") ? "archive" : "pandoc"); } + // v18: built-in "pdf-slides" engine (PDF → PPTX, a picture of each page per slide), after "pdf-text". + if (settings.SchemaVersion < 18 && !settings.EnginePriority.Contains("pdf-slides")) + { + var text = settings.EnginePriority.IndexOf("pdf-text"); + if (text >= 0) + settings.EnginePriority.Insert(text + 1, "pdf-slides"); + else + InsertBefore(settings.EnginePriority, "pdf-slides", "pandoc"); + } + if (settings.Triggers.Count == 0) settings.Triggers = TriggerGesture.Defaults(); @@ -479,9 +489,70 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id == "pdf-slides"); + if (at < 0) + at = presets.FindIndex(p => p.Id == "pdf-split"); + at = at < 0 ? presets.Count : at + 1; + foreach (var preset in BuiltInData.FramePresets()) + { + if (presets.Any(p => p.Id == preset.Id)) + continue; + presets.Insert(Math.Min(at++, presets.Count), preset); + changed = true; + } + } + // v20: "Compress PDF" (PDF → a smaller PDF), after "Split PDF" as in a new library, on no donut: the PDF donut + // is full. Present already but saved since by a Filee before 20 (back and forth between versions): that + // one dropped the compression level and left a preset that copies the pages, so the level is put back. + if (from < 20) + { + foreach (var compress in BuiltInData.PdfCompressionPresets()) + { + if (presets.FirstOrDefault(p => p.Id == compress.Id) is { } existing) + { + if (existing.Pdf.Compression == PdfCompression.None && existing.TargetFormat == compress.TargetFormat) + { + existing.Pdf.Compression = compress.Pdf.Compression; + changed = true; + } + continue; + } + var split = presets.FindIndex(p => p.Id == "pdf-split"); + presets.Insert(split < 0 ? presets.Count : split + 1, compress); + changed = true; + } + } return changed; } + private static bool AddPdfSlides(List presets, List profiles) + { + var changed = false; + if (presets.All(p => p.Id != "pdf-slides")) + { + var at = presets.FindIndex(p => p.Id == "pdf-split"); + presets.Insert(at < 0 ? presets.Count : at + 1, BuiltInData.PdfSlidesPresets().Single()); + changed = true; + } + if (profiles.FirstOrDefault(p => p.Id == "pdf") is { } pdf && pdf.PresetIds.SequenceEqual(BuiltInData.PdfDonutBefore18)) + { + pdf.PresetIds = [.. BuiltInData.PdfDonut]; + return true; + } + return Offer(presets, profiles, "pdf", "pdf-slides", "to-docx") || changed; + } + private static bool AddData(List presets, List profiles) { var changed = false; diff --git a/src/Filee.Engines/EngineRegistry.cs b/src/Filee.Engines/EngineRegistry.cs index 0a2e3a42..7d82f4b0 100644 --- a/src/Filee.Engines/EngineRegistry.cs +++ b/src/Filee.Engines/EngineRegistry.cs @@ -19,6 +19,7 @@ using Filee.Engines.Subtitles; using Filee.Engines.Text; using Filee.Engines.Vector; +using Microsoft.Extensions.Logging; namespace Filee.Engines; @@ -30,7 +31,8 @@ public static class EngineRegistry /// AppSettings.EnginePriority: built-in engines first, the optional downloads last. Software installed on the /// system (Microsoft Office, a system-wide LibreOffice) is never used. /// - public static IReadOnlyList CreateAll(EngineEnvironment env) => + /// The app's log; FFmpeg writes which hardware encoders work and when one failed. + public static IReadOnlyList CreateAll(EngineEnvironment env, ILoggerFactory? loggers = null) => [ new MagickImageConverter(), new PdfSharpConverter(), @@ -47,10 +49,11 @@ public static IReadOnlyList CreateAll(EngineEnvironment env) => new FontConverter(), new DocxWriterConverter(), new PdfTextConverter(), + new PdfSlidesConverter(), new EmlConverter(), new DataConverter(), new SubtitleConverter(), - new FfmpegConverter(), + new FfmpegConverter(loggers?.CreateLogger()), new CadConverter(), new ModelConverter(), new EbookConverter(), diff --git a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs index 77db4ef3..4e28b23f 100644 --- a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs +++ b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs @@ -142,10 +142,48 @@ public static bool UsesFileesCopy(string id) => OwnCopyFolder(id) is null SystemCopyFolder(id) is { } folder ? Path.Combine(folder, ProgramName(program)) : null; /// Searches the PC again (Settings → Engines → Check again, settings changes). - public static void ForgetSystemCopies() => SystemCopies.Clear(); + public static void ForgetSystemCopies() + { + SystemCopies.Clear(); + ReachableShares.Clear(); + } private static string? FindOnSystem(string id) => - SystemFolders(id).Select(folder => FolderWithPrograms(id, folder)).FirstOrDefault(found => found is not null); + SystemFolders(id).Where(IsReachable).Select(folder => FolderWithPrograms(id, folder)).FirstOrDefault(found => found is not null); + + /// How long a network share on the PATH may take to answer before the search leaves it out. + private static readonly TimeSpan ShareTimeout = TimeSpan.FromSeconds(2); + + private static readonly ConcurrentDictionary ReachableShares = new(StringComparer.OrdinalIgnoreCase); + + /// + /// False for a folder on a network share that doesn't answer in time. A PATH entry on a server that is offline + /// (a mapped work share at home) made every file check wait for the network timeout, many seconds each, and the + /// search runs for every engine. Each share is asked once, until . + /// + private static bool IsReachable(string folder) + { + if (ShareOf(folder) is not { } share) + return true; + return ReachableShares.GetOrAdd(share, s => + { + var probe = Task.Run(() => Directory.Exists(s)); + return probe.Wait(ShareTimeout) && probe.Result; + }); + } + + /// + /// The share root of a UNC path (\\server\share), or null for a local path. Device paths (\\?\, + /// \\.\) are local. Plain string work, the same on every OS. + /// + internal static string? ShareOf(string path) + { + if (!path.StartsWith(@"\\", StringComparison.Ordinal) || path.StartsWith(@"\\?\", StringComparison.Ordinal) + || path.StartsWith(@"\\.\", StringComparison.Ordinal)) + return null; + var parts = path[2..].Split('\\', StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 2 ? $@"\\{parts[0]}\{parts[1]}" : parts.Length == 1 ? $@"\\{parts[0]}" : null; + } private static IEnumerable SystemFolders(string id) { diff --git a/src/Filee.Engines/Infrastructure/engines.json b/src/Filee.Engines/Infrastructure/engines.json index b2657805..a9bb5511 100644 --- a/src/Filee.Engines/Infrastructure/engines.json +++ b/src/Filee.Engines/Infrastructure/engines.json @@ -105,7 +105,7 @@ }, "calibre": { "version": "9.15.0", - "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0-x86_64.txz", + "url": "https://download.calibre-ebook.com/9.15.0/calibre-9.15.0-x86_64.txz", "sha256": "3F5301C0AA51E5FB2D5F6DCD04024BA4E86501AB328CE5D9D6760EFCCB887990", "size": 194998300, "kind": "tar.xz" @@ -144,7 +144,7 @@ }, "calibre": { "version": "9.15.0", - "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0-arm64.txz", + "url": "https://download.calibre-ebook.com/9.15.0/calibre-9.15.0-arm64.txz", "sha256": "15AB887F9D787809BC834CA0EA6408E981B409F0B23046C41C18DE948EB50A38", "size": 182488416, "kind": "tar.xz" @@ -176,7 +176,7 @@ }, "calibre": { "version": "9.15.0", - "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0.dmg", + "url": "https://download.calibre-ebook.com/9.15.0/calibre-9.15.0.dmg", "sha256": "1B3A7451175B73AE2BAA28FE1BC1C53EE34D65B489881E7EC02C20F9E0FF6019", "size": 350293492, "kind": "dmg" @@ -215,7 +215,7 @@ }, "calibre": { "version": "9.15.0", - "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0.dmg", + "url": "https://download.calibre-ebook.com/9.15.0/calibre-9.15.0.dmg", "sha256": "1B3A7451175B73AE2BAA28FE1BC1C53EE34D65B489881E7EC02C20F9E0FF6019", "size": 350293492, "kind": "dmg" diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs index 5bc2877a..029416ca 100644 --- a/src/Filee.Engines/Media/FfmpegConverter.cs +++ b/src/Filee.Engines/Media/FfmpegConverter.cs @@ -1,27 +1,42 @@ // Video and audio conversion with FFmpeg (GPL-3.0 build, an optional download run as a separate program in -// engines/ffmpeg): video ↔ video (H.264, HEVC, AV1, …), video → animated GIF / APNG or a still frame, audio -// extraction, subtitle extraction (the first text track as SRT / ASS / WebVTT), audio ↔ audio and GIF, APNG or a still -// picture → video, and images → HEIC (FFmpeg's HEVC encoder, Filee's own container writer). -// Container and codec choices live in MediaEncoding, the process handling in FfmpegRunner. +// engines/ffmpeg): video ↔ video (H.264, HEVC, AV1, …), video → animated GIF / APNG, a still frame or every frame +// (FrameSequenceWriter), audio extraction, subtitle extraction (the first text track as SRT / ASS / WebVTT), audio ↔ +// audio and GIF, APNG or a still picture → video, and images → HEIC (FFmpeg's HEVC encoder, Filee's own container +// writer). Container and codec choices live in MediaEncoding, the process handling in FfmpegRunner, hardware encoders +// in HardwareVideo (options) and HardwareProbe (which one works here). using System.Collections.Concurrent; -using System.Diagnostics; using System.Text.RegularExpressions; using Filee.Core.Conversion; using Filee.Core.Formats; +using Filee.Core.Presets; using Filee.Engines.Heic; using Filee.Engines.Infrastructure; using Filee.Engines.Magick; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Filee.Engines.Media; /// Converts video and audio files with Filee's own copy of FFmpeg. -public sealed partial class FfmpegConverter : IConverter, IImageTransformer +/// Receives which hardware encoders work and why a hardware encode was redone in software. +public sealed partial class FfmpegConverter(ILogger? logger = null) : IConverter, IImageTransformer { private static readonly ConcurrentDictionary Versions = new(); + /// ffmpeg -version and -encoders answer at once; a copy that does not is broken or hangs. + internal static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(10); + + private readonly ILogger _logger = logger ?? NullLogger.Instance; + private (string Ffmpeg, string Ffprobe)? _tools; + /// + /// Picks the hardware encoder for (ffmpeg, codec, 10-bit, encoders) instead of . + /// Tests put an encoder here that FFmpeg does not have, to see the software retry. + /// + internal Func?, CancellationToken, Task>? SelectHardware { get; init; } + public string Id => "ffmpeg"; public string DisplayName => "FFmpeg"; @@ -35,6 +50,7 @@ public sealed partial class FfmpegConverter : IConverter, IImageTransformer /// /// Every video format (read by FFmpeg's demuxers) → every video target, animated GIF or APNG, a still PNG frame, + /// every frame as PNG or JPG pictures (only when a preset asks for it, see ), /// every audio target and SRT / ASS / WebVTT (its subtitles); every audio format → every audio target (including /// itself: re-encode with another bitrate); GIF, APNG and PNG → MP4, WEBM, MOV; every image ImageMagick reads → /// HEIC. Only formats the pinned build can write are targets (see MediaEncoding). @@ -55,6 +71,8 @@ private static List BuildEdges() return [ .. from source in videos from target in videoTargets select new ConversionEdge(source, target), + // "Save every frame": a folder of numbered pictures, only when a preset asks for it (never in another route). + .. from source in videos from target in MediaOptions.FrameTargets select new ConversionEdge(source, target, FrameSequence: true), // Subtitles out of a video. SRT is the hub to the other subtitle formats (the subtitle engine takes it on), // so ASS and WebVTT cost a little more and are only taken when they are the target. .. from source in videos from target in MediaEncoding.SubtitleTargets.Keys @@ -95,6 +113,9 @@ public async Task> ConvertAsync(ConversionStep step, IProg var info = await MediaInfo.ProbeAsync(ffprobe, input, cancellationToken); progress?.Report(0.02); + if (step.FrameSequence) + return await FrameSequenceWriter.ConvertAsync(ffmpeg, step, input, info, progress, cancellationToken); + var output = step.Output.Allocate(FormatRegistry.Get(step.To).PrimaryExtension); if (output is null) return []; @@ -107,21 +128,28 @@ public async Task> ConvertAsync(ConversionStep step, IProg ? await CoverArt.PrepareAsync(ffmpeg, input, coverStream, step.To, step.WorkDirectory, cancellationToken) : null; + var media = step.Preset.Media; + var encoders = still ? null : EncodersOf(ffmpeg); + // Hardware encoding (preset option): the first GPU encoder that passes its test encode, if any. + HardwareEncoder? hardware = null; + if (!still && MediaEncoding.HardwareCodec(step.To, media, info) is { } codec) + { + var tenBit = HardwareVideo.TenBit(codec, info.Video!); + hardware = SelectHardware is { } select + ? await select(ffmpeg, codec, tenBit, encoders, cancellationToken) + : await HardwareProbe.SelectAsync(ffmpeg, codec, tenBit, encoders, _logger, cancellationToken); + } + + IReadOnlyList Plan(HardwareEncoder? encoder) => still + ? MediaEncoding.PlanStill(input, output, step.To, media, info) + : MediaEncoding.Plan(input, output, step.To, media, info, step.WorkDirectory, cover, encoders, encoder); // Throws for a missing stream before anything is written (an existing file of that name stays untouched). - var passes = still - ? MediaEncoding.PlanStill(input, output, step.To, step.Preset.Media, info) - : MediaEncoding.Plan(input, output, step.To, step.Preset.Media, info, step.WorkDirectory, cover, EncodersOf(ffmpeg)); + var passes = Plan(hardware); var duration = still ? MediaEncoding.StillDuration : info.Duration; try { - var done = 0.0; - foreach (var pass in passes) - { - await RunPassAsync(ffmpeg, pass, duration, progress, done, cancellationToken); - done += pass.Weight; - } - if (!File.Exists(output) || new FileInfo(output).Length == 0) - throw new InvalidOperationException("FFmpeg did not write any output."); + await RunWithFallbackAsync(passes, hardware, Plan, + planned => RunPassesAsync(ffmpeg, planned, output, duration, progress, cancellationToken), output, _logger, cancellationToken); } catch { @@ -134,8 +162,51 @@ public async Task> ConvertAsync(ConversionStep step, IProg return [output]; } + /// + /// Runs , planned with . When a hardware encode fails (a driver + /// error, a picture larger than the chip encodes, …) the file is encoded again as plans it + /// without hardware: an encoder that passed its test encode can still refuse one particular video, and the user + /// asked for a converted file, not for the GPU. The failure is logged, not reported. Cancellation is never retried. + /// + internal static async Task RunWithFallbackAsync(IReadOnlyList passes, HardwareEncoder? hardware, + Func> plan, Func, Task> run, string output, ILogger logger, + CancellationToken cancellationToken) + { + if (hardware is null) + { + await run(passes); + return; + } + try + { + await run(passes); + } + catch (Exception ex) when (ex is not OperationCanceledException && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning("{File}: hardware encoder {Encoder} failed ({Reason}); encoding it again with the software encoder", + output, hardware.Name, ex.Message); + TryDelete(output); + // An FFmpeg without x265 says so here: the hardware encoder was the only way to write HEVC. + await run(plan(null)); + } + } + + /// Runs every pass, then checks that FFmpeg wrote something. + private static async Task RunPassesAsync(string ffmpeg, IReadOnlyList passes, string output, TimeSpan? duration, + IProgress? progress, CancellationToken cancellationToken) + { + var done = 0.0; + foreach (var pass in passes) + { + await RunPassAsync(ffmpeg, pass, duration, progress, done, cancellationToken); + done += pass.Weight; + } + if (!File.Exists(output) || new FileInfo(output).Length == 0) + throw new InvalidOperationException("FFmpeg did not write any output."); + } + /// Runs one pass and maps FFmpeg's position (seconds) onto this pass' share of the progress. - private static async Task RunPassAsync(string ffmpeg, FfmpegPass pass, TimeSpan? duration, IProgress? progress, double done, CancellationToken cancellationToken) + internal static async Task RunPassAsync(string ffmpeg, FfmpegPass pass, TimeSpan? duration, IProgress? progress, double done, CancellationToken cancellationToken) { var total = duration?.TotalSeconds ?? 0; Action? onPosition = progress is null || total <= 0 @@ -165,26 +236,23 @@ internal static (string Ffmpeg, string Ffprobe)? Locate() } /// "9.0.2" from ffmpeg -version; cached per executable so re-checking the engines stays cheap. - private static string VersionOf(string ffmpeg) => + /// FFmpeg does not start, fails or does not answer within . + internal static string VersionOf(string ffmpeg, TimeSpan? timeout = null) => Versions.GetOrAdd($"{ffmpeg}|{File.GetLastWriteTimeUtc(ffmpeg).Ticks}", _ => { - using var process = Process.Start(new ProcessStartInfo(ffmpeg, "-hide_banner -version") + ProcessResult result; + try { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - }) ?? throw new InvalidOperationException("FFmpeg could not be started."); - var firstLine = process.StandardOutput.ReadLine() ?? ""; - process.StandardOutput.ReadToEnd(); - if (!process.WaitForExit(10_000)) + result = FfmpegRunner.RunBriefly(ffmpeg, ["-hide_banner", "-version"], timeout ?? QueryTimeout); + } + catch (TimeoutException) { - process.Kill(entireProcessTree: true); throw new InvalidOperationException("FFmpeg did not start in time."); } - if (process.ExitCode != 0) - throw new InvalidOperationException($"FFmpeg does not start (exit code {process.ExitCode})."); + if (result.ExitCode != 0) + throw new InvalidOperationException($"FFmpeg does not start (exit code {result.ExitCode})."); // "ffmpeg version 9.0.2-full_build-www.gyan.dev Copyright …" → "9.0.2" + var firstLine = result.StandardOutput.Split('\n', 2)[0]; var match = VersionPattern().Match(firstLine); return match.Success ? match.Groups[1].Value : firstLine.Trim(); }); @@ -198,38 +266,29 @@ private static string VersionOf(string ffmpeg) => /// The encoders of (ffmpeg -encoders), cached per executable: a copy the user /// picked may lack x265 or SVT-AV1. Null (assume Filee's build) when the list can't be read. /// - private static IReadOnlySet? EncodersOf(string ffmpeg) => + internal static IReadOnlySet? EncodersOf(string ffmpeg, TimeSpan? timeout = null) => Encoders.GetOrAdd($"{ffmpeg}|{File.GetLastWriteTimeUtc(ffmpeg).Ticks}", _ => { try { - using var process = Process.Start(new ProcessStartInfo(ffmpeg, "-hide_banner -encoders") - { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - }); - if (process is null) - return null; - process.ErrorDataReceived += (_, _) => { }; - process.BeginErrorReadLine(); - var list = process.StandardOutput.ReadToEnd(); - if (!process.WaitForExit(10_000) || process.ExitCode != 0) - return null; - // " V....D libx265 libx265 H.265 / HEVC (codec hevc)" - return list.Split('\n') - .Select(line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - .Where(parts => parts.Length >= 2 && parts[0].Length == 6 && parts[0][0] is 'V' or 'A' or 'S') - .Select(parts => parts[1]) - .ToHashSet(StringComparer.Ordinal); + var result = FfmpegRunner.RunBriefly(ffmpeg, ["-hide_banner", "-encoders"], timeout ?? QueryTimeout); + return result.ExitCode == 0 ? ParseEncoders(result.StandardOutput) : null; } - catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or IOException) + catch (Exception ex) when (ex is TimeoutException or InvalidOperationException or System.ComponentModel.Win32Exception or IOException) { return null; } }); + /// The encoder names in ffmpeg -encoders output. + internal static IReadOnlySet ParseEncoders(string list) => + // " V....D libx265 libx265 H.265 / HEVC (codec hevc)" + list.Split('\n') + .Select(line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + .Where(parts => parts.Length >= 2 && parts[0].Length == 6 && parts[0][0] is 'V' or 'A' or 'S') + .Select(parts => parts[1]) + .ToHashSet(StringComparer.Ordinal); + private static void TryDelete(string path) { try diff --git a/src/Filee.Engines/Media/FfmpegRunner.cs b/src/Filee.Engines/Media/FfmpegRunner.cs index b4cb60b4..c4497197 100644 --- a/src/Filee.Engines/Media/FfmpegRunner.cs +++ b/src/Filee.Engines/Media/FfmpegRunner.cs @@ -108,6 +108,48 @@ public static async Task RunAsync( return new ProcessResult(process.ExitCode, "", stderr); } + /// + /// Runs a short query (ffmpeg -version, -encoders) synchronously. Both streams are read in the + /// background, so the time limit holds even when the program hangs with its output open (reading it to the end + /// first would wait for the hang). + /// + /// The program did not exit within ; it was killed. + /// The program could not be started. + internal static ProcessResult RunBriefly(string executable, IReadOnlyList arguments, TimeSpan timeout) + { + var psi = new ProcessStartInfo(executable) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + foreach (var argument in arguments) + psi.ArgumentList.Add(argument); + + using var process = new Process { StartInfo = psi }; + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + process.OutputDataReceived += (_, e) => { if (e.Data is not null) lock (stdout) stdout.Append(e.Data).Append('\n'); }; + process.ErrorDataReceived += (_, e) => { if (e.Data is not null) lock (stderr) stderr.Append(e.Data).Append('\n'); }; + if (!process.Start()) + throw new InvalidOperationException($"Could not start {executable}."); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + if (!process.WaitForExit(timeout)) + { + Kill(process); + throw new TimeoutException($"{Path.GetFileName(executable)} did not finish within {timeout.TotalSeconds:0} s."); + } + // Make sure the asynchronous readers have delivered the last lines. + process.WaitForExit(); + lock (stdout) + lock (stderr) + return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString()); + } + /// /// The position in seconds from one line of -progress output (out_time_us=2000000) or of the /// palette pass' frame stats (frame_time=2.5); null for every other line. @@ -149,12 +191,20 @@ private static string StripPrefix(string line) return line; } + /// How long a killed FFmpeg may take to exit (and let go of its output) before the caller moves on. + private static readonly TimeSpan ExitAfterKill = TimeSpan.FromSeconds(10); + + /// + /// Kills FFmpeg and waits until it has exited: Kill only asks Windows to end it, and a process that still runs + /// holds its output open, so the half-written file or folder of frames could not be deleted yet. + /// private static void Kill(Process process) { try { if (!process.HasExited) process.Kill(entireProcessTree: true); + process.WaitForExit(ExitAfterKill); } catch (InvalidOperationException) { } catch (System.ComponentModel.Win32Exception) { } diff --git a/src/Filee.Engines/Media/FrameSequenceWriter.cs b/src/Filee.Engines/Media/FrameSequenceWriter.cs new file mode 100644 index 00000000..341c7b26 --- /dev/null +++ b/src/Filee.Engines/Media/FrameSequenceWriter.cs @@ -0,0 +1,102 @@ +// Video → every frame as a picture ("Save every frame", MediaOptions.SaveEveryFrame): numbered PNG or JPG files in +// one folder, for focus stacking or picking the sharpest shot. The pictures are written into a hidden folder next to +// the output and moved into place when FFmpeg is done, so a cancelled or failed run leaves nothing behind. + +using System.Globalization; +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Engines.Archives; + +namespace Filee.Engines.Media; + +/// Writes the frames of a video as numbered pictures into a folder. +internal static class FrameSequenceWriter +{ + /// Name suffix of the output folder: "clip.mp4" → "clip_frames". + public const string FolderSuffix = "_frames"; + + /// + /// Saves the frames of as pictures (frame_000001.png, …) + /// into the folder the output rule names: "clip_frames", "clip_frames (2)" when that exists, or skipped, like any + /// other output. + /// + public static async Task> ConvertAsync(string ffmpeg, ConversionStep step, string input, MediaInfo info, + IProgress? progress, CancellationToken cancellationToken) + { + var video = info.Video ?? throw new InvalidOperationException("The file has no video stream."); + var extension = FormatRegistry.Get(step.To).PrimaryExtension; + var interval = Math.Max(1, step.Preset.Media.FrameInterval); + var frameCount = EstimatedFrames(info, interval); + if (frameCount > MediaOptions.MaxFrames) + { + throw new ConversionRefusedException("error.too_many_frames", + string.Create(CultureInfo.InvariantCulture, $"{frameCount:N0} > {MediaOptions.MaxFrames:N0}")); + } + + var output = step.Output.Allocate("", FolderSuffix); + if (output is null) + return []; + + if (frameCount is { } count && FreeSpace(output) is { } free && EstimatedBytes(video, extension, count) is var needed && needed > free) + throw new ConversionRefusedException("error.not_enough_space", $"{Gigabytes(needed)} > {Gigabytes(free)}"); + + // Same folder as the output (same drive, so the move is instant); the frames go into a visible folder inside + // it, which becomes the output. + var staging = Path.Combine(Path.GetDirectoryName(output)!, $".{Path.GetFileName(output)}.filee-{Guid.NewGuid().ToString("N")[..8]}"); + Directory.CreateDirectory(staging).Attributes |= FileAttributes.Hidden; + try + { + var frames = Directory.CreateDirectory(Path.Combine(staging, "frames")).FullName; + // FFmpeg reads % in the whole path as part of the number pattern: a literal one is written %%. + var pattern = Path.Combine(frames.Replace("%", "%%", StringComparison.Ordinal), $"frame_%06d.{extension}"); + var arguments = MediaEncoding.FrameSequence(input, pattern, extension, video, step.Preset.Image.Quality, interval); + + await FfmpegConverter.RunPassAsync(ffmpeg, new FfmpegPass(arguments, 1), info.Duration, progress, 0, cancellationToken); + if (!Directory.EnumerateFiles(frames).Any()) + throw new InvalidOperationException("FFmpeg did not write any frames."); + + ArchiveFiles.MoveFolder(frames, output); + progress?.Report(1); + return [output]; + } + finally + { + // FFmpeg has exited by now (FfmpegRunner waits for it after killing it), so nothing holds these files. + ArchiveFiles.TryDeleteFolder(staging); + } + } + + /// Pictures the video gives: duration × frame rate ÷ interval; null when the file doesn't say. + internal static long? EstimatedFrames(MediaInfo info, int interval) => + info.Duration is { TotalSeconds: > 0 } duration && info.Video is { FrameRate: > 0 } video + ? (long)Math.Ceiling(duration.TotalSeconds * video.FrameRate / Math.Max(1, interval)) + : null; + + /// + /// A generous guess of the pictures' size on disk: PNG about half of raw RGB (twice that for 10-bit video, which + /// becomes 16-bit PNG), JPG about a quarter byte per pixel at the usual qualities. + /// + internal static long EstimatedBytes(VideoStreamInfo video, string extension, long frames) + { + var pixels = (double)Math.Max(1, video.Width * video.SampleAspect) * Math.Max(1, video.Height); + var perPicture = extension == "png" ? pixels * 1.5 * (video.IsHighBitDepth ? 2 : 1) : pixels * 0.25; + return (long)Math.Min(perPicture * frames, long.MaxValue); + } + + /// Free bytes on the drive of ; null where that can't be read (some network shares). + private static long? FreeSpace(string path) + { + try + { + return Path.GetPathRoot(Path.GetFullPath(path)) is { Length: > 0 } root ? new DriveInfo(root).AvailableFreeSpace : null; + } + catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException) + { + return null; + } + } + + private static string Gigabytes(long bytes) => + string.Create(CultureInfo.InvariantCulture, $"{bytes / 1e9:0.0} GB"); +} diff --git a/src/Filee.Engines/Media/HardwareProbe.cs b/src/Filee.Engines/Media/HardwareProbe.cs new file mode 100644 index 00000000..eaaff759 --- /dev/null +++ b/src/Filee.Engines/Media/HardwareProbe.cs @@ -0,0 +1,68 @@ +// Finds out which hardware video encoder works on this computer. FFmpeg lists every encoder it was built with, also +// NVENC without an NVIDIA card or Quick Sync without an Intel GPU, so each candidate gets a tiny test encode first. The +// result is kept per FFmpeg executable, encoder and bit depth for as long as Filee runs. + +using System.Collections.Concurrent; +using Filee.Engines.Infrastructure; +using Microsoft.Extensions.Logging; + +namespace Filee.Engines.Media; + +/// Picks the first hardware encoder whose test encode succeeds. +internal static class HardwareProbe +{ + /// + /// A test encode takes 0.1–2 s (Quick Sync loads its runtime on the first one); a driver that hangs is given up on + /// after this long and the software encoder is used. + /// + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15); + + private static readonly ConcurrentDictionary>> Results = new(); + + /// + /// The hardware encoder to write ("h264" or "hevc") with, or null when none works here + /// (then x264 / x265 encode as usual). + /// + /// The video is encoded in 10 bits (HEVC Main 10): an encoder must handle that too. + /// The encoders lists; null when the list could not be read. + public static async Task SelectAsync(string ffmpeg, string codec, bool tenBit, IReadOnlySet? encoders, + ILogger logger, CancellationToken cancellationToken) + { + var vaapi = HardwareVideo.Current == HostPlatform.Linux && File.Exists(HardwareVideo.VaapiDevice); + foreach (var candidate in HardwareVideo.Candidates(codec, HardwareVideo.Current, encoders, vaapi)) + { + // The probe itself is not cancelled with the conversion: other conversions may be waiting for its result. + if (await WorksAsync(ffmpeg, candidate, tenBit, logger).WaitAsync(cancellationToken)) + return candidate; + } + return null; + } + + /// The cached result of the test encode of (run on first use). + internal static Task WorksAsync(string ffmpeg, HardwareEncoder encoder, bool tenBit, ILogger logger) => + Results.GetOrAdd($"{ffmpeg}|{File.GetLastWriteTimeUtc(ffmpeg).Ticks}|{encoder.Name}|{encoder.RateControl}|{(tenBit ? 10 : 8)}", + _ => new Lazy>(() => ProbeAsync(ffmpeg, encoder, tenBit, logger))).Value; + + private static async Task ProbeAsync(string ffmpeg, HardwareEncoder encoder, bool tenBit, ILogger logger) + { + var bits = tenBit ? 10 : 8; + try + { + var result = await ProcessRunner.RunAsync(ffmpeg, HardwareVideo.ProbeArguments(encoder, tenBit), Timeout, CancellationToken.None); + if (result.ExitCode == 0) + { + logger.LogInformation("Hardware encoder {Encoder} ({RateControl}, {Bits}-bit) works", encoder.Name, encoder.RateControl, bits); + return true; + } + logger.LogInformation("Hardware encoder {Encoder} ({RateControl}, {Bits}-bit) is not usable: {Reason}", encoder.Name, + encoder.RateControl, bits, FfmpegRunner.Summarize(result.StandardError)); + return false; + } + catch (Exception ex) when (ex is TimeoutException or InvalidOperationException or System.ComponentModel.Win32Exception or IOException) + { + logger.LogInformation("Hardware encoder {Encoder} ({RateControl}, {Bits}-bit) is not usable: {Reason}", encoder.Name, + encoder.RateControl, bits, ex.Message); + return false; + } + } +} diff --git a/src/Filee.Engines/Media/HardwareVideo.cs b/src/Filee.Engines/Media/HardwareVideo.cs new file mode 100644 index 00000000..8253460c --- /dev/null +++ b/src/Filee.Engines/Media/HardwareVideo.cs @@ -0,0 +1,229 @@ +// Hardware video encoders (NVENC, Quick Sync, AMF, VideoToolbox, VA-API) for H.264 and HEVC: which ones to try on +// which system, and their FFmpeg options. Pure (no processes), so every choice is unit tested without a GPU; whether +// an encoder actually works on this computer is found out by HardwareProbe. + +using System.Globalization; +using Filee.Core.Presets; + +namespace Filee.Engines.Media; + +/// The vendor interface a hardware encoder goes through; it decides the encoder's options. +internal enum HardwareApi +{ + /// NVIDIA (h264_nvenc, hevc_nvenc). + Nvenc, + + /// Intel Quick Sync Video (h264_qsv, hevc_qsv). + Qsv, + + /// AMD Advanced Media Framework (h264_amf, hevc_amf). + Amf, + + /// Apple VideoToolbox (h264_videotoolbox, hevc_videotoolbox). + VideoToolbox, + + /// Linux VA-API (h264_vaapi, hevc_vaapi): frames are uploaded to the GPU first. + Vaapi, +} + +/// How the preset's quality reaches a hardware encoder. +internal enum HardwareRateControl +{ + /// A constant quality or quantizer, like x264's CRF. + Quality, + + /// + /// An average bitrate from the picture size and frame rate: VideoToolbox on Intel Macs has no constant quality mode + /// (FFmpeg rejects -q:v there). + /// + Bitrate, +} + +/// The operating systems hardware encoders are chosen for. +internal enum HostPlatform +{ + Windows, + MacOS, + Linux, + Other, +} + +/// One hardware encoder of FFmpeg, with the way it is told the quality. +/// FFmpeg encoder name, e.g. hevc_nvenc. +internal sealed record HardwareEncoder(string Name, HardwareApi Api, HardwareRateControl RateControl = HardwareRateControl.Quality) +{ + /// "h264" or "hevc": the part before the underscore. + public string Codec => Name[..Name.IndexOf('_', StringComparison.Ordinal)]; +} + +/// Hardware encoder candidates and command-line options. +internal static class HardwareVideo +{ + /// + /// The first DRM render node: the integrated or only GPU on almost every Linux system. A second GPU has + /// renderD129, but NVIDIA's (the usual second one) is taken through NVENC, which is tried first. + /// + public const string VaapiDevice = "/dev/dri/renderD128"; + + /// The system Filee runs on. + public static HostPlatform Current { get; } = + OperatingSystem.IsWindows() ? HostPlatform.Windows + : OperatingSystem.IsMacOS() ? HostPlatform.MacOS + : OperatingSystem.IsLinux() ? HostPlatform.Linux + : HostPlatform.Other; + + /// + /// The hardware encoders for ("h264" or "hevc") worth trying, best first. Windows: + /// NVIDIA, then Intel, then AMD (a laptop with an Intel chip and an NVIDIA card encodes on the card). macOS: + /// VideoToolbox with constant quality (Apple Silicon), then with a bitrate (Intel Macs). Linux: NVIDIA, then + /// VA-API when a render node exists. Only encoders the FFmpeg in use lists are tried (all of them when the list + /// could not be read); the probe decides which one really works. + /// + public static IReadOnlyList Candidates(string codec, HostPlatform platform, IReadOnlySet? encoders, bool vaapiDevice) + { + if (codec is not ("h264" or "hevc")) + return []; + List candidates = platform switch + { + HostPlatform.Windows => [new($"{codec}_nvenc", HardwareApi.Nvenc), new($"{codec}_qsv", HardwareApi.Qsv), new($"{codec}_amf", HardwareApi.Amf)], + HostPlatform.MacOS => + [ + new($"{codec}_videotoolbox", HardwareApi.VideoToolbox), + new($"{codec}_videotoolbox", HardwareApi.VideoToolbox, HardwareRateControl.Bitrate), + ], + HostPlatform.Linux => vaapiDevice + ? [new($"{codec}_nvenc", HardwareApi.Nvenc), new($"{codec}_vaapi", HardwareApi.Vaapi)] + : [new($"{codec}_nvenc", HardwareApi.Nvenc)], + _ => [], + }; + return encoders is null ? candidates : [.. candidates.Where(c => encoders.Contains(c.Name))]; + } + + /// + /// True when the video is encoded in 10 bits: HEVC keeps 10-bit and HDR sources in Main 10 (as x265 does). H.264 + /// is always 8-bit 4:2:0, the only format every player decodes (as with x264). + /// + public static bool TenBit(string codec, VideoStreamInfo video) => codec == "hevc" && (video.IsHighBitDepth || video.IsHdr); + + /// + /// The preset's quality on the encoders' 0–51 quantizer scale (NVENC -cq, Quick Sync -global_quality, + /// AMF and VA-API -qp): one above x264's CRF 18/23/28 for H.264 and x265's 22/26/30 for HEVC. Hardware + /// encoders lack x264/x265's psychovisual tuning and spend bits less wisely, so the same number gives a larger + /// file; one step coarser keeps the size close at a slightly lower quality. + /// + public static int Quantizer(string codec, MediaQuality quality) => codec == "hevc" + ? Choose(quality, 23, 27, 31) + : Choose(quality, 19, 24, 29); + + /// VideoToolbox's constant quality (-q:v, 1–100, higher is better) on Apple Silicon. + public static int VideoToolboxQuality(MediaQuality quality) => Choose(quality, 70, 60, 50); + + /// + /// The average bitrate (kbit/s) for VideoToolbox on Intel Macs: bits per pixel per frame of the output picture, + /// e.g. 1080p at 30 fps gets about 9 / 5.6 / 3.1 Mbit/s as H.264 and two thirds of that as HEVC. + /// + public static int BitrateKbps(string codec, MediaQuality quality, VideoStreamInfo video, int maxHeight) + { + double height = video.DisplayHeight > 0 ? video.DisplayHeight : 1080; + if (maxHeight > 0 && height > Math.Max(16, maxHeight)) + height = Math.Max(16, maxHeight); + var width = height * (video.DisplayAspect > 0 && double.IsFinite(video.DisplayAspect) ? video.DisplayAspect : 16 / 9.0); + var fps = video.FrameRate is > 0 and <= 240 ? video.FrameRate : 30; + var bitsPerPixel = Choose(quality, 0.14, 0.09, 0.05) * (codec == "hevc" ? 0.65 : 1); + return (int)Math.Clamp(Math.Round(width * height * fps * bitsPerPixel / 1000), 300, 100_000); + } + + /// Global options before the input: VA-API needs its device. + public static string[] InputArguments(HardwareEncoder encoder) => + encoder.Api == HardwareApi.Vaapi ? ["-vaapi_device", VaapiDevice] : []; + + /// + /// Appended to the video filter chain: VA-API encodes frames in GPU memory, so the scaled frames are converted to + /// NV12 / P010 and uploaded. The others take frames from system memory (-pix_fmt). + /// + public static string FilterSuffix(HardwareEncoder encoder, bool tenBit) => + encoder.Api == HardwareApi.Vaapi ? $",format={(tenBit ? "p010le" : "nv12")},hwupload" : ""; + + /// + /// The encoder options for one video: rate control from the preset's quality, NV12 (8-bit) or P010 with the Main 10 + /// profile (10-bit HEVC), the source's HDR colour tags, and the hvc1 tag Apple players need for HEVC in + /// MP4/MOV. + /// + public static List CodecArguments(HardwareEncoder encoder, string target, MediaQuality quality, VideoStreamInfo video, int maxHeight) + { + var codec = encoder.Codec; + var tenBit = TenBit(codec, video); + var quantizer = Text(Quantizer(codec, quality)); + List args = ["-c:v", encoder.Name]; + switch (encoder.Api) + { + case HardwareApi.Nvenc: + // Constant quality inside VBR; -b:v 0 lifts NVENC's default 2 Mbit/s cap. P5 is the "slow, good quality" + // preset, still many times faster than x264's medium. + args.AddRange(["-preset", "p5", "-rc", "vbr", "-cq", quantizer, "-b:v", "0"]); + break; + case HardwareApi.Qsv: + // global_quality without a bitrate selects ICQ (intelligent constant quality), or CQP where the driver + // has no ICQ. + args.AddRange(["-preset", "medium", "-global_quality", quantizer]); + break; + case HardwareApi.Amf: + // Constant quantizer; I-frames two steps finer, as AMF's own presets do. + args.AddRange(["-quality", "balanced", "-rc", "cqp", "-qp_i", Text(Math.Max(0, Quantizer(codec, quality) - 2)), "-qp_p", quantizer]); + if (codec == "h264") + args.AddRange(["-qp_b", quantizer]); + break; + case HardwareApi.VideoToolbox when encoder.RateControl == HardwareRateControl.Bitrate: + args.AddRange(["-b:v", Text(BitrateKbps(codec, quality, video, maxHeight)) + "k"]); + break; + case HardwareApi.VideoToolbox: + args.AddRange(["-q:v", Text(VideoToolboxQuality(quality))]); + break; + case HardwareApi.Vaapi: + args.AddRange(["-rc_mode", "CQP", "-qp", quantizer]); + break; + } + + if (codec == "h264") + args.AddRange(["-profile:v", "high"]); + else + args.AddRange(["-profile:v", tenBit ? "main10" : "main"]); + // VA-API's frames are already uploaded in the right format (FilterSuffix). + if (encoder.Api != HardwareApi.Vaapi) + args.AddRange(["-pix_fmt", tenBit ? "p010le" : "nv12"]); + if (tenBit) + args.AddRange(MediaEncoding.ColorArguments(video)); + if (codec == "hevc" && target is "mp4" or "m4v" or "mov") + args.AddRange(["-tag:v", "hvc1"]); + return args; + } + + /// The source of a probe: a tenth of a second of test pattern, three small frames. + private const string ProbeSource = "testsrc=size=256x144:rate=30:duration=0.1"; + + /// + /// A tiny test encode with exactly the options a real one uses (rate control, profile, pixel format), so an + /// encoder FFmpeg lists but the computer can't run (no GPU, old driver, no 10-bit support, no constant quality on + /// an Intel Mac) fails here instead of on the user's file. + /// + public static List ProbeArguments(HardwareEncoder encoder, bool tenBit) + { + var picture = new VideoStreamInfo(256, 144, 1, 0, 30, PixelFormat: tenBit ? "yuv420p10le" : "yuv420p"); + return + [ + .. MediaEncoding.Common, .. InputArguments(encoder), "-f", "lavfi", "-i", ProbeSource, + "-vf", "null" + FilterSuffix(encoder, tenBit), + .. CodecArguments(encoder, "mkv", MediaQuality.Balanced, picture, 0), + "-f", "null", "-", + ]; + } + + private static T Choose(MediaQuality quality, T high, T balanced, T small) => quality switch + { + MediaQuality.High => high, + MediaQuality.Small => small, + _ => balanced, + }; + + private static string Text(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/src/Filee.Engines/Media/MediaEncoding.cs b/src/Filee.Engines/Media/MediaEncoding.cs index ff791f17..c4f949bd 100644 --- a/src/Filee.Engines/Media/MediaEncoding.cs +++ b/src/Filee.Engines/Media/MediaEncoding.cs @@ -141,9 +141,13 @@ private static readonly (int Num, int Den)[] BroadcastRates = /// Scratch folder (the GIF palette goes there). /// The source's cover for targets FFmpeg can't put a picture stream into (). /// The encoders the FFmpeg in use has (ffmpeg -encoders); null assumes the pinned build's. + /// + /// The hardware encoder that passed its probe for of this file; null encodes with x264 / + /// x265 (also the retry after a hardware encode failed). + /// /// The file lacks the stream the target needs (e.g. no audio track). public static IReadOnlyList Plan(string input, string output, string target, MediaOptions options, MediaInfo info, string workDirectory, - CoverExtras? cover = null, IReadOnlySet? encoders = null) + CoverExtras? cover = null, IReadOnlySet? encoders = null, HardwareEncoder? hardware = null) { if (target == "gif") return AnimatedGif(input, output, options, info, workDirectory); @@ -152,7 +156,7 @@ public static IReadOnlyList Plan(string input, string output, string if (target == "png") return [new FfmpegPass(PosterFrame(input, output, info), 1)]; if (VideoTargets.TryGetValue(target, out var video)) - return [new FfmpegPass(Video(input, output, target, video, options, info, encoders), 1)]; + return [new FfmpegPass(Video(input, output, target, video, options, info, encoders, hardware), 1)]; if (AudioTargets.TryGetValue(target, out var audio)) return [new FfmpegPass(Audio(input, output, target, audio, options, info, cover), 1)]; if (SubtitleTargets.ContainsKey(target)) @@ -160,10 +164,64 @@ public static IReadOnlyList Plan(string input, string output, string throw new NotSupportedException($"FFmpeg cannot write '{target}'."); } + /// + /// Maps preset image quality (1..100, 100 best) to FFmpeg's mjpeg -q:v parameter (1..31, 1 best, 2..3 visually lossless). + /// + public static int QualityToQv(int quality) + { + var clamped = Math.Clamp(quality, 1, 100); + return Math.Clamp((int)Math.Round(2.0 + (100 - clamped) * 29.0 / 99.0), 1, 31); + } + + /// + /// Every frame (or every th) of the first video stream as numbered pictures in + /// (an image2 pattern such as frame_%06d.png), for focus stacking and + /// picking a shot. + /// + /// + /// Each decoded frame is written once. The image2 muxer would otherwise pace frames to a constant rate and + /// duplicate (or drop) frames of a variable frame rate video (phones), so frames pass through with their own + /// timestamps; with an interval the select filter leaves gaps, which vfr keeps as they are. + /// + public static List FrameSequence( + string input, + string outputPattern, + string imageExt, + VideoStreamInfo? video = null, + int quality = 90, + int frameInterval = 1) + { + var select = frameInterval > 1 ? $"select='not(mod(n\\,{frameInterval}))'" : "null"; + List args = + [ + .. Common, "-i", input, "-map", "0:V:0", "-vf", (video is null ? "" : SquarePixels(video)) + select, + "-fps_mode", frameInterval > 1 ? "vfr" : "passthrough", + ]; + + if (imageExt is "jpg" or "jpeg") + args.AddRange(["-c:v", "mjpeg", "-q:v", QualityToQv(quality).ToString(CultureInfo.InvariantCulture)]); + else + args.AddRange(["-c:v", "png"]); + + args.AddRange(["-f", "image2", "-progress", "pipe:1", outputPattern]); + return args; + } + // ───────────────────────── Video ───────────────────────── + /// + /// The codec a hardware encoder would have to write for this file ("h264" or "hevc"), or null when the preset does + /// not ask for hardware encoding, the target is written with another codec (VP9, AV1, MPEG-2, …), or the video is + /// copied anyway. + /// + internal static string? HardwareCodec(string target, MediaOptions options, MediaInfo info) => + options.HardwareEncoding && info.Video is { } video && VideoTargets.ContainsKey(target) + && MediaCodecs.TakesHardwareEncoding(target, options.VideoCodec) && !CanCopyVideo(target, video, options) + ? MediaCodecs.Effective(target, options.VideoCodec) == Core.Presets.VideoCodec.Hevc ? "hevc" : "h264" + : null; + private static List Video(string input, string output, string target, MediaTarget spec, MediaOptions options, MediaInfo info, - IReadOnlySet? encoders) + IReadOnlySet? encoders, HardwareEncoder? hardware) { var video = info.Video ?? throw new InvalidOperationException("The file has no video stream."); // An input without sound is fine: the output simply has no audio track. @@ -180,6 +238,13 @@ private static List Video(string input, string output, string target, Me if (video.Codec == "hevc" && target is "mp4" or "m4v" or "mov") args.AddRange(["-tag:v", "hvc1"]); } + else if (hardware is not null && HardwareCodec(target, options, info) == hardware.Codec) + { + // VA-API's device is a global option, so it goes before the input. + args.InsertRange(Common.Length, HardwareVideo.InputArguments(hardware)); + args.AddRange(["-vf", ScaleFilter(options.MaxHeight) + HardwareVideo.FilterSuffix(hardware, HardwareVideo.TenBit(hardware.Codec, video))]); + args.AddRange(HardwareVideo.CodecArguments(hardware, target, options.Quality, video, options.MaxHeight)); + } else { var pal = IsPal(video.FrameRate); @@ -367,8 +432,14 @@ .. HighBitDepthArguments(video), "-x265-params", "log-level=error", internal static string[] HighBitDepthArguments(VideoStreamInfo video) { var tenBit = video.IsHighBitDepth || video.IsHdr; - List args = ["-pix_fmt", tenBit ? "yuv420p10le" : "yuv420p"]; - if (tenBit && video.Color is { } color) + return ["-pix_fmt", tenBit ? "yuv420p10le" : "yuv420p", .. tenBit ? ColorArguments(video) : []]; + } + + /// The source's colour tags (primaries, transfer, matrix), for 10-bit and HDR encodes. + internal static List ColorArguments(VideoStreamInfo video) + { + List args = []; + if (video.Color is { } color) { if (color.Primaries is { } primaries) args.AddRange(["-color_primaries", primaries]); @@ -377,7 +448,7 @@ internal static string[] HighBitDepthArguments(VideoStreamInfo video) if (color.Matrix is { } matrix) args.AddRange(["-colorspace", matrix]); } - return [.. args]; + return args; } /// -r for a rate, nothing when the source rate is unknown (FFmpeg then keeps its own guess). diff --git a/src/Filee.Engines/Pdf/PdfCompressor.cs b/src/Filee.Engines/Pdf/PdfCompressor.cs new file mode 100644 index 00000000..3499bf9c --- /dev/null +++ b/src/Filee.Engines/Pdf/PdfCompressor.cs @@ -0,0 +1,538 @@ +// Makes a PDF smaller while it stays a PDF (PDFsharp + Magick.NET). Pictures drawn at more than the level's resolution +// are scaled down to it, photos are saved as JPEG at the level's quality and pictures with few colours (charts, +// screenshots, logos) losslessly; every other stream is deflated at the highest level; page thumbnails, other +// applications' private data and, when the preset drops metadata, the XMP metadata of pages and pictures are left out +// (PDFsharp writes the document's own XMP from its document information on every save). Text, fonts and vector +// drawings stay as they are. A picture is changed only when the result is smaller. + +using System.IO.Compression; +using Filee.Core.Presets; +using ImageMagick; +using PdfSharp.Pdf; +using PdfSharp.Pdf.Advanced; +using PdfSharp.Pdf.Content; +using PdfSharp.Pdf.Content.Objects; +using PdfSharp.Pdf.IO; + +namespace Filee.Engines.Pdf; + +/// Shrinks the pictures and streams of a PDF document in place (before it is saved). +internal static class PdfCompressor +{ + /// + /// Pictures are scaled down only when they are drawn at more than this many times the level's resolution, like + /// Ghostscript does: a picture just above it would lose sharpness for little saving. + /// + private const double DownsampleThreshold = 1.5; + + /// A picture drawn tiny (or with a transformation read wrong) never gets smaller than this, in pixels. + private const int MinimumSide = 8; + + /// A JPEG that keeps its size is encoded again only when that saves at least this share of it. + private const double MinimumJpegSaving = 0.1; + + /// Colours up to which a picture is a graphic (kept lossless) rather than a photo (JPEG). + private const int GraphicColors = 256; + + /// The same for gray pictures, which never have more than 256 shades: a gray photo has many of them. + private const int GraphicGrays = 32; + + /// Resolution pictures are scaled down to (dpi) and JPEG quality, per level. + public static (int Dpi, int Quality) Settings(PdfCompression level) => level switch + { + PdfCompression.Screen => (72, 55), + PdfCompression.Print => (300, 85), + _ => (150, 65), + }; + + /// Shrinks ; save it afterwards. + /// False drops the XMP metadata of pages, pictures and forms. + public static void Compress(PdfDocument document, PdfCompression level, bool keepMetadata, CancellationToken cancellationToken) + { + var (dpi, quality) = Settings(level); + var done = new HashSet(ReferenceEqualityComparer.Instance); + foreach (var (image, use) in PdfPictureUses.Find(document)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (done.Add(image)) + ShrinkPicture(document, image, use, dpi, quality, done); + } + + MergeDuplicatePictures(document); + + foreach (var page in document.Pages) + { + page.Elements.Remove("/Thumb"); + page.Elements.Remove("/PieceInfo"); + } + document.Internals.Catalog.Elements.Remove("/PieceInfo"); + // PDFsharp writes the document's XMP afresh from its document information when it saves (as for every PDF it + // writes); the old packet would stay in the file unreferenced, so it goes whatever the preset says. + document.Internals.Catalog.Elements.Remove("/Metadata"); + + foreach (var item in document.Internals.GetAllObjects()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (item is not PdfDictionary dictionary) + continue; + if (!keepMetadata && dictionary.Elements.ContainsKey("/Metadata")) + dictionary.Elements.Remove("/Metadata"); + if (dictionary.Stream is not null) + Deflate(dictionary); + } + + document.Options.CompressContentStreams = true; + document.Options.FlateEncodeMode = PdfFlateEncodeMode.BestCompression; + document.Options.Layout = PdfWriterLayout.Compact; + } + + /// Scales a picture down and saves it as JPEG or Flate, when that's smaller. Soft masks follow it. + private static void ShrinkPicture(PdfDocument document, PdfDictionary image, PictureUse use, int dpi, int quality, HashSet done) + { + if (Decode(image) is not { } picture) + return; + using var pixels = picture.Image; + var width = (int)pixels.Width; + var height = (int)pixels.Height; + + // Pixels needed to show the picture at the target resolution where it is drawn largest. + var scale = Math.Max(use.WidthInches * dpi / width, use.HeightInches * dpi / height); + var downsample = scale * DownsampleThreshold < 1; + var newWidth = downsample ? Math.Clamp((int)Math.Round(width * scale), Math.Min(MinimumSide, width), width) : width; + var newHeight = downsample ? Math.Clamp((int)Math.Round(height * scale), Math.Min(MinimumSide, height), height) : height; + downsample = newWidth < width || newHeight < height; + if (downsample) + pixels.Resize(new MagickGeometry((uint)newWidth, (uint)newHeight) { IgnoreAspectRatio = true }); + + var original = image.Stream.Value.Length; + // A JPEG that only gets a lower quality is worth it only for a clear saving: it loses detail once more. + var limit = !downsample && picture.WasJpeg ? (int)(original * (1 - MinimumJpegSaving)) : original; + var graphic = !picture.WasJpeg && pixels.TotalColors <= (picture.Components == 1 ? GraphicGrays : GraphicColors); + var encoded = picture.Components == 4 || graphic + ? EncodeFlate(pixels, picture.Components) + : EncodeJpeg(pixels, picture.Components, quality); + if (encoded is null || encoded.Value.Data.Length >= limit) + return; + + image.Stream.Value = encoded.Value.Data; + image.Elements.SetName("/Filter", encoded.Value.Filter); + if (encoded.Value.DecodeParms is { } parms) + image.Elements["/DecodeParms"] = Parms(document, parms); + else + image.Elements.Remove("/DecodeParms"); + image.Elements.SetInteger("/Width", newWidth); + image.Elements.SetInteger("/Height", newHeight); + image.Elements.SetInteger("/BitsPerComponent", 8); + + // The soft mask (transparency) may have its own size: scaled by as much as the picture, and kept lossless. + if (downsample && Resolve(image.Elements["/SMask"]) is PdfDictionary mask && done.Add(mask)) + ShrinkMask(document, mask, (double)newWidth / width, (double)newHeight / height); + } + + private static void ShrinkMask(PdfDocument document, PdfDictionary mask, double scaleX, double scaleY) + { + if (Decode(mask) is not { Components: 1, WasJpeg: false } picture) + return; + using var pixels = picture.Image; + var width = (int)Math.Max(1, Math.Round(pixels.Width * scaleX)); + var height = (int)Math.Max(1, Math.Round(pixels.Height * scaleY)); + pixels.Resize(new MagickGeometry((uint)width, (uint)height) { IgnoreAspectRatio = true }); + if (EncodeFlate(pixels, 1) is not { } encoded || encoded.Data.Length >= mask.Stream.Value.Length) + return; + mask.Stream.Value = encoded.Data; + mask.Elements.SetName("/Filter", encoded.Filter); + mask.Elements["/DecodeParms"] = Parms(document, encoded.DecodeParms!.Value); + mask.Elements.SetInteger("/Width", width); + mask.Elements.SetInteger("/Height", height); + mask.Elements.SetInteger("/BitsPerComponent", 8); + } + + /// + /// Pictures stored more than once with the same data and settings (a logo embedded again on every page) are kept + /// once: every reference to a copy points to the first. Soft masks first, so the pictures using them compare equal. + /// + private static void MergeDuplicatePictures(PdfDocument document) + { + var images = document.Internals.GetAllObjects().OfType() + .Where(d => d.Stream is not null && d.Elements.GetName("/Subtype") == "/Image" && d.Reference is not null) + .ToList(); + foreach (var pass in new[] { images.Where(Plain).ToList(), images.Where(i => !Plain(i)).ToList() }) + { + var first = new Dictionary(StringComparer.Ordinal); + var copies = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var image in pass) + { + var key = string.Join(" ", image.Elements.Keys.Where(k => k != "/Length").Order(StringComparer.Ordinal) + .Select(k => k + " " + image.Elements[k])) + " " + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(image.Stream.Value)); + if (!first.TryAdd(key, image)) + copies[image] = first[key].Reference!; + } + if (copies.Count == 0) + continue; + foreach (var item in document.Internals.GetAllObjects()) + { + if (item is PdfDictionary or PdfArray) + Redirect(item, copies, depth: 0); + } + } + + static bool Plain(PdfDictionary image) => !image.Elements.ContainsKey("/SMask") && !image.Elements.ContainsKey("/Mask"); + } + + /// Points references to the copies in to the kept picture, in direct objects too. + private static void Redirect(PdfItem container, Dictionary copies, int depth) + { + if (depth > 32) + return; + if (container is PdfDictionary dictionary) + { + foreach (var key in dictionary.Elements.Keys.ToList()) + { + var value = dictionary.Elements[key]; + if (value is PdfReference { Value: PdfDictionary target } && copies.TryGetValue(target, out var kept)) + dictionary.Elements[key] = kept; + else if (value is PdfDictionary { Reference: null } or PdfArray { Reference: null }) + Redirect(value, copies, depth + 1); + } + } + else if (container is PdfArray array) + { + for (var i = 0; i < array.Elements.Count; i++) + { + var value = array.Elements[i]; + if (value is PdfReference { Value: PdfDictionary target } && copies.TryGetValue(target, out var kept)) + array.Elements[i] = kept; + else if (value is PdfDictionary { Reference: null } or PdfArray { Reference: null }) + Redirect(value, copies, depth + 1); + } + } + } + + private static PdfDictionary Parms(PdfDocument document, (int Colors, int Columns) parms) + { + var dictionary = new PdfDictionary(document); + dictionary.Elements.SetInteger("/Predictor", 15); + dictionary.Elements.SetInteger("/Colors", parms.Colors); + dictionary.Elements.SetInteger("/BitsPerComponent", 8); + dictionary.Elements.SetInteger("/Columns", parms.Columns); + return dictionary; + } + + private sealed record DecodedPicture(MagickImage Image, int Components, bool WasJpeg); + + /// + /// The pixels of a picture Filee can encode again without changing how it looks: 8 bits per component, gray, RGB or + /// CMYK (CMYK not from JPEG: Adobe's inverted CMYK JPEGs are easy to get wrong), stored as JPEG, Flate or + /// uncompressed. Null for everything else (JBIG2, CCITT, JPEG 2000, 1-bit, indexed, spot colours, colour-key + /// masks), which stays as it is. + /// + private static DecodedPicture? Decode(PdfDictionary image) + { + var elements = image.Elements; + if (elements.GetName("/Subtype") != "/Image" || elements.GetBoolean("/ImageMask") || elements.GetInteger("/BitsPerComponent") != 8 + || Resolve(elements["/Mask"]) is PdfArray || elements.ContainsKey("/F")) + return null; + var width = elements.GetInteger("/Width"); + var height = elements.GetInteger("/Height"); + var components = Components(Resolve(elements["/ColorSpace"])); + if (width <= 0 || height <= 0 || components == 0 || (long)width * height > 100_000_000) + return null; + + var filters = Filters(image); + var data = image.Stream.Value; + try + { + if (filters is ["/DCTDecode"]) + { + // ColorTransform overrides how the JPEG's colours are read: rare, so left alone. + if (components == 4 || Parms(image) is { } dct && dct.Elements.ContainsKey("/ColorTransform")) + return null; + var jpeg = new MagickImage(data, MagickFormat.Jpeg); + var gray = jpeg.ColorSpace == ColorSpace.Gray || jpeg.ChannelCount == 1; + if (jpeg.Width != width || jpeg.Height != height || gray != (components == 1) || jpeg.ColorSpace == ColorSpace.CMYK) + { + jpeg.Dispose(); + return null; + } + return new DecodedPicture(jpeg, components, WasJpeg: true); + } + + if (filters is not ([] or ["/FlateDecode"] or ["/RunLengthDecode"])) + return null; + var raw = filters switch + { + [] => data, + ["/RunLengthDecode"] => UndoRunLength(data), + _ => Inflate(data), + }; + if (filters is ["/FlateDecode"] && Parms(image) is { } flate && flate.Elements.GetInteger("/Predictor") > 1) + { + // PNG prediction of whole pixels only; TIFF prediction (2) is rare in pictures. + var parms = flate.Elements; + var colors = parms.ContainsKey("/Colors") ? parms.GetInteger("/Colors") : 1; + var columns = parms.ContainsKey("/Columns") ? parms.GetInteger("/Columns") : 1; + var bits = parms.ContainsKey("/BitsPerComponent") ? parms.GetInteger("/BitsPerComponent") : 8; + if (parms.GetInteger("/Predictor") < 10 || colors != components || columns != width || bits != 8 + || UndoPngPredictor(raw, width * components, height, components) is not { } unpredicted) + return null; + raw = unpredicted; + } + var size = width * height * components; + if (raw.Length < size) + return null; + var settings = new MagickReadSettings + { + Width = (uint)width, + Height = (uint)height, + Depth = 8, + Format = components switch { 1 => MagickFormat.Gray, 3 => MagickFormat.Rgb, _ => MagickFormat.Cmyk }, + }; + return new DecodedPicture(new MagickImage(raw.AsSpan(0, size), settings), components, WasJpeg: false); + } + catch (Exception ex) when (ex is MagickException or InvalidDataException) + { + return null; // damaged or unusual data: the picture stays as it is + } + } + + private static (byte[] Data, string Filter, (int Colors, int Columns)? DecodeParms)? EncodeJpeg(MagickImage pixels, int components, int quality) + { + pixels.Strip(); + pixels.Quality = (uint)quality; + pixels.ColorType = components == 1 ? ColorType.Grayscale : ColorType.TrueColor; + var data = pixels.ToByteArray(MagickFormat.Jpeg); + // Exactly what the image dictionary says, or the picture would come out wrong: ImageMagick could write a + // gray-looking colour picture with one component. + return JpegFrame(data) is var (w, h, n) && w == pixels.Width && h == pixels.Height && n == components + ? (data, "/DCTDecode", null) + : null; + } + + private static (byte[] Data, string Filter, (int Colors, int Columns)? DecodeParms)? EncodeFlate(MagickImage pixels, int components) + { + pixels.Depth = 8; + var raw = pixels.ToByteArray(components switch { 1 => MagickFormat.Gray, 3 => MagickFormat.Rgb, _ => MagickFormat.Cmyk }); + var stride = (int)pixels.Width * components; + if (raw.Length != stride * (int)pixels.Height) + return null; + return (Deflate(PngPredict(raw, stride, (int)pixels.Height, components)), "/FlateDecode", (components, (int)pixels.Width)); + } + + /// Deflates an uncompressed stream, or deflates a Flate stream again at the highest level, when smaller. + private static void Deflate(PdfDictionary dictionary) + { + var type = dictionary.Elements.GetName("/Type"); + // XMP stays readable for tools that search files for it; object and cross-reference streams are PDFsharp's. + if (type is "/Metadata" or "/ObjStm" or "/XRef" || dictionary.Elements.ContainsKey("/F")) + return; + var filters = Filters(dictionary); + var data = dictionary.Stream.Value; + if (data.Length < 64) + return; + try + { + if (filters.Length == 0) + { + var packed = Deflate(data); + if (packed.Length >= data.Length) + return; + dictionary.Stream.Value = packed; + dictionary.Elements.SetName("/Filter", "/FlateDecode"); + } + else if (filters is ["/FlateDecode"]) + { + // The data stays the same (predictor included), only packed tighter. + var packed = Deflate(Inflate(data)); + if (packed.Length < data.Length) + dictionary.Stream.Value = packed; + } + else if (filters is ["/RunLengthDecode"]) + { + var packed = Deflate(UndoRunLength(data)); + if (packed.Length >= data.Length) + return; + dictionary.Stream.Value = packed; + dictionary.Elements.SetName("/Filter", "/FlateDecode"); + } + } + catch (InvalidDataException) + { + // damaged stream: left as it is + } + } + + private static string[] Filters(PdfDictionary dictionary) => Resolve(dictionary.Elements["/Filter"]) switch + { + PdfName name => [name.Value], + PdfArray array => [.. array.Elements.Select(e => Resolve(e) is PdfName n ? n.Value : "?")], + _ => [], + }; + + private static PdfDictionary? Parms(PdfDictionary dictionary) => Resolve(dictionary.Elements["/DecodeParms"]) switch + { + PdfDictionary parms => parms, + PdfArray { Elements.Count: 1 } array => Resolve(array.Elements[0]) as PdfDictionary, + _ => null, + }; + + /// Components of a colour space Filee re-encodes (gray, RGB, CMYK and their calibrated / ICC forms), else 0. + private static int Components(PdfItem? colorSpace) => colorSpace switch + { + PdfName { Value: "/DeviceGray" } => 1, + PdfName { Value: "/DeviceRGB" } => 3, + PdfName { Value: "/DeviceCMYK" } => 4, + PdfArray { Elements.Count: > 0 } array => (Resolve(array.Elements[0]) as PdfName)?.Value switch + { + "/CalGray" => 1, + "/CalRGB" => 3, + "/ICCBased" when array.Elements.Count > 1 && Resolve(array.Elements[1]) is PdfDictionary icc + => icc.Elements.GetInteger("/N") is var n and (1 or 3 or 4) ? n : 0, + _ => 0, + }, + _ => 0, + }; + + /// The object a reference points to; a name stored as an object of its own (ImageMagick does) as a name. + internal static PdfItem? Resolve(PdfItem? item) => item is PdfReference reference + ? reference.Value is PdfNameObject name ? new PdfName(name.Value) : reference.Value + : item; + + private static byte[] Inflate(byte[] data) + { + using var input = new ZLibStream(new MemoryStream(data), CompressionMode.Decompress); + using var output = new MemoryStream(); + input.CopyTo(output); + return output.ToArray(); + } + + private static byte[] Deflate(byte[] data) + { + using var output = new MemoryStream(); + using (var zlib = new ZLibStream(output, CompressionLevel.SmallestSize, leaveOpen: true)) + zlib.Write(data); + return output.ToArray(); + } + + /// + /// RunLengthDecode (ImageMagick writes pictures with it): a length byte n < 128 copies the next n + 1 bytes, n > 128 + /// repeats the next byte 257 - n times, 128 ends the data. + /// + private static byte[] UndoRunLength(byte[] data) + { + using var output = new MemoryStream(data.Length * 2); + var i = 0; + while (i < data.Length) + { + var length = data[i++]; + if (length == 128) + break; + if (length < 128) + { + var count = Math.Min(length + 1, data.Length - i); + output.Write(data, i, count); + i += count; + } + else if (i < data.Length) + { + var value = data[i++]; + for (var n = 0; n < 257 - length; n++) + output.WriteByte(value); + } + } + return output.ToArray(); + } + + /// Reverses PNG prediction (/Predictor 10-15): each row starts with its filter type. Null when damaged. + private static byte[]? UndoPngPredictor(byte[] data, int stride, int rows, int bpp) + { + if (data.Length < (long)(stride + 1) * rows) + return null; + var result = new byte[stride * rows]; + var none = new byte[stride]; + for (var r = 0; r < rows; r++) + { + var type = data[r * (stride + 1)]; + var source = data.AsSpan(r * (stride + 1) + 1, stride); + var row = result.AsSpan(r * stride, stride); + ReadOnlySpan up = r == 0 ? none : result.AsSpan((r - 1) * stride, stride); + for (var x = 0; x < stride; x++) + { + int a = x >= bpp ? row[x - bpp] : 0, b = up[x], c = x >= bpp ? up[x - bpp] : 0; + int predicted; + switch (type) + { + case 0: predicted = 0; break; + case 1: predicted = a; break; + case 2: predicted = b; break; + case 3: predicted = (a + b) / 2; break; + case 4: predicted = Paeth(a, b, c); break; + default: return null; + } + row[x] = (byte)(source[x] + predicted); + } + } + return result; + } + + /// PNG-predicts rows (/Predictor 15), each with the filter that leaves the smallest values. + private static byte[] PngPredict(byte[] raw, int stride, int rows, int bpp) + { + var output = new byte[(stride + 1) * rows]; + var candidates = new byte[5][]; + for (var t = 0; t < candidates.Length; t++) + candidates[t] = new byte[stride]; + var none = new byte[stride]; + for (var r = 0; r < rows; r++) + { + ReadOnlySpan row = raw.AsSpan(r * stride, stride); + ReadOnlySpan up = r == 0 ? none : raw.AsSpan((r - 1) * stride, stride); + var best = 0; + var bestSum = long.MaxValue; + for (var type = 0; type < candidates.Length; type++) + { + var filtered = candidates[type]; + long sum = 0; + for (var x = 0; x < stride; x++) + { + int a = x >= bpp ? row[x - bpp] : 0, b = up[x], c = x >= bpp ? up[x - bpp] : 0; + var predicted = type switch { 0 => 0, 1 => a, 2 => b, 3 => (a + b) / 2, _ => Paeth(a, b, c) }; + var value = (byte)(row[x] - predicted); + filtered[x] = value; + sum += value < 128 ? value : 256 - value; + } + if (sum < bestSum) + { + bestSum = sum; + best = type; + } + } + output[r * (stride + 1)] = (byte)best; + candidates[best].CopyTo(output.AsSpan(r * (stride + 1) + 1)); + } + return output; + } + + private static int Paeth(int a, int b, int c) + { + var p = a + b - c; + int pa = Math.Abs(p - a), pb = Math.Abs(p - b), pc = Math.Abs(p - c); + return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } + + /// Width, height and components of a JPEG's frame header, or null. + private static (int Width, int Height, int Components)? JpegFrame(byte[] jpeg) + { + var i = 2; + while (i + 9 < jpeg.Length) + { + if (jpeg[i] != 0xFF) + return null; + var marker = jpeg[i + 1]; + var length = (jpeg[i + 2] << 8) | jpeg[i + 3]; + if (marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC) + return ((jpeg[i + 7] << 8) | jpeg[i + 8], (jpeg[i + 5] << 8) | jpeg[i + 6], jpeg[i + 9]); + i += 2 + length; + } + return null; + } +} diff --git a/src/Filee.Engines/Pdf/PdfDocumentReader.cs b/src/Filee.Engines/Pdf/PdfDocumentReader.cs index 4703e760..f25d868c 100644 --- a/src/Filee.Engines/Pdf/PdfDocumentReader.cs +++ b/src/Filee.Engines/Pdf/PdfDocumentReader.cs @@ -183,9 +183,41 @@ private HDocument ReadDocument() } } - private static InvalidOperationException PasswordProtected() => + internal static InvalidOperationException PasswordProtected() => new("The PDF is password-protected. Open it with its password and save a copy without one, then convert that copy."); + /// + /// The text of each page in (zero-based), read like (reading order, + /// invisible OCR text included) but page by page, with running heads and page numbers kept: "" for a page without + /// text. Paragraphs are separated by an empty line, lines end with CR LF. + /// + public static IReadOnlyList PageTexts(string path, IReadOnlyList pages, CancellationToken cancellationToken = default) + { + var reader = new PdfDocumentReader(path, "", textOnly: true); + try + { + using var pdf = PdfDocument.Open(path, new ParsingOptions { UseLenientParsing = true, SkipMissingFonts = true }); + var texts = new List(pages.Count); + foreach (var index in pages) + { + cancellationToken.ThrowIfCancellationRequested(); + var page = reader.ReadPage(pdf.GetPage(index + 1)); + if (page.Scanned) + { + texts.Add(""); + continue; + } + page.Items = ReadingOrder(page.Items); + texts.Add(PlainText(reader.Build([page], null)).TrimEnd()); + } + return texts; + } + catch (PdfDocumentEncryptedException) + { + throw PasswordProtected(); + } + } + private PageContent ReadPage(Page page) { var crop = page.CropBox.Bounds; diff --git a/src/Filee.Engines/Pdf/PdfPictureUses.cs b/src/Filee.Engines/Pdf/PdfPictureUses.cs new file mode 100644 index 00000000..70e88327 --- /dev/null +++ b/src/Filee.Engines/Pdf/PdfPictureUses.cs @@ -0,0 +1,242 @@ +// Where the pictures of a PDF are drawn and how large, for PdfCompressor: the content streams of the pages, of the +// forms they draw and of annotation appearances are read for "cm" (transformations) and "Do" (draw an XObject). A +// picture's largest drawn size decides how far it may be scaled down. Pictures whose drawing can't be followed (a +// content stream PDFsharp can't read) are taken as large as the page lets them be, so they are never scaled down too far. + +using PdfSharp.Pdf; +using PdfSharp.Pdf.Content; +using PdfSharp.Pdf.Content.Objects; + +namespace Filee.Engines.Pdf; + +/// The largest size, in inches, a picture is drawn at (width and height of the picture, wherever it is turned). +internal readonly record struct PictureUse(double WidthInches, double HeightInches); + +/// Finds the pictures (image XObjects) of a document and the size they are drawn at. +internal sealed class PdfPictureUses +{ + private const double PointsPerInch = 72; + + /// Forms drawing forms deeper than this are not followed (and nothing is drawn that deep in practice). + private const int MaxDepth = 12; + + /// Forms followed per document at most, against forms drawing each other many times over. + private const int MaxForms = 20_000; + + /// The part of a transformation that sizes things (no translation): x' = a·x + c·y, y' = b·x + d·y. + private readonly record struct Matrix(double A, double B, double C, double D) + { + public static readonly Matrix Identity = new(1, 0, 0, 1); + + /// This transformation applied first, then (PDF's "this × outer"). + public Matrix Then(Matrix outer) => + new(A * outer.A + B * outer.C, A * outer.B + B * outer.D, C * outer.A + D * outer.C, C * outer.B + D * outer.D); + } + + private readonly Dictionary _drawn = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _found = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _unsure = new(ReferenceEqualityComparer.Instance); + private int _forms; + + /// Every picture in the resources of the pages, their forms and annotation appearances, with its size. + public static IReadOnlyList<(PdfDictionary Image, PictureUse Use)> Find(PdfDocument document) + { + var uses = new PdfPictureUses(); + double pageWidth = 0, pageHeight = 0; + foreach (var page in document.Pages) + { + pageWidth = Math.Max(pageWidth, page.Width.Point); + pageHeight = Math.Max(pageHeight, page.Height.Point); + var resources = page.Elements.GetDictionary("/Resources"); + uses.Collect(resources); + CSequence? content = null; + try + { + content = ContentReader.ReadContent(page); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + uses.Unsure(resources); // can't be read: its pictures are taken as large as the page + } + if (content is not null) + uses.Walk(content, resources, Matrix.Identity, 0); + uses.WalkAnnotations(page); + } + + // Not drawn anywhere Filee could follow: as large as the largest page holds it (either way round). + var pageWidthInches = Math.Max(pageWidth, 1) / PointsPerInch; + var pageHeightInches = Math.Max(pageHeight, 1) / PointsPerInch; + var result = new List<(PdfDictionary, PictureUse)>(); + foreach (var image in uses._found) + { + var width = Math.Max(1, image.Elements.GetInteger("/Width")); + var height = Math.Max(1, image.Elements.GetInteger("/Height")); + var dpi = Math.Min(Math.Max(width / pageWidthInches, height / pageHeightInches), + Math.Max(width / pageHeightInches, height / pageWidthInches)); + var drawn = uses._drawn.TryGetValue(image, out var size) ? size : (0, 0); + if (!uses._drawn.ContainsKey(image) || uses._unsure.Contains(image)) + drawn = (Math.Max(drawn.Width, width / dpi), Math.Max(drawn.Height, height / dpi)); + result.Add((image, new PictureUse(drawn.Width, drawn.Height))); + } + return result; + } + + /// Remembers the pictures of a resource dictionary. + private void Collect(PdfDictionary? resources) + { + if (resources?.Elements.GetDictionary("/XObject") is not { } xobjects) + return; + foreach (var key in xobjects.Elements.Keys) + { + if (PdfCompressor.Resolve(xobjects.Elements[key]) is PdfDictionary { Stream: not null } xobject + && xobject.Elements.GetName("/Subtype") == "/Image") + _found.Add(xobject); + } + } + + private void Unsure(PdfDictionary? resources) + { + if (resources?.Elements.GetDictionary("/XObject") is not { } xobjects) + return; + foreach (var key in xobjects.Elements.Keys) + { + if (PdfCompressor.Resolve(xobjects.Elements[key]) is PdfDictionary xobject) + _unsure.Add(xobject); + } + } + + /// Follows q / Q / cm and records what Do draws. + private void Walk(CSequence content, PdfDictionary? resources, Matrix start, int depth) + { + var saved = new Stack(); + var current = start; + foreach (var item in content) + { + if (item is not COperator op) + continue; + switch (op.OpCode.OpCodeName) + { + case OpCodeName.q: + saved.Push(current); + break; + case OpCodeName.Q: + if (saved.Count > 0) + current = saved.Pop(); + break; + case OpCodeName.cm when op.Operands.Count == 6: + current = new Matrix(Number(op.Operands[0]), Number(op.Operands[1]), Number(op.Operands[2]), Number(op.Operands[3])).Then(current); + break; + case OpCodeName.Do when op.Operands.Count == 1 && op.Operands[0] is CName name: + Draw(name.Name, resources, current, depth); + break; + } + } + } + + private void Draw(string name, PdfDictionary? resources, Matrix ctm, int depth) + { + if (resources?.Elements.GetDictionary("/XObject") is not { } xobjects + || PdfCompressor.Resolve(xobjects.Elements[name]) is not PdfDictionary { Stream: not null } xobject) + return; + switch (xobject.Elements.GetName("/Subtype")) + { + case "/Image": + // The picture fills the unit square: its sides become the transformed unit vectors. + var width = Math.Sqrt(ctm.A * ctm.A + ctm.B * ctm.B) / PointsPerInch; + var height = Math.Sqrt(ctm.C * ctm.C + ctm.D * ctm.D) / PointsPerInch; + _found.Add(xobject); + _drawn[xobject] = _drawn.TryGetValue(xobject, out var known) + ? (Math.Max(known.Width, width), Math.Max(known.Height, height)) + : (width, height); + break; + case "/Form": + WalkForm(xobject, resources, ctm, depth + 1); + break; + } + } + + private void WalkForm(PdfDictionary form, PdfDictionary? inherited, Matrix parent, int depth) + { + if (depth > MaxDepth || ++_forms > MaxForms) + { + Unsure(form.Elements.GetDictionary("/Resources") ?? inherited); + return; + } + // Old PDFs leave out a form's resources: it uses the page's then. + var resources = form.Elements.GetDictionary("/Resources") ?? inherited; + Collect(resources); + CSequence content; + try + { + content = ContentReader.ReadContent(form.Stream.UnfilteredValue); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Unsure(resources); + return; + } + Walk(content, resources, FormMatrix(form).Then(parent), depth); + } + + /// + /// Annotation appearances (stamps, signatures, form fields): the form's bounding box, transformed by its matrix, + /// is fitted into the annotation's rectangle. + /// + private void WalkAnnotations(PdfPage page) + { + if (page.Elements.GetArray("/Annots") is not { } annotations) + return; + foreach (var item in annotations.Elements) + { + if (PdfCompressor.Resolve(item) is not PdfDictionary annotation + || annotation.Elements.GetDictionary("/AP") is not { } appearances + || annotation.Elements.GetArray("/Rect") is not { Elements.Count: 4 } rect) + continue; + var rectWidth = Math.Abs(rect.Elements.GetReal(2) - rect.Elements.GetReal(0)); + var rectHeight = Math.Abs(rect.Elements.GetReal(3) - rect.Elements.GetReal(1)); + foreach (var key in new[] { "/N", "/R", "/D" }) + { + switch (PdfCompressor.Resolve(appearances.Elements[key])) + { + case PdfDictionary { Stream: not null } form: + WalkAppearance(form, page, rectWidth, rectHeight); + break; + case PdfDictionary states: + foreach (var state in states.Elements.Keys) + { + if (PdfCompressor.Resolve(states.Elements[state]) is PdfDictionary { Stream: not null } stateForm) + WalkAppearance(stateForm, page, rectWidth, rectHeight); + } + break; + } + } + } + } + + private void WalkAppearance(PdfDictionary form, PdfPage page, double rectWidth, double rectHeight) + { + var matrix = FormMatrix(form); + double boxWidth = 0, boxHeight = 0; + if (form.Elements.GetArray("/BBox") is { Elements.Count: 4 } box) + { + var w = Math.Abs(box.Elements.GetReal(2) - box.Elements.GetReal(0)); + var h = Math.Abs(box.Elements.GetReal(3) - box.Elements.GetReal(1)); + boxWidth = Math.Abs(matrix.A) * w + Math.Abs(matrix.C) * h; + boxHeight = Math.Abs(matrix.B) * w + Math.Abs(matrix.D) * h; + } + var fit = boxWidth > 0 && boxHeight > 0 ? new Matrix(rectWidth / boxWidth, 0, 0, rectHeight / boxHeight) : Matrix.Identity; + WalkForm(form, page.Elements.GetDictionary("/Resources"), fit, 1); + } + + private static Matrix FormMatrix(PdfDictionary form) => + form.Elements.GetArray("/Matrix") is { Elements.Count: 6 } m + ? new Matrix(m.Elements.GetReal(0), m.Elements.GetReal(1), m.Elements.GetReal(2), m.Elements.GetReal(3)) + : Matrix.Identity; + + private static double Number(CObject item) => item switch + { + CInteger integer => integer.Value, + CReal real => real.Value, + _ => 0, + }; +} diff --git a/src/Filee.Engines/Pdf/PdfSharpConverter.cs b/src/Filee.Engines/Pdf/PdfSharpConverter.cs index f3dba224..4f64ac75 100644 --- a/src/Filee.Engines/Pdf/PdfSharpConverter.cs +++ b/src/Filee.Engines/Pdf/PdfSharpConverter.cs @@ -1,9 +1,11 @@ -// Builds PDFs from images, splits / extracts PDF pages, and merges PDFs (PDFsharp, MIT). +// Builds PDFs from images, splits / extracts PDF pages, merges PDFs and makes PDFs smaller (PDFsharp, MIT; the +// compression itself is in PdfCompressor, with Ghostscript as an optional second try). using Filee.Core.Conversion; using Filee.Core.Presets; using Filee.Engines.Infrastructure; using Filee.Engines.Magick; +using Filee.Engines.Vector; using ImageMagick; using PdfSharp.Drawing; using PdfSharp.Pdf; @@ -11,7 +13,7 @@ namespace Filee.Engines.Pdf; -/// Image → PDF, PDF → PDF (split / page range) and PDF merging. +/// Image → PDF, PDF → PDF (split / page range / compression) and PDF merging. public sealed class PdfSharpConverter : IConverter, IPdfMerger, IImageTransformer { private const double PointsPerInch = 72.0; @@ -33,9 +35,9 @@ public sealed class PdfSharpConverter : IConverter, IPdfMerger, IImageTransforme public EngineStatus GetStatus() => EngineStatus.Available(version: EngineVersions.Library("PDFsharp", typeof(PdfDocument))); public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => - Task.Run(() => step.From == "pdf" - ? SplitOrExtract(step, progress, cancellationToken) - : ImageToPdf(step, progress, cancellationToken), cancellationToken); + step.From != "pdf" ? Task.Run(() => ImageToPdf(step, progress, cancellationToken), cancellationToken) + : step.Preset.Pdf.Compression == PdfCompression.None ? Task.Run(() => SplitOrExtract(step, progress, cancellationToken), cancellationToken) + : Task.Run(() => CompressAsync(step, progress, cancellationToken), cancellationToken); public Task MergeAsync(IReadOnlyList inputPaths, string outputPath, CancellationToken cancellationToken) => Task.Run(() => @@ -44,7 +46,7 @@ public Task MergeAsync(IReadOnlyList inputPaths, string outputPath, Canc foreach (var input in inputPaths) { cancellationToken.ThrowIfCancellationRequested(); - using var source = PdfReader.Open(input, PdfDocumentOpenMode.Import); + using var source = Open(input, PdfDocumentOpenMode.Import); foreach (var page in source.Pages) output.AddPage(page); } @@ -106,7 +108,7 @@ private static IReadOnlyList ImageToPdf(ConversionStep step, IProgressPDF → PDF: split into single pages, or extract the configured page range. private static IReadOnlyList SplitOrExtract(ConversionStep step, IProgress? progress, CancellationToken ct) { - using var source = PdfReader.Open(step.InputPath, PdfDocumentOpenMode.Import); + using var source = Open(step.InputPath, PdfDocumentOpenMode.Import); var pages = PageRange.Parse(step.Preset.Pdf.PageRange, source.PageCount); var written = new List(); @@ -137,6 +139,150 @@ private static IReadOnlyList SplitOrExtract(ConversionStep step, IProgre return [output]; } + /// + /// PDF → a smaller PDF (), also with a page range or one file per page: the whole + /// file keeps everything (bookmarks, form fields, links, metadata) and is compressed as it is; selected pages are + /// copied into new PDFs first, as without compression. Each result is the smallest of the built-in compression and, + /// when the preset asks for it and it is installed, Ghostscript's; when neither is smaller than the pages as they + /// were, those are written unchanged and the file gets a notice. + /// + private static async Task> CompressAsync(ConversionStep step, IProgress? progress, CancellationToken ct) + { + var options = step.Preset.Pdf; + var scratch = Path.Combine(step.WorkDirectory, "compress-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(scratch); + + // The PDFs to compress: the file itself, or its selected pages copied into new PDFs. + var parts = new List<(string Plain, string? Suffix)>(); + using (var source = Open(step.InputPath, PdfDocumentOpenMode.Import)) + { + var pages = PageRange.Parse(options.PageRange, source.PageCount); + if (options.SplitPages) + { + foreach (var index in pages) + { + using var single = new PdfDocument(); + single.AddPage(source.Pages[index]); + var path = Path.Combine(scratch, $"page-{index + 1}.pdf"); + single.Save(path); + parts.Add((path, $"_p{index + 1}")); + } + } + else if (pages.SequenceEqual(Enumerable.Range(0, source.PageCount))) + { + parts.Add((step.InputPath, null)); + } + else + { + using var extract = new PdfDocument(); + foreach (var index in pages) + extract.AddPage(source.Pages[index]); + var path = Path.Combine(scratch, "pages.pdf"); + extract.Save(path); + parts.Add((path, null)); + } + } + + var gs = options.CompressWithGhostscript ? GhostscriptConverter.Locate() : null; + var written = new List(); + var anySmaller = false; + for (var i = 0; i < parts.Count; i++) + { + ct.ThrowIfCancellationRequested(); + var output = step.Output.Allocate("pdf", parts[i].Suffix); + if (output is null) + continue; + anySmaller |= await CompressFileAsync(parts[i].Plain, output, step.Preset, gs, scratch, ct); + written.Add(output); + progress?.Report((i + 1.0) / parts.Count); + } + if (written.Count > 0 && !anySmaller) + step.Notice?.Invoke("notice.pdf_not_smaller"); + return written; + } + + /// Writes the smallest of and its compressed versions to . + /// True when a compressed version was smaller. + private static async Task CompressFileAsync(string plain, string output, Preset preset, string? gs, string scratch, CancellationToken ct) + { + var level = preset.Pdf.Compression; + var candidates = new List(); + Exception? failure = null; + var builtIn = Path.Combine(scratch, Guid.NewGuid().ToString("N")[..8] + "-filee.pdf"); + try + { + using var document = OpenForChanges(plain); + PdfCompressor.Compress(document, level, preset.Image.KeepMetadata, ct); + document.Save(builtIn); + candidates.Add(builtIn); + } + catch (Exception ex) when (gs is not null && ex is not OperationCanceledException) + { + failure = ex; // Ghostscript may still manage + } + + if (gs is not null) + { + var rewritten = Path.Combine(scratch, Guid.NewGuid().ToString("N")[..8] + "-gs.pdf"); + try + { + await GhostscriptConverter.CompressPdfAsync(gs, plain, rewritten, level, scratch, ct); + candidates.Add(rewritten); + } + catch (Exception ex) when (ex is not OperationCanceledException && candidates.Count > 0) + { + // The built-in result stands. + } + catch (Exception) when (failure is not null) + { + throw failure; + } + } + + var original = new FileInfo(plain).Length; + var best = candidates.Select(path => new FileInfo(path)).MinBy(file => file.Length); + if (best is not null && best.Length < original) + { + File.Copy(best.FullName, output, overwrite: true); + return true; + } + File.Copy(plain, output, overwrite: true); + return false; + } + + /// + /// Opens a PDF to change it. One locked only against changes (it opens without a password) has its pages copied + /// into a new PDF instead, as when pages are split or extracted: bookmarks and form fields stay behind then. + /// + private static PdfDocument OpenForChanges(string path) + { + try + { + return PdfReader.Open(path, PdfDocumentOpenMode.Modify); + } + catch (PdfReaderException ex) when (ex.Message.Contains("owner password", StringComparison.OrdinalIgnoreCase)) + { + using var source = Open(path, PdfDocumentOpenMode.Import); + var copy = new PdfDocument(); + foreach (var page in source.Pages) + copy.AddPage(page); + return copy; + } + } + + /// Opens a PDF; a password-protected one fails with the message the other PDF conversions give. + internal static PdfDocument Open(string path, PdfDocumentOpenMode mode) + { + try + { + return PdfReader.Open(path, mode); + } + catch (PdfReaderException ex) when (ex.Message.Contains("password", StringComparison.OrdinalIgnoreCase)) + { + throw PdfDocumentReader.PasswordProtected(); + } + } + /// /// Encodes a frame for embedding. JPEG sources stay JPEG (no generational loss beyond one re-encode at high quality); /// everything else is embedded losslessly as PNG. diff --git a/src/Filee.Engines/Pdf/PdfSlidesConverter.cs b/src/Filee.Engines/Pdf/PdfSlidesConverter.cs new file mode 100644 index 00000000..e6bd9933 --- /dev/null +++ b/src/Filee.Engines/Pdf/PdfSlidesConverter.cs @@ -0,0 +1,165 @@ +// PDF → PPTX: one slide per page showing the page as a picture, so the slides look exactly like the PDF (they are not +// editable). Pages are rendered with PDFium (via PDFtoImage) at the preset's resolution and written by +// PictureDeckWriter; the text of each page (PdfPig, reading order, like PDF → TXT) goes into the slide's speaker notes, +// so it can still be searched and copied. +// +// - Slide size: the first page's own size, so A4 portrait gives portrait slides and a 16:9 PDF 16:9 slides. Every +// page, also one of another size or orientation, is fitted onto the slide without cropping and centred on white. +// - Resolution: the preset's render DPI, at least 150 (sharp on a projector) and at most 600; a page over 25 +// megapixels at that resolution (a poster) is rendered smaller, so memory and file size stay reasonable. +// - Pictures are JPEG at the preset's quality (90 by default). Pages are rendered on white as every PDF viewer shows +// them, so they have no transparency that would need PNG, and JPEG keeps a 100-page deck a manageable size. +// - The edge is DirectOnly: only a PDF becomes picture slides, and a picture deck is never a step towards another +// format (ODP, PPSX, ...). + +using Filee.Core.Conversion; +using Filee.Engines.Infrastructure; +using PDFtoImage; +using PDFtoImage.Exceptions; +using SkiaSharp; + +// PDFtoImage marks its API for the platforms PDFium ships for; Filee only targets desktop platforms, all covered. +#pragma warning disable CA1416 + +namespace Filee.Engines.Pdf; + +/// PDF → PPTX with a picture of each page on its own slide. +public sealed class PdfSlidesConverter : IConverter +{ + internal const int MinDpi = 150; + internal const int MaxDpi = 600; + + /// A page larger than this many pixels at the preset's resolution is rendered at a lower one. + internal const double MaxPagePixels = 25_000_000; + + public string Id => "pdf-slides"; + public string DisplayName => "PDF slides (built-in)"; + + /// PDFium renders one page at a time anyway; one deck at a time keeps memory predictable. + public int MaxParallelism => 1; + + public IReadOnlyList Edges { get; } = [new("pdf", "pptx", DirectOnly: true)]; + + public EngineStatus GetStatus() => + EngineStatus.Available("PDF → PPTX", $"{EngineVersions.BuiltIn} · {EngineVersions.Library("PDFtoImage", typeof(Conversion))}"); + + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => + Task.Run>(() => + { + var pdf = File.ReadAllBytes(step.InputPath); + int pageCount; + IList sizes; + try + { + pageCount = Conversion.GetPageCount(pdf, null); + sizes = Conversion.GetPageSizes(pdf, null); + } + catch (PdfPasswordProtectedException) + { + throw PdfDocumentReader.PasswordProtected(); + } + var pages = PageRange.Parse(step.Preset.Pdf.PageRange, pageCount); + if (pages.Count == 0) + throw new InvalidOperationException("The PDF has no pages."); + + var output = step.Output.Allocate("pptx"); + if (output is null) + return []; + + var notes = PageTexts(step.InputPath, pages, cancellationToken); + progress?.Report(0.1); + var dpi = Math.Clamp(step.Preset.Pdf.RenderDpi, MinDpi, MaxDpi); + var quality = Math.Clamp(step.Preset.Image.Quality, 1, 100); + + PictureDeckWriter? deck = null; + try + { + var done = 0; + // Pages that render at the same resolution go through PDFium together (one document load). + foreach (var run in Runs(pages, page => PageDpi(sizes[page].Width, sizes[page].Height, dpi))) + { + var options = new RenderOptions(Dpi: run.Dpi, WithAnnotations: true, WithFormFill: true, BackgroundColor: SKColors.White); + var index = 0; + foreach (var bitmap in Conversion.ToImages(pdf, run.Pages, null, options)) + { + using (bitmap) + { + cancellationToken.ThrowIfCancellationRequested(); + var page = run.Pages[index++]; + var (width, height) = Shown(sizes[page], bitmap); + if (deck is null) + { + var (slideWidth, slideHeight) = PictureDeckWriter.SlideSize(width, height); + deck = new PictureDeckWriter(output, slideWidth, slideHeight); + } + using var jpeg = bitmap.Encode(SKEncodedImageFormat.Jpeg, quality); + deck.AddSlide(jpeg.AsSpan(), width, height, $"Page {page + 1}", notes?[done]); + } + done++; + progress?.Report(0.1 + 0.85 * done / pages.Count); + } + } + (deck ?? throw new InvalidOperationException("PDFium rendered no page of the PDF.")).Save(); + } + finally + { + deck?.Dispose(); + } + progress?.Report(1); + return [output]; + }, cancellationToken); + + /// + /// Resolution for a page of × points: , or + /// less when the page would exceed . + /// + internal static int PageDpi(double width, double height, int dpi) + { + var pixels = width / 72 * dpi * (height / 72 * dpi); + if (pixels <= MaxPagePixels || pixels <= 0) + return dpi; + return Math.Max(1, (int)Math.Floor(dpi * Math.Sqrt(MaxPagePixels / pixels))); + } + + /// Consecutive pages with the same resolution. + private static List<(int Dpi, List Pages)> Runs(IReadOnlyList pages, Func dpiOf) + { + var runs = new List<(int Dpi, List Pages)>(); + foreach (var page in pages) + { + var dpi = dpiOf(page); + if (runs.Count == 0 || runs[^1].Dpi != dpi) + runs.Add((dpi, [])); + runs[^1].Pages.Add(page); + } + return runs; + } + + /// + /// The page's size in points as it is shown: PDFium turns pages that are stored rotated, so the rendering tells + /// whether width and height swap. + /// + private static (double Width, double Height) Shown(System.Drawing.SizeF size, SKBitmap bitmap) + { + double width = size.Width, height = size.Height; + var landscapePage = width > height; + var landscapePicture = bitmap.Width > bitmap.Height; + return width != height && bitmap.Width != bitmap.Height && landscapePage != landscapePicture ? (height, width) : (width, height); + } + + /// + /// Text of every page for the speaker notes, or null when PdfPig can't read the file: the pictures (PDFium) are the + /// conversion, the notes an extra that must not make it fail. + /// + private static IReadOnlyList? PageTexts(string path, IReadOnlyList pages, CancellationToken cancellationToken) + { + try + { + return PdfDocumentReader.PageTexts(path, pages, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return null; + } + } +} diff --git a/src/Filee.Engines/Pdf/PictureDeckWriter.cs b/src/Filee.Engines/Pdf/PictureDeckWriter.cs new file mode 100644 index 00000000..9cc1009c --- /dev/null +++ b/src/Filee.Engines/Pdf/PictureDeckWriter.cs @@ -0,0 +1,367 @@ +// Writes a PowerPoint presentation (PPTX, PresentationML / ECMA-376) whose slides each show one picture, fitted and +// centred, with optional speaker notes: the package PDF → PPTX needs. Written by hand like the DOCX writer (the Open XML +// SDK is only used by the tests, which validate the output with it), as small as PowerPoint, LibreOffice Impress and +// Keynote accept: presentation, one slide master with one blank layout, a theme, presentation / view / table style +// properties, the slides with their pictures, a notes master and notes slides when there is text, and the document +// properties. +// +// Slides and pictures are written into the ZIP as they come, so a long PDF never has all its pages in memory; the +// parts that list them (presentation, content types) follow at the end. + +using System.Globalization; +using System.IO.Compression; +using System.Text; +using Filee.Engines.Hwp.Hwpx; + +namespace Filee.Engines.Pdf; + +/// A PPTX of picture slides, written slide by slide. +internal sealed class PictureDeckWriter : IDisposable +{ + /// EMU (English Metric Units) per point: 914400 per inch. + public const double EmuPerPoint = 12700; + + /// Smallest slide side PowerPoint allows (1 inch, ST_SlideSizeCoordinate). + public const long MinSlideSide = 914400; + + /// Largest slide side PowerPoint allows (56 inches, ST_SlideSizeCoordinate). + public const long MaxSlideSide = 51206400; + + private const string Declaration = ""; + private const string Relationships = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"; + private const string PresentationTypes = "application/vnd.openxmlformats-officedocument.presentationml."; + + /// Namespaces of every PresentationML part. + private const string Namespaces = + "xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" " + + "xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" " + + "xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\""; + + /// The group every shape tree starts with. + private const string TreeStart = + "" + + ""; + + private const string ColorMap = + ""; + + /// Notes pages are portrait Letter-like, as in PowerPoint (7.5 × 10 inches). + private const long NotesWidth = 6858000; + private const long NotesHeight = 9144000; + + private readonly string _path; + private readonly string _temp; + private readonly FileStream _file; + private readonly ZipArchive _zip; + private readonly List _slideHasNotes = []; + private bool _saved; + + /// The PPTX to write; it appears only once has finished. + /// Slide width in EMU, within ... + /// Slide height in EMU. + public PictureDeckWriter(string path, long slideWidth, long slideHeight) + { + _path = path; + SlideWidth = Math.Clamp(slideWidth, MinSlideSide, MaxSlideSide); + SlideHeight = Math.Clamp(slideHeight, MinSlideSide, MaxSlideSide); + _temp = path + ".tmp"; + _file = File.Create(_temp); + _zip = new ZipArchive(_file, ZipArchiveMode.Create); + } + + public long SlideWidth { get; } + public long SlideHeight { get; } + + /// + /// Slide size for a page of × points: the page's own size, so an + /// A4 page gives an A4 portrait slide and a 16:9 page a 16:9 slide. Pages larger than PowerPoint's 56 inches are + /// scaled down and pages smaller than 1 inch scaled up, keeping the shape as far as those limits allow. + /// + public static (long Width, long Height) SlideSize(double width, double height) + { + double cx = Math.Max(1, width) * EmuPerPoint; + double cy = Math.Max(1, height) * EmuPerPoint; + var shrink = Math.Min(1, MaxSlideSide / Math.Max(cx, cy)); + cx *= shrink; + cy *= shrink; + var grow = Math.Max(1, MinSlideSide / Math.Min(cx, cy)); + cx *= grow; + cy *= grow; + return (Math.Clamp((long)Math.Round(cx), MinSlideSide, MaxSlideSide), Math.Clamp((long)Math.Round(cy), MinSlideSide, MaxSlideSide)); + } + + /// + /// Where a picture of × (any unit) goes on the slide: as large as + /// fits without cropping, centred (EMU). + /// + public (long X, long Y, long Width, long Height) Fit(double width, double height) + { + var scale = Math.Min(SlideWidth / Math.Max(width, 1e-6), SlideHeight / Math.Max(height, 1e-6)); + var cx = Math.Clamp((long)Math.Round(width * scale), 1, SlideWidth); + var cy = Math.Clamp((long)Math.Round(height * scale), 1, SlideHeight); + return ((SlideWidth - cx) / 2, (SlideHeight - cy) / 2, cx, cy); + } + + /// + /// Adds a slide showing , fitted and centred like for a picture of + /// × , with (lines separated by CR LF or + /// LF) as its speaker notes when there are any. + /// + public void AddSlide(ReadOnlySpan jpeg, double width, double height, string name, string? notes) + { + var number = _slideHasNotes.Count + 1; + var hasNotes = !string.IsNullOrWhiteSpace(notes); + _slideHasNotes.Add(hasNotes); + + using (var media = _zip.CreateEntry($"ppt/media/image{number}.jpeg", CompressionLevel.NoCompression).Open()) + media.Write(jpeg); + + var (x, y, cx, cy) = Fit(width, height); + var slide = new StringBuilder($"").Append(TreeStart) + .Append("") + .Append($"") + .Append("") + .Append("") + .Append($"") + .Append("") + .Append(""); + Write($"ppt/slides/slide{number}.xml", slide.ToString()); + List<(string, string, string)> relationships = + [ + ("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml"), + ("rId2", "image", $"../media/image{number}.jpeg"), + ]; + if (hasNotes) + relationships.Add(("rId3", "notesSlide", $"../notesSlides/notesSlide{number}.xml")); + Write($"ppt/slides/_rels/slide{number}.xml.rels", Rels([.. relationships])); + + if (!hasNotes) + return; + var body = new StringBuilder(); + foreach (var line in notes!.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')) + body.Append(line.Length == 0 ? "" : $"{Escape(line)}"); + Write($"ppt/notesSlides/notesSlide{number}.xml", + $"{TreeStart}" + + "" + + "" + + "" + + "" + + $"{body}" + + ""); + Write($"ppt/notesSlides/_rels/notesSlide{number}.xml.rels", Rels( + ("rId1", "notesMaster", "../notesMasters/notesMaster1.xml"), + ("rId2", "slide", $"../slides/slide{number}.xml"))); + } + + /// Writes the parts that list the slides and moves the finished file into place. + public void Save() + { + if (_slideHasNotes.Count == 0) + throw new InvalidOperationException("A presentation needs at least one slide."); + var notes = _slideHasNotes.Contains(true); + var slides = _slideHasNotes.Count; + + // Presentation: master, notes master, slides, sizes. Relationship ids: 1-5 fixed parts, 6 the notes master, + // slides from 7. + var presentation = new StringBuilder($"") + .Append(""); + if (notes) + presentation.Append(""); + presentation.Append(""); + for (var i = 0; i < slides; i++) + presentation.Append($""); + presentation.Append("") + .Append($"") + .Append($"") + .Append(""); + Write("ppt/presentation.xml", presentation.ToString()); + + var relationships = new List<(string, string, string)> + { + ("rId1", "slideMaster", "slideMasters/slideMaster1.xml"), + ("rId2", "theme", "theme/theme1.xml"), + ("rId3", "presProps", "presProps.xml"), + ("rId4", "viewProps", "viewProps.xml"), + ("rId5", "tableStyles", "tableStyles.xml"), + }; + if (notes) + relationships.Add(("rId6", "notesMaster", "notesMasters/notesMaster1.xml")); + for (var i = 0; i < slides; i++) + relationships.Add(($"rId{7 + i}", "slide", $"slides/slide{i + 1}.xml")); + Write("ppt/_rels/presentation.xml.rels", Rels([.. relationships])); + + Write("ppt/slideMasters/slideMaster1.xml", SlideMaster()); + Write("ppt/slideMasters/_rels/slideMaster1.xml.rels", Rels( + ("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml"), + ("rId2", "theme", "../theme/theme1.xml"))); + Write("ppt/slideLayouts/slideLayout1.xml", + $"{TreeStart}" + + ""); + Write("ppt/slideLayouts/_rels/slideLayout1.xml.rels", Rels(("rId1", "slideMaster", "../slideMasters/slideMaster1.xml"))); + Write("ppt/theme/theme1.xml", Theme()); + if (notes) + { + Write("ppt/notesMasters/notesMaster1.xml", NotesMaster()); + // A master of its own needs a theme of its own (PowerPoint's notes masters use theme2 as well). + Write("ppt/notesMasters/_rels/notesMaster1.xml.rels", Rels(("rId1", "theme", "../theme/theme2.xml"))); + Write("ppt/theme/theme2.xml", Theme()); + } + Write("ppt/presProps.xml", $""); + Write("ppt/viewProps.xml", + $"" + + ""); + Write("ppt/tableStyles.xml", + ""); + + var now = DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture); + Write("docProps/core.xml", + "" + + $"{now}" + + $"{now}"); + Write("docProps/app.xml", + "" + + $"Filee{N(slides)}{N(_slideHasNotes.Count(n => n))}"); + + Write("_rels/.rels", Rels( + ("rId1", "officeDocument", "ppt/presentation.xml"), + ("rId2", "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", "docProps/core.xml"), + ("rId3", "extended-properties", "docProps/app.xml"))); + Write("[Content_Types].xml", ContentTypes(slides, notes)); + + _zip.Dispose(); + _file.Dispose(); + File.Move(_temp, _path, overwrite: true); + _saved = true; + } + + public void Dispose() + { + if (_saved) + return; + _zip.Dispose(); + _file.Dispose(); + try { File.Delete(_temp); } catch (IOException) { } + } + + private string ContentTypes(int slides, bool notes) + { + var sb = new StringBuilder("") + .Append("") + .Append("") + .Append(""); + void Override(string part, string type) => sb.Append($""); + Override("ppt/presentation.xml", PresentationTypes + "presentation.main+xml"); + Override("ppt/slideMasters/slideMaster1.xml", PresentationTypes + "slideMaster+xml"); + Override("ppt/slideLayouts/slideLayout1.xml", PresentationTypes + "slideLayout+xml"); + for (var i = 1; i <= slides; i++) + Override($"ppt/slides/slide{i}.xml", PresentationTypes + "slide+xml"); + Override("ppt/theme/theme1.xml", "application/vnd.openxmlformats-officedocument.theme+xml"); + if (notes) + { + Override("ppt/notesMasters/notesMaster1.xml", PresentationTypes + "notesMaster+xml"); + Override("ppt/theme/theme2.xml", "application/vnd.openxmlformats-officedocument.theme+xml"); + for (var i = 1; i <= slides; i++) + { + if (_slideHasNotes[i - 1]) + Override($"ppt/notesSlides/notesSlide{i}.xml", PresentationTypes + "notesSlide+xml"); + } + } + Override("ppt/presProps.xml", PresentationTypes + "presProps+xml"); + Override("ppt/viewProps.xml", PresentationTypes + "viewProps+xml"); + Override("ppt/tableStyles.xml", PresentationTypes + "tableStyles+xml"); + Override("docProps/core.xml", "application/vnd.openxmlformats-package.core-properties+xml"); + Override("docProps/app.xml", "application/vnd.openxmlformats-officedocument.extended-properties+xml"); + return sb.Append("").ToString(); + } + + /// A white background, no placeholders and default text styles: the slides only hold their picture. + private static string SlideMaster() + { + static string Level(string size, string font, string align = "") => + $"" + + $""; + + return $"" + + "" + + $"{TreeStart}{ColorMap}" + + "" + + $"{Level("4400", "mj", " algn=\"ctr\"")}" + + $"{Level("2800", "mn")}{Level("1800", "mn")}" + + ""; + } + + /// The notes page: the slide's picture in the upper part (shaped like the slide), the notes below. + private string NotesMaster() + { + // The slide image fills a 6 × 4 inch box at the top, centred; the notes take the lower half of the page. + const long boxLeft = 685800, boxTop = 457200, boxWidth = 5486400, boxHeight = 3657600; + var scale = Math.Min((double)boxWidth / SlideWidth, (double)boxHeight / SlideHeight); + var cx = (long)Math.Round(SlideWidth * scale); + var cy = (long)Math.Round(SlideHeight * scale); + var x = boxLeft + (boxWidth - cx) / 2; + var y = boxTop + (boxHeight - cy) / 2; + return $"{TreeStart}" + + "" + + "" + + $"" + + "" + + "" + + "" + + "" + + "" + + $"{ColorMap}" + + "" + + "" + + "" + + ""; + } + + /// Office's default colours and fonts; three plain entries in each style list, the least the schema takes. + private static string Theme() + { + static string Color(string name, string value) => $""; + const string fill = ""; + var lines = string.Concat(new[] { "6350", "12700", "19050" }.Select(w => $"{fill}")); + return "" + + "" + + "" + + Color("dk2", "44546A") + Color("lt2", "E7E6E6") + Color("accent1", "4472C4") + Color("accent2", "ED7D31") + + Color("accent3", "A5A5A5") + Color("accent4", "FFC000") + Color("accent5", "5B9BD5") + Color("accent6", "70AD47") + + Color("hlink", "0563C1") + Color("folHlink", "954F72") + "" + + "" + + "" + + "" + + "" + + $"{fill}{fill}{fill}" + + $"{lines}" + + "" + + $"{fill}{fill}{fill}" + + ""; + } + + /// A relationships part; a relationship type without a scheme is one of the officeDocument types. + private static string Rels(params (string Id, string Type, string Target)[] relationships) + { + var sb = new StringBuilder(""); + foreach (var (id, type, target) in relationships) + { + var fullType = type.Contains("://", StringComparison.Ordinal) ? type : Relationships + type; + sb.Append($""); + } + return sb.Append("").ToString(); + } + + private void Write(string name, string xml) + { + using var writer = new StreamWriter(_zip.CreateEntry(name, CompressionLevel.Optimal).Open(), new UTF8Encoding(false)); + writer.Write(Declaration); + writer.Write(xml); + } + + private static string Escape(string text) => HwpxWriter.Escape(text); + + private static string N(long value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/src/Filee.Engines/Vector/GhostscriptConverter.cs b/src/Filee.Engines/Vector/GhostscriptConverter.cs index 8a5da4e1..06f06f5a 100644 --- a/src/Filee.Engines/Vector/GhostscriptConverter.cs +++ b/src/Filee.Engines/Vector/GhostscriptConverter.cs @@ -1,9 +1,11 @@ // PostScript and EPS with Ghostscript (AGPL-3.0, a separate program downloaded on demand into engines/ghostscript). // EPS/PS → PDF with pdfwrite; PDF → EPS/PS with eps2write/ps2write; EPS/PS → PNG with pngalpha, which keeps a // transparent background (through PDF the page would be drawn on white paper). Everything else (EPS → JPG, -// SVG → EPS, …) is planned through PDF or PNG by the route planner. +// SVG → EPS, …) is planned through PDF or PNG by the route planner. "Compress PDF" may also run a PDF through pdfwrite +// (PdfSharpConverter calls CompressPdfAsync; there is no PDF → PDF edge here, so routes don't change). using Filee.Core.Conversion; +using Filee.Core.Presets; using Filee.Engines.Infrastructure; using Filee.Engines.Magick; using ImageMagick; @@ -92,6 +94,26 @@ internal static async Task ConvertToPdfAsync(string gs, string input, string out File.Move(target, output, overwrite: true); } + /// + /// Rewrites a PDF smaller with pdfwrite's settings for the level (/screen, /ebook, /printer: pictures at 72, 150 and + /// 300 dpi, Filee's levels too). Used by for "Compress PDF" when the preset asks + /// for Ghostscript; the caller keeps the result only when it is smaller. + /// + internal static async Task CompressPdfAsync(string gs, string input, string output, PdfCompression level, string workDirectory, + CancellationToken cancellationToken) + { + var settings = level switch + { + PdfCompression.Screen => "/screen", + PdfCompression.Print => "/printer", + _ => "/ebook", + }; + var target = Path.Combine(NewScratch(workDirectory), "out.pdf"); + List args = ["-sDEVICE=pdfwrite", "-dPDFSETTINGS=" + settings, "-dCompatibilityLevel=1.6", "-dAutoRotatePages=/None"]; + await RunAsync(gs, args, target, input, cancellationToken, progress: null); + File.Move(target, output, overwrite: true); + } + private static async Task> ToPdfAsync(string gs, ConversionStep step, CancellationToken ct, IProgress? progress) { var output = step.Output.Allocate("pdf"); diff --git a/src/Filee.Engines/Vector/SvgRenderer.cs b/src/Filee.Engines/Vector/SvgRenderer.cs index e43de355..3ee25d8d 100644 --- a/src/Filee.Engines/Vector/SvgRenderer.cs +++ b/src/Filee.Engines/Vector/SvgRenderer.cs @@ -56,10 +56,23 @@ public static Drawing Load(string path) svg.Settings.EnableJavaScript = false; svg.Settings.EnableExternalJavaScript = false; SKPicture? picture; + var uri = new Uri(Path.GetFullPath(path)); try { - using var stream = new MemoryStream(bytes, writable: false); - picture = svg.Load(stream, null, new Uri(Path.GetFullPath(path))); + picture = Parse(svg, bytes, uri); + } + catch (Exception ex) when (ex is not OutOfMemoryException && SvgXmlRepair.FixAmpersands(bytes) is { } repaired) + { + // "A & B" or   in the text (common in generated SVGs): read it again with those escaped. + try + { + picture = Parse(svg, repaired, uri); + } + catch (Exception retry) when (retry is not OutOfMemoryException) + { + svg.Dispose(); + throw new InvalidDataException("The SVG file could not be read: " + ex.Message, ex); + } } catch (Exception ex) when (ex is not OutOfMemoryException) { @@ -75,6 +88,12 @@ public static Drawing Load(string path) return new Drawing(svg, picture, bounds); } + private static SKPicture? Parse(SKSvg svg, byte[] bytes, Uri baseUri) + { + using var stream = new MemoryStream(bytes, writable: false); + return svg.Load(stream, null, baseUri); + } + /// The file's SVG text as bytes: SVGZ (or a gzipped .svg) is decompressed. public static byte[] ReadSvgBytes(string path) { diff --git a/src/Filee.Engines/Vector/SvgXmlRepair.cs b/src/Filee.Engines/Vector/SvgXmlRepair.cs new file mode 100644 index 00000000..5abdc7f1 --- /dev/null +++ b/src/Filee.Engines/Vector/SvgXmlRepair.cs @@ -0,0 +1,94 @@ +// Repairs the one mistake that makes many hand-written and AI-generated SVGs unreadable: an "&" that isn't an entity +// ("A & B" in a , a URL query in an href) and HTML entities XML doesn't know ( , ©). Browsers forgive +// some of it; the XML parser Svg.Skia uses stops at the first one (issue #120). + +using System.Net; +using System.Text; +using System.Text.RegularExpressions; + +namespace Filee.Engines.Vector; + +internal static partial class SvgXmlRepair +{ + /// The entities every XML document has; anything else must be declared in the DTD. + private static readonly HashSet XmlEntities = ["amp", "lt", "gt", "quot", "apos"]; + + /// + /// Returns with every "&" that doesn't start a character reference or a known entity + /// escaped, and HTML named entities turned into character references. Entities declared in the document's DTD + /// (Illustrator's &ns_svg;), the DOCTYPE, comments and CDATA sections are left alone. Null when nothing needed repair, or + /// when the bytes aren't UTF-8 (another declared encoding is read as it is). + /// + public static byte[]? FixAmpersands(byte[] svg) + { + if (svg.AsSpan().IndexOf((byte)'&') < 0) + return null; + string text; + try + { + text = new UTF8Encoding(false, throwOnInvalidBytes: true).GetString(svg); + } + catch (DecoderFallbackException) + { + return null; + } + + var declared = DeclaredEntity().Matches(text).Select(m => m.Groups[1].Value).ToHashSet(StringComparer.Ordinal); + var result = new StringBuilder(text.Length + 64); + var changed = false; + var position = 0; + foreach (Match skip in Untouched().Matches(text)) + { + changed |= Repair(text.AsSpan(position, skip.Index - position), declared, result); + result.Append(skip.Value); + position = skip.Index + skip.Length; + } + changed |= Repair(text.AsSpan(position), declared, result); + if (!changed) + return null; + return Encoding.UTF8.GetBytes(result.ToString()); // a byte order mark was decoded as U+FEFF and stays + } + + /// Appends to repaired; true if anything changed. + private static bool Repair(ReadOnlySpan part, HashSet declared, StringBuilder result) + { + var changed = false; + var text = part.ToString(); + var last = 0; + foreach (Match amp in Ampersand().Matches(text)) + { + result.Append(text, last, amp.Index - last); + last = amp.Index + amp.Length; + var name = amp.Groups["name"]; + if (amp.Groups["number"].Success || name.Success && (XmlEntities.Contains(name.Value) || declared.Contains(name.Value))) + { + result.Append(amp.Value); + continue; + } + changed = true; + if (name.Success && WebUtility.HtmlDecode(amp.Value) is { } decoded && decoded != amp.Value) + { + // An HTML entity: the same character as a reference XML reads. + foreach (var rune in decoded.EnumerateRunes()) + result.Append("&#").Append(rune.Value).Append(';'); + } + else + { + // A lone "&", or an entity nobody defines: the "&" itself is text. + result.Append("&").Append(amp.Value, 1, amp.Value.Length - 1); + } + } + result.Append(text, last, text.Length - last); + return changed; + } + + // "&", then a character reference, a name with its ";", or neither (a lone "&"). + [GeneratedRegex(@"&(?:(?#[0-9]+;|#[xX][0-9A-Fa-f]+;)|(?[A-Za-z_][A-Za-z0-9._-]*);)?")] + private static partial Regex Ampersand(); + + [GeneratedRegex(@"||]*(?:\[.*?\])?\s*>", RegexOptions.Singleline)] + private static partial Regex Untouched(); + + [GeneratedRegex(@"Elevation checks and the de-elevated restart. Every member is safe to call on any OS. +public static class Elevation +{ + /// Reads the integrity levels of this process and of Explorer. off Windows. + public static ElevationState Probe() + { + if (!OperatingSystem.IsWindows()) + return ElevationState.Unknown; + try + { + var process = LevelOfProcess(NativeMethods.GetCurrentProcess(), out var elevated); + return new ElevationState(process, ShellLevel(), elevated); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return ElevationState.Unknown; + } + } + + /// + /// Starts through Explorer, so it gets Explorer's rights instead of this process's. + /// Returns false when there is no Explorer desktop to ask (another shell, Explorer restarting) or it refused; + /// then says why, in English. + /// + public static bool RelaunchThroughShell(string executable, string arguments, out string? error) + { + if (OperatingSystem.IsWindows()) + return RelaunchOnWindows(executable, arguments, out error); + error = "Not supported on this operating system."; + return false; + } + + [SupportedOSPlatform("windows")] + private static bool RelaunchOnWindows(string executable, string arguments, out string? error) + { + // Explorer's COM objects want a single-threaded apartment; a thread of its own keeps the caller's apartment + // out of it and bounds the wait if Explorer doesn't answer. + string? failure = null; + var thread = new Thread(() => + { + try + { + ShellExecuteFromDesktop(executable, arguments); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Anything Explorer or the COM binder throws: report it, never take Filee down from this thread. + failure = $"{ex.GetType().Name}: {ex.Message}"; + } + }) + { IsBackground = true, Name = "Filee shell relaunch" }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + if (!thread.Join(TimeSpan.FromSeconds(15))) + { + error = "Explorer did not answer."; + return false; + } + error = failure; + return failure is null; + } + + [SupportedOSPlatform("windows")] + private static void ShellExecuteFromDesktop(string executable, string arguments) + { + var shellWindowsType = Type.GetTypeFromCLSID(new Guid("9BA05972-F6A8-11CF-A442-00A0C90A8F39"), throwOnError: true)!; + object? shellWindows = null, desktop = null, browser = null, view = null, background = null; + try + { + shellWindows = Activator.CreateInstance(shellWindowsType) + ?? throw new InvalidOperationException("ShellWindows is not available."); + object location = CSIDL_DESKTOP; + object? empty = null; // VT_EMPTY + desktop = ((IShellWindows)shellWindows).FindWindowSW(ref location, ref empty, SWC_DESKTOP, out _, SWFO_NEEDDISPATCH) + ?? throw new InvalidOperationException("Explorer's desktop window was not found."); + var topLevelBrowser = SID_STopLevelBrowser; + var shellBrowserId = typeof(IShellBrowser).GUID; + browser = ((IServiceProvider)desktop).QueryService(ref topLevelBrowser, ref shellBrowserId); + view = ((IShellBrowser)browser).QueryActiveShellView(); + var dispatchId = IID_IDispatch; + background = ((IShellView)view).GetItemObject(SVGIO_BACKGROUND, ref dispatchId); + // IShellFolderViewDual.Application is Explorer's IShellDispatch2: its ShellExecute runs in Explorer. + dynamic folderView = background; + dynamic application = folderView.Application; + try + { + application.ShellExecute(executable, arguments, Path.GetDirectoryName(executable) ?? "", "", SW_SHOWNORMAL); + } + finally + { + Marshal.FinalReleaseComObject((object)application); + } + } + finally + { + // Released here, on their apartment's thread, which ends right after. Two of them may be one object. + foreach (var item in new[] { background, view, browser, desktop, shellWindows } + .OfType().Where(Marshal.IsComObject).Distinct(ReferenceEqualityComparer.Instance)) + Marshal.FinalReleaseComObject(item); + } + } + + /// Shows a plain Windows message box (for problems before Filee's own windows can open). No-op off Windows. + public static void ShowWarning(string text) + { + if (OperatingSystem.IsWindows()) + NativeMethods.MessageBox(0, text, "Filee", + NativeMethods.MB_OK | NativeMethods.MB_ICONWARNING | NativeMethods.MB_SETFOREGROUND | NativeMethods.MB_TOPMOST); + } + + [SupportedOSPlatform("windows")] + private static IntegrityLevel ShellLevel() + { + var desktop = NativeMethods.GetShellWindow(); + if (desktop == 0) + return IntegrityLevel.Unknown; + NativeMethods.GetWindowThreadProcessId(desktop, out var pid); + if (pid == 0) + return IntegrityLevel.Unknown; + var process = NativeMethods.OpenProcess(NativeMethods.PROCESS_QUERY_LIMITED_INFORMATION, false, pid); + if (process == 0) + return IntegrityLevel.Unknown; + try + { + return LevelOfProcess(process, out _); + } + finally + { + NativeMethods.CloseHandle(process); + } + } + + [SupportedOSPlatform("windows")] + private static unsafe IntegrityLevel LevelOfProcess(nint process, out bool elevated) + { + elevated = false; + if (!NativeMethods.OpenProcessToken(process, NativeMethods.TOKEN_QUERY, out var token)) + return IntegrityLevel.Unknown; + try + { + uint isElevated = 0; + if (NativeMethods.GetTokenInformation(token, NativeMethods.TokenElevation, &isElevated, sizeof(uint), out _)) + elevated = isElevated != 0; + + // TOKEN_MANDATORY_LABEL: a SID_AND_ATTRIBUTES whose SID points into the same buffer. + NativeMethods.GetTokenInformation(token, NativeMethods.TokenIntegrityLevel, null, 0, out var length); + if (length == 0) + return IntegrityLevel.Unknown; + var buffer = stackalloc byte[(int)length]; + if (!NativeMethods.GetTokenInformation(token, NativeMethods.TokenIntegrityLevel, buffer, length, out _)) + return IntegrityLevel.Unknown; + var sid = *(nint*)buffer; + var count = *(byte*)NativeMethods.GetSidSubAuthorityCount(sid); + if (count == 0) + return IntegrityLevel.Unknown; + var rid = *(uint*)NativeMethods.GetSidSubAuthority(sid, (uint)(count - 1)); + return ElevationState.FromRid(rid); + } + finally + { + NativeMethods.CloseHandle(token); + } + } + + private const int CSIDL_DESKTOP = 0; + private const int SWC_DESKTOP = 8; + private const int SWFO_NEEDDISPATCH = 1; + private const uint SVGIO_BACKGROUND = 0; + private const int SW_SHOWNORMAL = 1; + private static readonly Guid SID_STopLevelBrowser = new("4C96BE40-915C-11CF-99D3-00AA004AE837"); + private static readonly Guid IID_IDispatch = new("00020400-0000-0000-C000-000000000046"); + + // Only the methods Filee calls are typed; the slots before them keep the vtable order and are never called. + + [ComImport, Guid("85CB6900-4D95-11CF-960C-0080C7F4EE85"), InterfaceType(ComInterfaceType.InterfaceIsDual)] + private interface IShellWindows + { + void get_Count(); + void Item(); + void _NewEnum(); + void Register(); + void RegisterPending(); + void Revoke(); + void OnNavigate(); + void OnActivated(); + + [return: MarshalAs(UnmanagedType.IDispatch)] + object? FindWindowSW(ref object location, ref object? locationRoot, int windowClass, out int hwnd, int options); + } + + [ComImport, Guid("6D5140C1-7436-11CE-8034-00AA006009FA"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IServiceProvider + { + [return: MarshalAs(UnmanagedType.IUnknown)] + object QueryService(ref Guid service, ref Guid riid); + } + + [ComImport, Guid("000214E2-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellBrowser + { + void GetWindow(); + void ContextSensitiveHelp(); + void InsertMenusSB(); + void SetMenuSB(); + void RemoveMenusSB(); + void SetStatusTextSB(); + void EnableModelessSB(); + void TranslateAcceleratorSB(); + void BrowseObject(); + void GetViewStateStream(); + void GetControlWindow(); + void SendControlMsg(); + + [return: MarshalAs(UnmanagedType.IUnknown)] + object QueryActiveShellView(); + } + + [ComImport, Guid("000214E3-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellView + { + void GetWindow(); + void ContextSensitiveHelp(); + void TranslateAccelerator(); + void EnableModeless(); + void UIActivate(); + void Refresh(); + void CreateViewWindow(); + void DestroyViewWindow(); + void GetCurrentInfo(); + void AddPropertySheetPages(); + void SaveViewState(); + void SelectItem(); + + [return: MarshalAs(UnmanagedType.IUnknown)] + object GetItemObject(uint item, ref Guid riid); + } +} diff --git a/src/Filee.Platform.Windows/NativeMethods.cs b/src/Filee.Platform.Windows/NativeMethods.cs index b4521c18..e936aaf1 100644 --- a/src/Filee.Platform.Windows/NativeMethods.cs +++ b/src/Filee.Platform.Windows/NativeMethods.cs @@ -78,6 +78,47 @@ public unsafe struct PACKAGE_ID [LibraryImport("kernel32.dll")] public static unsafe partial int PackageFamilyNameFromId(PACKAGE_ID* packageId, ref uint packageFamilyNameLength, char* packageFamilyName); + // Tokens and integrity levels (Elevation). + public const uint TOKEN_QUERY = 0x0008; + public const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; + public const int TokenElevation = 20; + public const int TokenIntegrityLevel = 25; + + [LibraryImport("kernel32.dll")] + public static partial nint GetCurrentProcess(); + + [LibraryImport("kernel32.dll", SetLastError = true)] + public static partial nint OpenProcess(uint access, [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, uint processId); + + [LibraryImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool CloseHandle(nint handle); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool OpenProcessToken(nint process, uint access, out nint token); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static unsafe partial bool GetTokenInformation(nint token, int informationClass, void* information, uint length, out uint returnLength); + + /// Pointer to the sub-authority count (a byte) inside a SID. + [LibraryImport("advapi32.dll")] + public static partial nint GetSidSubAuthorityCount(nint sid); + + /// Pointer to sub-authority (a DWORD) inside a SID. + [LibraryImport("advapi32.dll")] + public static partial nint GetSidSubAuthority(nint sid, uint index); + + /// The desktop window of the shell (Explorer's Progman), or 0 when no shell runs. + [LibraryImport("user32.dll")] + public static partial nint GetShellWindow(); + + public const uint MB_OK = 0x0, MB_ICONWARNING = 0x30, MB_SETFOREGROUND = 0x10000, MB_TOPMOST = 0x40000; + + [LibraryImport("user32.dll", EntryPoint = "MessageBoxW", StringMarshalling = StringMarshalling.Utf16)] + public static partial int MessageBox(nint owner, string text, string caption, uint type); + /// Returns the Win32 class name of a window ("CabinetWClass" for Explorer, ...). public static string ClassNameOf(nint hwnd) { diff --git a/src/Filee.Platform.Windows/WindowsPlatformServices.cs b/src/Filee.Platform.Windows/WindowsPlatformServices.cs index 103f2558..16c3bb6d 100644 --- a/src/Filee.Platform.Windows/WindowsPlatformServices.cs +++ b/src/Filee.Platform.Windows/WindowsPlatformServices.cs @@ -105,6 +105,17 @@ public IReadOnlyList GetFileManagerSelection() return []; } + public string DescribeForegroundWindow() + { + var foreground = NativeMethods.GetForegroundWindow(); + if (foreground == 0) + return "no foreground window"; + var root = NativeMethods.GetAncestor(foreground, NativeMethods.GA_ROOT); + if (root == 0) + root = foreground; + return $"{NativeMethods.ClassNameOf(root)} / {ProcessNameOf(root) ?? "?"}"; + } + public int SystemDragThreshold => Math.Max(2, NativeMethods.GetSystemMetrics(NativeMethods.SM_CXDRAG)); public bool PrefersReducedMotion => diff --git a/tests/Filee.App.Tests/DiagnosticsTests.cs b/tests/Filee.App.Tests/DiagnosticsTests.cs new file mode 100644 index 00000000..036e85ca --- /dev/null +++ b/tests/Filee.App.Tests/DiagnosticsTests.cs @@ -0,0 +1,288 @@ +// The log lines that explain a Filee which seems to ignore everything: UI thread stalls, bursts kept short, the +// start-up line, and the restart without administrator rights. + +using Avalonia.Headless.XUnit; +using Filee.App.Services; +using Filee.Core.Platform; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.App.Tests; + +/// A clock the test moves by hand. +internal sealed class ManualClock : TimeProvider +{ + public DateTimeOffset Now { get; set; } = new(2026, 10, 18, 12, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => Now; + + public void Advance(TimeSpan by) => Now += by; +} + +/// Collects formatted log lines. +internal sealed class ListLogger : ILogger +{ + public List<(LogLevel Level, string Message)> Lines { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (Lines) + Lines.Add((logLevel, formatter(state, exception))); + } +} + +public class UiWatchdogTests +{ + private readonly ManualClock _clock = new(); + private readonly ListLogger _log = new(); + private readonly Queue _uiQueue = new(); + + private UiWatchdog Create() => new(_uiQueue.Enqueue, _log, _clock, TimeSpan.FromSeconds(5)); + + /// Ticks once a second for seconds while the UI thread runs nothing. + private static void TickFor(UiWatchdog watchdog, ManualClock clock, int seconds) + { + for (var i = 0; i < seconds; i++) + { + clock.Advance(TimeSpan.FromSeconds(1)); + watchdog.Tick(); + } + } + + private void RunUi() + { + while (_uiQueue.TryDequeue(out var action)) + action(); + } + + [Fact] + public void A_responsive_ui_thread_logs_nothing() + { + var watchdog = Create(); + for (var i = 0; i < 20; i++) + { + _clock.Advance(TimeSpan.FromSeconds(1)); + watchdog.Tick(); + RunUi(); + } + Assert.Empty(_log.Lines); + Assert.Empty(_uiQueue); + } + + [Fact] + public void A_stall_is_reported_once_and_its_end_with_the_length() + { + var watchdog = Create(); + watchdog.Tick(); // ping sent at 0 s + TickFor(watchdog, _clock, 4); + Assert.Empty(_log.Lines); // 4 s: under the threshold + + TickFor(watchdog, _clock, 8); // 12 s + var warning = Assert.Single(_log.Lines); + Assert.Equal(LogLevel.Warning, warning.Level); + Assert.Equal("UI thread blocked for 6 s", warning.Message); // the first tick past 5 s + Assert.Single(_uiQueue); // no second ping while the first waits + + _clock.Advance(TimeSpan.FromSeconds(0.5)); + RunUi(); + Assert.Equal(2, _log.Lines.Count); + Assert.Equal(LogLevel.Information, _log.Lines[1].Level); + Assert.Equal($"UI thread responsive again after {12.5:0.0} s", _log.Lines[1].Message); + } + + [Fact] + public void Each_stall_is_a_new_episode() + { + var watchdog = Create(); + for (var episode = 0; episode < 2; episode++) + { + watchdog.Tick(); + TickFor(watchdog, _clock, 7); + RunUi(); + // Quiet seconds between the stalls. + for (var i = 0; i < 3; i++) + { + _clock.Advance(TimeSpan.FromSeconds(1)); + watchdog.Tick(); + RunUi(); + } + } + Assert.Equal(2, _log.Lines.Count(l => l.Level == LogLevel.Warning)); + Assert.Equal(2, _log.Lines.Count(l => l.Level == LogLevel.Information)); + } + + [Fact] + public void A_dispatcher_that_has_shut_down_does_not_break_the_timer() + { + var watchdog = new UiWatchdog(_ => throw new InvalidOperationException("shut down"), _log, _clock); + watchdog.Tick(); + TickFor(watchdog, _clock, 10); + Assert.Empty(_log.Lines); + } +} + +public class LogThrottleTests +{ + [Fact] + public void Logs_the_first_then_one_per_interval_counting_the_rest() + { + var clock = new ManualClock(); + var throttle = new LogThrottle(TimeSpan.FromSeconds(30), clock); + + Assert.True(throttle.TryEnter("forwarded", out var held)); + Assert.Equal(0, held); + for (var i = 0; i < 99; i++) + { + clock.Advance(TimeSpan.FromMilliseconds(100)); + Assert.False(throttle.TryEnter("forwarded", out held)); + } + Assert.True(throttle.TryEnter("other kind", out _)); // kinds are counted apart + + clock.Advance(TimeSpan.FromSeconds(30)); + Assert.True(throttle.TryEnter("forwarded", out held)); + Assert.Equal(99, held); + Assert.Equal(" (99 more since the last one)", LogThrottle.HeldText(held)); + Assert.Equal("", LogThrottle.HeldText(0)); + } +} + +public class StartupDiagnosticsTests +{ + [Fact] + public void The_startup_line_names_version_path_kind_rights_os_and_argument_count() + { + var line = StartupDiagnostics.Describe("1.11.0", "/opt/filee/Filee", "portable", + new ElevationState(IntegrityLevel.High, IntegrityLevel.Medium, true), "Microsoft Windows 10.0.26200 X64", 3); + Assert.Equal("Filee 1.11.0 starting: /opt/filee/Filee (portable), integrity High (elevated), Explorer Medium, " + + "Microsoft Windows 10.0.26200 X64, 3 argument(s)", line); + } + + [Fact] + public void An_unknown_path_and_state_still_give_a_line() + { + var line = StartupDiagnostics.Describe("1.0.0", null, "development build", ElevationState.Unknown, "Linux", 0); + Assert.Equal("Filee 1.0.0 starting: (unknown path) (development build), integrity unknown, Linux, 0 argument(s)", line); + } + + [Theory] + [InlineData(true, true, "installed")] + [InlineData(true, false, "installed")] + [InlineData(false, true, "portable")] + [InlineData(false, false, "development build")] + public void Install_kind(bool installed, bool release, string expected) => + Assert.Equal(expected, StartupDiagnostics.InstallKind(installed, release)); + + [Fact] + public void The_operating_system_text_is_not_empty() => + Assert.False(string.IsNullOrWhiteSpace(StartupDiagnostics.OperatingSystemText())); + + [Fact] + public void The_early_logger_writes_to_the_daily_file() + { + var folder = Directory.CreateTempSubdirectory("filee-early-log-").FullName; + try + { + var log = StartupDiagnostics.CreateEarlyLogger(folder); + log.LogInformation("{Startup}", "Filee test starting"); + var file = Assert.Single(Directory.GetFiles(folder, "filee-*.log")); + Assert.Contains("Program: Filee test starting", File.ReadAllText(file)); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + + [Fact] + public void Launch_problems_are_shown_in_the_system_language_without_avalonia() + { + // Program shows these before Avalonia starts, so they are read without Avalonia's asset service. + foreach (var key in new[] { "startup.elevated_other", "startup.no_answer" }) + { + var text = Services.LocalizationService.EarlyText(key); + Assert.NotEqual(key, text); + Assert.False(string.IsNullOrWhiteSpace(text)); + } + } +} + +public class ElevationServiceTests +{ + private readonly List _calls = []; + + private ElevationService Create(string? relaunchError) => new(NullLogger.Instance, + new ElevationState(IntegrityLevel.High, IntegrityLevel.Medium, true), + exe => + { + _calls.Add("relaunch " + exe); + return relaunchError; + }, + () => _calls.Add("release"), () => _calls.Add("reacquire"), () => _calls.Add("shutdown")); + + [Fact] + public void The_restart_lets_go_of_the_lock_before_starting_the_new_filee_then_quits() + { + var elevation = Create(relaunchError: null); + Assert.True(elevation.ShellCannotReachFilee); + + Assert.True(elevation.RestartWithoutAdministratorRights("/apps/Filee")); + + Assert.Equal(["release", "relaunch /apps/Filee", "shutdown"], _calls); + Assert.False(elevation.RestartFailed); + } + + [Fact] + public void A_failed_restart_takes_the_lock_back_keeps_running_and_says_so() + { + var elevation = Create(relaunchError: "Explorer's desktop window was not found."); + var changed = new List(); + elevation.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + Assert.False(elevation.RestartWithoutAdministratorRights("/apps/Filee")); + + Assert.Equal(["release", "relaunch /apps/Filee", "reacquire"], _calls); + Assert.True(elevation.RestartFailed); + Assert.Contains(nameof(ElevationService.RestartFailed), changed); + } + + [Fact] + public void Not_elevated_shows_no_warning() + { + var elevation = new ElevationService(NullLogger.Instance, + new ElevationState(IntegrityLevel.Medium, IntegrityLevel.Medium, false), _ => null, () => { }, () => { }, () => { }); + Assert.False(elevation.ShellCannotReachFilee); + } + + [AvaloniaFact] + public void The_home_page_warns_and_shows_a_failed_restart() + { + TestServices.EnsureInitialized(); + var elevation = Create(relaunchError: "Explorer did not answer."); + using var page = new ViewModels.Pages.HomePageViewModel(AppHost.Get(), + AppHost.Get(), AppHost.Get(), elevation: elevation); + var changed = new List(); + page.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + Assert.True(page.IsElevated); + Assert.False(page.ElevationRestartFailed); + + page.RestartWithoutAdministratorRightsCommand.Execute(null); + + Assert.True(page.ElevationRestartFailed); + Assert.Contains(nameof(page.ElevationRestartFailed), changed); + Assert.DoesNotContain("shutdown", _calls); + } + + [AvaloniaFact] + public void The_home_page_has_no_warning_by_default() + { + TestServices.EnsureInitialized(); + using var page = new ViewModels.Pages.HomePageViewModel(AppHost.Get(), + AppHost.Get(), AppHost.Get(), + elevation: AppHost.Get()); // tests never run Program: the state stays unknown + Assert.False(page.IsElevated); + } +} diff --git a/tests/Filee.App.Tests/HookFailureTests.cs b/tests/Filee.App.Tests/HookFailureTests.cs new file mode 100644 index 00000000..b2a21860 --- /dev/null +++ b/tests/Filee.App.Tests/HookFailureTests.cs @@ -0,0 +1,25 @@ +using Filee.App.Services.Triggers; +using Filee.App.ViewModels.Pages; +using SharpHook; +using SharpHook.Data; + +namespace Filee.App.Tests; + +public class HookFailureTests +{ + [Fact] + public void A_Linux_back_end_that_does_not_load_is_recognised() + { + Assert.True(TriggerService.IsMissingLinuxBackend(new HookException(UioHookResult.ErrorLinuxLoadBackend))); + Assert.False(TriggerService.IsMissingLinuxBackend(new HookException(UioHookResult.ErrorAxApiDisabled))); + Assert.False(TriggerService.IsMissingLinuxBackend(new InvalidOperationException("ErrorLinuxLoadBackend"))); + } + + [Fact] + public void A_missing_Linux_back_end_is_explained_instead_of_showing_the_raw_error() + { + var loc = new KeyLocalizer(); + Assert.Equal("triggers.hook_linux_backend", TriggersPageViewModel.HookFailure(loc, "ErrorLinuxLoadBackend", linuxBackendMissing: true)); + Assert.Equal("triggers.hook_failed|denied", TriggersPageViewModel.HookFailure(loc, "denied", linuxBackendMissing: false)); + } +} diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs index aabfe58b..de6778d4 100644 --- a/tests/Filee.App.Tests/RenderTests.cs +++ b/tests/Filee.App.Tests/RenderTests.cs @@ -661,6 +661,33 @@ public void Preset_editor_keeps_rotation_visible_for_pdf_and_drops_it_for_target Assert.Equal(Filee.Core.Presets.ImageFlip.None, toVideo.Image.Flip); } + [AvaloniaFact] + public void Preset_editor_saves_every_frame_only_for_png_and_jpg() + { + TestServices.EnsureInitialized("en"); + var loc = AppHost.Get(); + + var preset = new Filee.Core.Presets.Preset { TargetFormat = "jpg" }; + var vm = new PresetEditorViewModel(preset, loc); + Assert.True(vm.ShowSaveEveryFrame); + vm.SaveEveryFrame = true; + vm.FrameInterval = 5; + Assert.Equal("One minute of 30 fps video gives 360 pictures, at most 100,000 per video.", vm.FrameEstimate); + vm.FrameInterval = 5000; // out of range: clamped + Assert.True(vm.Apply()); + Assert.True(preset.Media.SaveEveryFrame); + Assert.Equal(1000, preset.Media.FrameInterval); + + // Ticked, then another target: nothing on screen says so any more, so it isn't saved. + vm = new PresetEditorViewModel(preset, loc); + Assert.True(vm.SaveEveryFrame); + vm.Target = vm.Targets.Single(t => t.Value == "webp"); + Assert.False(vm.ShowSaveEveryFrame); + Assert.True(vm.Apply()); + Assert.False(preset.Media.SaveEveryFrame); + Assert.Equal(1, preset.Media.FrameInterval); + } + [AvaloniaFact] public void Preset_editor_shows_and_saves_background_removal() { @@ -684,6 +711,37 @@ public void Preset_editor_shows_and_saves_background_removal() Assert.True(preset.Image.KeepEnclosedBackground); } + [AvaloniaFact] + public void Preset_editor_shows_and_saves_pdf_compression() + { + TestServices.EnsureInitialized("ko"); + var preset = Filee.Core.Presets.BuiltInData.CreatePresets().Single(p => p.Id == "compress-pdf"); + var vm = new PresetEditorViewModel(preset, AppHost.Get()); + Assert.True(vm.ShowPdfCompression); + Assert.True(vm.ShowPdfCompressionOptions); + Assert.Equal(Filee.Core.Presets.PdfCompression.Ebook, vm.CompressionLevel.Value); + Assert.Equal(["None", "Screen", "Ebook", "Print"], vm.CompressionLevels.Select(c => c.Value.ToString())); + var window = new PresetEditorWindow { DataContext = vm, Height = 900 }; + window.Show(); + Pump(); + Save(window, "preset-compress-pdf-ko.png"); + window.Close(); + + vm.CompressionLevel = vm.CompressionLevels.Single(c => c.Value == Filee.Core.Presets.PdfCompression.Screen); + vm.CompressWithGhostscript = true; + vm.KeepMetadata = false; + Assert.True(vm.Apply()); + Assert.Equal(Filee.Core.Presets.PdfCompression.Screen, preset.Pdf.Compression); + Assert.True(preset.Pdf.CompressWithGhostscript); + Assert.False(preset.Image.KeepMetadata); + + // Off: Ghostscript and metadata have nothing to do. Other targets don't compress PDFs at all. + vm.CompressionLevel = vm.CompressionLevels[0]; + Assert.False(vm.ShowPdfCompressionOptions); + vm.Target = vm.Targets.Single(t => t.Value == "png"); + Assert.False(vm.ShowPdfCompression); + } + [AvaloniaTheory] [InlineData("en")] [InlineData("ko")] @@ -1011,6 +1069,37 @@ public void Preset_editor_saves_media_and_archive_options() Assert.Equal("600p", new PresetEditorViewModel(preset, AppHost.Get()).MaxHeight.Label); } + [AvaloniaFact] + public void Preset_editor_offers_hardware_encoding_for_h264_and_hevc() + { + TestServices.EnsureInitialized("en"); + var preset = new Filee.Core.Presets.Preset { TargetFormat = "mp4", Name = "Fast" }; + var vm = new PresetEditorViewModel(preset, AppHost.Get()); + Assert.True(vm.ShowHardwareEncoding); + Assert.False(vm.HardwareEncoding); // off unless asked for + vm.HardwareEncoding = true; + + // AV1 and VP9 have no hardware path; HEVC does. + vm.VideoCodecChoice = vm.VideoCodecs.Single(c => c.Value == Filee.Core.Presets.VideoCodec.Av1); + Assert.False(vm.ShowHardwareEncoding); + vm.VideoCodecChoice = vm.VideoCodecs.Single(c => c.Value == Filee.Core.Presets.VideoCodec.Hevc); + Assert.True(vm.ShowHardwareEncoding); + vm.Target = vm.Targets.Single(t => t.Value == "webm"); + Assert.False(vm.ShowHardwareEncoding); + vm.Target = vm.Targets.Single(t => t.Value == "avi"); + Assert.False(vm.ShowHardwareEncoding); + vm.Target = vm.Targets.Single(t => t.Value == "mp3"); + Assert.False(vm.ShowHardwareEncoding); + vm.Target = vm.Targets.Single(t => t.Value == "ts"); + Assert.True(vm.ShowHardwareEncoding); + vm.Target = vm.Targets.Single(t => t.Value == "mov"); + Assert.True(vm.Apply()); + Assert.True(preset.Media.HardwareEncoding); + Assert.Equal(Filee.Core.Presets.VideoCodec.Hevc, preset.Media.VideoCodec); + + Assert.True(new PresetEditorViewModel(preset, AppHost.Get()).HardwareEncoding); + } + [AvaloniaFact] public void Preset_editor_offers_the_codecs_each_target_can_hold() { diff --git a/tests/Filee.App.Tests/SelectionGuardTests.cs b/tests/Filee.App.Tests/SelectionGuardTests.cs new file mode 100644 index 00000000..5ecc80da --- /dev/null +++ b/tests/Filee.App.Tests/SelectionGuardTests.cs @@ -0,0 +1,282 @@ +// Combo boxes and the navigation list keep their selection when the selected item is picked again with the toggle +// modifier (Ctrl+Space / Ctrl+click, Cmd on macOS), and the settings behind them never become null (#107). + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.App.ViewModels.Pages; +using Filee.App.Views; +using Filee.App.Views.Pages; +using Filee.Core.Localization; +using Filee.Core.Platform; +using Filee.Core.Presets; +using Filee.Core.Settings; + +namespace Filee.App.Tests; + +public class SelectionGuardTests +{ + private static void Pump() + { + for (var i = 0; i < 10; i++) + { + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + } + } + + /// Ctrl on Windows and Linux, Cmd on macOS: what Avalonia reads as "toggle the selection". + private static RawInputModifiers Toggle(TopLevel top) => + (RawInputModifiers)(top.GetPlatformSettings() ?? Application.Current!.PlatformSettings)!.HotkeyConfiguration.CommandModifiers; + + private static void CtrlSpace(Window window) + { + window.KeyPress(Key.Space, Toggle(window), PhysicalKey.Space, " "); + window.KeyRelease(Key.Space, Toggle(window), PhysicalKey.Space, " "); + Pump(); + } + + private static Point Middle(Control control, Visual to) => + control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), to)!.Value; + + private static (Window Window, TriggerGestureViewModel Gesture, ComboBox Kind) ShowTriggers() + { + TestServices.EnsureInitialized("en"); + var vm = new TriggersPageViewModel(AppHost.Get(), AppHost.Get()); + var window = new Window { Content = new TriggersPage { DataContext = vm }, Width = 900, Height = 900 }; + window.Show(); + Pump(); + var gesture = vm.Gestures[0]; + var kind = window.GetVisualDescendants().OfType().First(c => ReferenceEquals(c.ItemsSource, gesture.Kinds)); + return (window, gesture, kind); + } + + [AvaloniaFact] + public void Ctrl_space_twice_on_a_combo_box_keeps_its_choice() + { + var (window, gesture, kind) = ShowTriggers(); + try + { + var before = gesture.Kind; + var changes = 0; + kind.SelectionChanged += (_, _) => changes++; + Assert.True(kind.Focus(NavigationMethod.Tab)); + + CtrlSpace(window); // opens the drop-down on the selected item + CtrlSpace(window); // picks that item again: used to clear the selection + CtrlSpace(window); + CtrlSpace(window); + + Assert.Same(before, gesture.Kind); + Assert.Same(before, kind.SelectedItem); + Assert.Equal(0, changes); // never cleared, not just put back + Assert.False(kind.IsDropDownOpen); + Assert.Equal(before.Value, gesture.Gesture.Kind); + Assert.Empty(DataValidationErrors.GetErrors(kind) ?? []); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Ctrl_clicking_the_selected_item_of_an_open_combo_box_keeps_it_and_closes_the_drop_down() + { + var (window, gesture, kind) = ShowTriggers(); + try + { + var before = gesture.Kind; + kind.IsDropDownOpen = true; + Pump(); + var changes = 0; + kind.SelectionChanged += (_, _) => changes++; + var item = (ComboBoxItem)kind.ContainerFromItem(before)!; + var root = TopLevel.GetTopLevel(item)!; + var at = Middle(item, root); + root.MouseDown(at, MouseButton.Left, Toggle(root)); + root.MouseUp(at, MouseButton.Left, Toggle(root)); + Pump(); + + Assert.Same(before, gesture.Kind); + Assert.Same(before, kind.SelectedItem); + Assert.Equal(0, changes); + Assert.False(kind.IsDropDownOpen); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void A_combo_box_cleared_anyway_shows_its_choice_again() + { + var (window, gesture, kind) = ShowTriggers(); + try + { + var before = gesture.Kind; + kind.SelectedItem = null; // e.g. an Avalonia path the guard does not know + Pump(); + + Assert.Same(before, gesture.Kind); + Assert.Same(before, kind.SelectedItem); + Assert.Empty(DataValidationErrors.GetErrors(kind) ?? []); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Ctrl_clicking_the_open_page_in_the_navigation_keeps_it_open() + { + TestServices.EnsureInitialized("en"); + var vm = new MainWindowViewModel(AppHost.Services, AppHost.Get(), AppHost.Get()); + var window = new MainWindow { DataContext = vm, Width = 1080, Height = 820 }; + window.Show(); + Pump(); + try + { + var nav = window.GetVisualDescendants().OfType().First(l => l.Classes.Contains("nav")); + var home = vm.SelectedItem; + var page = vm.CurrentPage; + var changes = 0; + nav.SelectionChanged += (_, _) => changes++; + var at = Middle(nav.ContainerFromItem(home)!, window); + window.MouseDown(at, MouseButton.Left, Toggle(window)); + window.MouseUp(at, MouseButton.Left, Toggle(window)); + Pump(); + Assert.Same(home, vm.SelectedItem); + Assert.Same(page, vm.CurrentPage); + Assert.Equal(0, changes); + + // Ctrl+clicking another page still opens it. + at = Middle(nav.ContainerFromItem(vm.Items[1])!, window); + window.MouseDown(at, MouseButton.Left, Toggle(window)); + window.MouseUp(at, MouseButton.Left, Toggle(window)); + Pump(); + Assert.Same(vm.Items[1], vm.SelectedItem); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Multi_selection_lists_still_toggle() + { + TestServices.EnsureInitialized("en"); + var list = new ListBox { ItemsSource = new[] { "a", "b", "c" }, SelectionMode = SelectionMode.Multiple }; + var window = new Window { Content = list, Width = 300, Height = 300 }; + window.Show(); + Pump(); + try + { + list.SelectedIndex = 0; + Pump(); + Assert.True(list.ContainerFromIndex(0)!.Focus(NavigationMethod.Tab)); + CtrlSpace(window); + Assert.Empty(list.SelectedItems!); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Choice_properties_ignore_null_and_announce_their_value_again() + { + TestServices.EnsureInitialized("en"); + var loc = AppHost.Get(); + var store = AppHost.Get(); + + var gesture = new TriggerGestureViewModel(new TriggerGesture { Button = TriggerMouseButton.Left }, loc, () => { }); + AssertKeeps(gesture, nameof(gesture.Kind), () => gesture.Kind, v => gesture.Kind = v); + AssertKeeps(gesture, nameof(gesture.Button), () => gesture.Button, v => gesture.Button = v); + AssertKeeps(gesture, nameof(gesture.Scope), () => gesture.Scope, v => gesture.Scope = v); + + var theme = new ThemePageViewModel(store, loc, AppHost.Get()); + AssertKeeps(theme, nameof(theme.Mode), () => theme.Mode, v => theme.Mode = v); + + var general = ActivatorUtilitiesCreate(); + AssertKeeps(general, nameof(general.Language), () => general.Language, v => general.Language = v); + AssertKeeps(general, nameof(general.DefaultLocation), () => general.DefaultLocation, v => general.DefaultLocation = v); + + var editor = new PresetEditorViewModel(store.FindPreset("to-png")!, loc); + AssertKeeps(editor, nameof(editor.Target), () => editor.Target, v => editor.Target = v); + AssertKeeps(editor, nameof(editor.Resize), () => editor.Resize, v => editor.Resize = v); + AssertKeeps(editor, nameof(editor.Rotation), () => editor.Rotation, v => editor.Rotation = v); + AssertKeeps(editor, nameof(editor.Flip), () => editor.Flip, v => editor.Flip = v); + AssertKeeps(editor, nameof(editor.PageSize), () => editor.PageSize, v => editor.PageSize = v); + AssertKeeps(editor, nameof(editor.Location), () => editor.Location, v => editor.Location = v); + AssertKeeps(editor, nameof(editor.Conflict), () => editor.Conflict, v => editor.Conflict = v); + AssertKeeps(editor, nameof(editor.TiffCompression), () => editor.TiffCompression, v => editor.TiffCompression = v); + AssertKeeps(editor, nameof(editor.MediaQuality), () => editor.MediaQuality, v => editor.MediaQuality = v); + AssertKeeps(editor, nameof(editor.MaxHeight), () => editor.MaxHeight, v => editor.MaxHeight = v); + AssertKeeps(editor, nameof(editor.AudioBitrate), () => editor.AudioBitrate, v => editor.AudioBitrate = v); + AssertKeeps(editor, nameof(editor.AudioChannels), () => editor.AudioChannels, v => editor.AudioChannels = v); + AssertKeeps(editor, nameof(editor.SampleRate), () => editor.SampleRate, v => editor.SampleRate = v); + AssertKeeps(editor, nameof(editor.WavFormat), () => editor.WavFormat, v => editor.WavFormat = v); + AssertKeeps(editor, nameof(editor.ArchiveLevel), () => editor.ArchiveLevel, v => editor.ArchiveLevel = v); + AssertKeeps(editor, nameof(editor.TraceMode), () => editor.TraceMode, v => editor.TraceMode = v); + AssertKeeps(editor, nameof(editor.VideoCodecChoice), () => editor.VideoCodecChoice, v => editor.VideoCodecChoice = v); + AssertKeeps(editor, nameof(editor.AudioCodecChoice), () => editor.AudioCodecChoice, v => editor.AudioCodecChoice = v); + Assert.True(editor.Apply()); + + var nav = new MainWindowViewModel(AppHost.Services, loc, AppHost.Get()); + AssertKeeps(nav, nameof(nav.SelectedItem), () => nav.SelectedItem, v => nav.SelectedItem = v); + + var rename = new RenameToolViewModel("", "{name}", [new RenameStep()], [], (_, _, name) => name, loc); + var step = rename.Steps[0]; + AssertKeeps(step, nameof(step.Kind), () => step.Kind, v => step.Kind = v); + + var watch = ActivatorUtilitiesCreate(); + watch.AddCommand.Execute(null); + var rule = watch.Rules[^1]; + try + { + AssertKeeps(rule, nameof(rule.Preset), () => rule.Preset!, v => rule.Preset = v); + AssertKeeps(rule, nameof(rule.Originals), () => rule.Originals, v => rule.Originals = v); + Assert.Equal(rule.Preset!.Value, rule.Rule.PresetId); + } + finally + { + store.Settings.WatchFolders.Remove(rule.Rule); + store.SaveSettings(); + watch.Dispose(); + } + } + + private static T ActivatorUtilitiesCreate() => + Microsoft.Extensions.DependencyInjection.ActivatorUtilities.CreateInstance(AppHost.Services); + + private static void AssertKeeps(System.ComponentModel.INotifyPropertyChanged vm, string property, Func get, Action set) + where T : class + { + var before = get(); + Assert.NotNull(before); + var announced = false; + void OnChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => announced |= e.PropertyName == property; + vm.PropertyChanged += OnChanged; + try + { + set(null!); + } + finally + { + vm.PropertyChanged -= OnChanged; + } + Assert.Equal(before, get()); // the codec choices are rebuilt on every read: equal, not the same + Assert.True(announced, $"{property} did not announce its value again"); + } +} diff --git a/tests/Filee.App.Tests/SingleInstanceTests.cs b/tests/Filee.App.Tests/SingleInstanceTests.cs index dec9a087..d83b8f56 100644 --- a/tests/Filee.App.Tests/SingleInstanceTests.cs +++ b/tests/Filee.App.Tests/SingleInstanceTests.cs @@ -44,4 +44,73 @@ public async Task Forwarding_gives_up_when_nobody_listens() Assert.False(delivered); } + + [Fact] + public async Task Forwarding_tells_that_nobody_answered() + { + var name = "Filee-test-" + Guid.NewGuid().ToString("N")[..8]; + using var first = SingleInstance.Acquire(name); + + var result = await Task.Run(() => + { + using var instance = SingleInstance.Acquire(name); + return instance.Forward(["--convert", "a.txt"], TimeSpan.FromSeconds(1)); + }, TestContext.Current.CancellationToken); + + Assert.Equal(ForwardResult.NoAnswer, result); + } + + [Fact] + public void Releasing_twice_is_harmless() + { + // A restart without administrator rights releases the lock early; Program releases it again at exit. + var name = "Filee-test-" + Guid.NewGuid().ToString("N")[..8]; + var first = SingleInstance.Acquire(name); + first.Dispose(); + first.Dispose(); + + using var next = SingleInstance.Acquire(name); + Assert.True(next.IsFirst); + } +} + +/// Windows: a running Filee with other rights (as administrator) refuses a normal launch. +[System.Runtime.Versioning.SupportedOSPlatform("windows")] +public class SingleInstanceAccessTests +{ + [Fact] + public async Task A_pipe_that_refuses_this_process_is_given_up_on_quickly() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows pipe security."); + var name = "Filee-test-" + Guid.NewGuid().ToString("N")[..8]; + using var first = SingleInstance.Acquire(name); + // Only SYSTEM may write: what a pipe of an elevated Filee looks like to a normal process. + var security = new System.IO.Pipes.PipeSecurity(); + security.AddAccessRule(new System.IO.Pipes.PipeAccessRule( + new System.Security.Principal.SecurityIdentifier(System.Security.Principal.WellKnownSidType.LocalSystemSid, null), + System.IO.Pipes.PipeAccessRights.FullControl, System.Security.AccessControl.AccessControlType.Allow)); + await using var server = System.IO.Pipes.NamedPipeServerStreamAcl.Create(name, System.IO.Pipes.PipeDirection.In, 1, + System.IO.Pipes.PipeTransmissionMode.Byte, System.IO.Pipes.PipeOptions.Asynchronous, 0, 0, security); + var watch = System.Diagnostics.Stopwatch.StartNew(); + + var result = await Task.Run(() => + { + using var second = SingleInstance.Acquire(name); + return second.Forward(["--convert", "a.txt"], TimeSpan.FromSeconds(30)); + }, TestContext.Current.CancellationToken); + + Assert.Equal(ForwardResult.AccessDenied, result); + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(10), $"took {watch.Elapsed}"); + } + + [Fact] + public void The_integrity_level_of_this_process_can_be_read() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows tokens."); + var state = Filee.Platform.Windows.Elevation.Probe(); + Assert.True(state.Process >= Filee.Core.Platform.IntegrityLevel.Medium, state.ToString()); + // Explorer may not run (CI): then its level is unknown and nothing is reported. + if (state.Shell == Filee.Core.Platform.IntegrityLevel.Unknown) + Assert.False(state.ShellCannotReachFilee); + } } diff --git a/tests/Filee.App.Tests/ToastWindowTests.cs b/tests/Filee.App.Tests/ToastWindowTests.cs index 72784ad5..5dd64d32 100644 --- a/tests/Filee.App.Tests/ToastWindowTests.cs +++ b/tests/Filee.App.Tests/ToastWindowTests.cs @@ -51,6 +51,21 @@ public void A_job_handed_to_the_hidden_toast_gets_one_card() } } + [AvaloniaFact] + public void A_note_on_a_converted_file_is_shown_under_the_card() + { + TestServices.EnsureInitialized("en"); + var conversion = new ConversionJob(["C:/scan.pdf", "C:/photos.pdf"], new Preset { TargetFormat = "pdf" }, "Compress PDF"); + conversion.Files[0].State = FileState.Done; + conversion.Files[0].NoticeKey = "notice.pdf_not_smaller"; + conversion.Files[1].State = FileState.Done; + var job = new JobViewModel(conversion, AppHost.Get(), AppHost.Get(), _ => { }); + + Assert.True(job.HasNotices); + Assert.Equal("scan.pdf — Couldn't make it smaller, so it was copied as it is", job.Notices); + Assert.False(job.HasErrors); + } + private static int Cards(ToastWindow toast) => toast.GetVisualDescendants().OfType().Single(c => c.Name == "Jobs").ItemsPanelRoot?.Children.Count ?? 0; diff --git a/tests/Filee.Cli.Tests/CliTests.cs b/tests/Filee.Cli.Tests/CliTests.cs index fd57353f..7d2c6af6 100644 --- a/tests/Filee.Cli.Tests/CliTests.cs +++ b/tests/Filee.Cli.Tests/CliTests.cs @@ -76,6 +76,30 @@ public async Task Json_output_lists_every_file() Assert.EndsWith("a.webp", file.GetProperty("outputs")[0].GetString()); } + [Fact] + public async Task Compress_pdf_says_when_a_pdf_cant_get_smaller() + { + var source = Path.Combine(_folder, "scan.pdf"); + using (var image = new ImageMagick.MagickImage("gradient:#C04020-#2060C0", 1800, 1800)) + { + image.AddNoise(ImageMagick.NoiseType.Gaussian, 0.6); + image.Density = new ImageMagick.Density(600); + image.Write(source, ImageMagick.MagickFormat.Pdf); + } + + var (code, output, errors) = await Run("convert", source, "--preset", "compress-pdf", "--json"); + Assert.Equal(Cli.Success, code); + var compressed = Path.Combine(_folder, "scan_compressed.pdf"); + Assert.True(new FileInfo(compressed).Length < new FileInfo(source).Length / 2, errors); + using (var json = JsonDocument.Parse(output)) + Assert.False(json.RootElement.GetProperty("files")[0].TryGetProperty("notice", out _)); + + // Compressed already: written unchanged, with a note. + (code, output, _) = await Run("convert", compressed, "--preset", "compress-pdf"); + Assert.Equal(Cli.Success, code); + Assert.Contains("scan_compressed_compressed.pdf (Couldn't make it smaller", output); + } + [Fact] public async Task A_folder_converts_the_files_filee_knows() { diff --git a/tests/Filee.Core.Tests/ElevationAndCatalogTests.cs b/tests/Filee.Core.Tests/ElevationAndCatalogTests.cs new file mode 100644 index 00000000..fcc5c7fe --- /dev/null +++ b/tests/Filee.Core.Tests/ElevationAndCatalogTests.cs @@ -0,0 +1,111 @@ +// When an elevated Filee counts as cut off from Explorer, and engine statuses checked in the background. + +using Filee.Core.Conversion; +using Filee.Core.Platform; + +namespace Filee.Core.Tests; + +public class ElevationStateTests +{ + [Theory] + [InlineData(IntegrityLevel.High, IntegrityLevel.Medium, true)] // installer run as administrator started Filee + [InlineData(IntegrityLevel.System, IntegrityLevel.Medium, true)] + [InlineData(IntegrityLevel.Medium, IntegrityLevel.Medium, false)] // the normal case + [InlineData(IntegrityLevel.High, IntegrityLevel.High, false)] // UAC off: Explorer runs elevated too + [InlineData(IntegrityLevel.Medium, IntegrityLevel.High, false)] // Explorer above Filee: not what this warns about + [InlineData(IntegrityLevel.High, IntegrityLevel.Unknown, false)] // no Explorer (another shell) + [InlineData(IntegrityLevel.Unknown, IntegrityLevel.Medium, false)] + [InlineData(IntegrityLevel.MediumPlus, IntegrityLevel.Medium, false)] + public void Only_an_administrator_Filee_above_Explorer_is_cut_off(IntegrityLevel process, IntegrityLevel shell, bool cutOff) => + Assert.Equal(cutOff, new ElevationState(process, shell, Elevated: process >= IntegrityLevel.High).ShellCannotReachFilee); + + [Fact] + public void Unknown_state_is_never_cut_off() => Assert.False(ElevationState.Unknown.ShellCannotReachFilee); + + [Theory] + [InlineData(0x0000u, IntegrityLevel.Untrusted)] + [InlineData(0x1000u, IntegrityLevel.Low)] + [InlineData(0x2000u, IntegrityLevel.Medium)] + [InlineData(0x2100u, IntegrityLevel.MediumPlus)] + [InlineData(0x3000u, IntegrityLevel.High)] + [InlineData(0x3001u, IntegrityLevel.High)] + [InlineData(0x4000u, IntegrityLevel.System)] + [InlineData(0x5000u, IntegrityLevel.System)] // protected process + public void Mandatory_label_rids_map_to_levels(uint rid, IntegrityLevel level) => + Assert.Equal(level, ElevationState.FromRid(rid)); + + [Fact] + public void Describes_itself_for_the_log() + { + Assert.Equal("integrity High (elevated), Explorer Medium", + new ElevationState(IntegrityLevel.High, IntegrityLevel.Medium, true).ToString()); + Assert.Equal("integrity Medium, Explorer Medium", + new ElevationState(IntegrityLevel.Medium, IntegrityLevel.Medium, false).ToString()); + Assert.Equal("integrity unknown", ElevationState.Unknown.ToString()); + } +} + +public class CatalogBackgroundCheckTests +{ + /// An engine whose check waits for the test, like FFmpeg answering -version slowly. + private sealed class SlowEngine : IConverter + { + public ManualResetEventSlim Gate { get; } = new(false); + public ManualResetEventSlim Started { get; } = new(false); + public int Checks; + public volatile bool Available; + + public string Id => "slow"; + public string DisplayName => "Slow"; + public IReadOnlyList Edges => [new("png", "jpg")]; + public int MaxParallelism => 0; + + public EngineStatus GetStatus() + { + Interlocked.Increment(ref Checks); + var available = Available; // what the engine looked like when the check started + Started.Set(); + Gate.Wait(TimeSpan.FromSeconds(10)); + return available ? EngineStatus.Available() : EngineStatus.Unavailable("engine.reason.not_installed"); + } + + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + [Fact] + public async Task A_status_asked_during_the_background_check_waits_for_it_instead_of_checking_again() + { + var engine = new SlowEngine { Available = true }; + var catalog = new ConverterCatalog([engine]); + var background = catalog.CheckInBackgroundAsync(); + Assert.True(engine.Started.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + + var asked = Task.Run(() => catalog.StatusOf(engine), TestContext.Current.CancellationToken); + await Task.Delay(100, TestContext.Current.CancellationToken); + Assert.False(asked.IsCompleted); + engine.Gate.Set(); + + Assert.True((await asked.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)).IsAvailable); + await background.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.Equal(1, engine.Checks); + } + + [Fact] + public async Task A_refresh_during_the_background_check_discards_its_result() + { + var engine = new SlowEngine { Available = false }; + var catalog = new ConverterCatalog([engine]); + var background = catalog.CheckInBackgroundAsync(); + Assert.True(engine.Started.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + + // The user installed the engine while the first check was still running. + engine.Available = true; + catalog.Refresh(); + engine.Gate.Set(); + await background.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(catalog.StatusOf(engine).IsAvailable); + Assert.Equal(2, engine.Checks); + } +} diff --git a/tests/Filee.Core.Tests/FakeConverter.cs b/tests/Filee.Core.Tests/FakeConverter.cs index 0bdfc339..7889ae38 100644 --- a/tests/Filee.Core.Tests/FakeConverter.cs +++ b/tests/Filee.Core.Tests/FakeConverter.cs @@ -16,12 +16,17 @@ internal sealed class FakeConverter(string id, params ConversionEdge[] edges) : public bool Throw { get; set; } + /// Thrown instead of converting, when set. + public Exception? Failure { get; set; } + public EngineStatus GetStatus() => Available ? EngineStatus.Available() : EngineStatus.Unavailable("engine.reason.not_installed"); public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { if (Throw) throw new InvalidOperationException("boom"); + if (Failure is not null) + throw Failure; var written = new List(); for (var i = 0; i < OutputsPerInput; i++) @@ -73,12 +78,18 @@ internal sealed class FakeRecorder(string id, params ConversionEdge[] edges) : I public List<(string To, Filee.Core.Presets.ImageRotation Rotation)> Asked { get; } = []; + /// The steps it was asked for: (from, to, every frame). + public List<(string From, string To, bool FrameSequence)> Steps { get; } = []; + public EngineStatus GetStatus() => EngineStatus.Available(); public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { lock (Asked) + { Asked.Add((step.To, step.Preset.Image.Rotation)); + Steps.Add((step.From, step.To, step.FrameSequence)); + } return _writer.ConvertAsync(step, progress, cancellationToken); } } diff --git a/tests/Filee.Core.Tests/JobQueueTests.cs b/tests/Filee.Core.Tests/JobQueueTests.cs index 4c3d302d..b5317989 100644 --- a/tests/Filee.Core.Tests/JobQueueTests.cs +++ b/tests/Filee.Core.Tests/JobQueueTests.cs @@ -102,6 +102,54 @@ public async Task Keep_dates_gives_outputs_the_dates_of_their_source() Assert.True(File.GetLastWriteTimeUtc(Path.Combine(dir.Path, "plain.png")) > DateTime.UtcNow.AddMinutes(-5)); } + [Fact] + public async Task Every_frame_is_saved_only_for_videos_and_the_frame_targets() + { + using var dir = new TempDir(); + var video = dir.File("clip.mp4"); + var photo = dir.File("photo.png"); + var ffmpeg = new FakeRecorder("ffmpeg", new ConversionEdge("mp4", "png"), + new ConversionEdge("mp4", "png", FrameSequence: true), new ConversionEdge("mp4", "jpg", FrameSequence: true)); + var magick = new FakeTurner("magick", new ConversionEdge("png", "jpg"), new ConversionEdge("png", "webp")); + await using var queue = new JobQueue(new ConverterCatalog([ffmpeg, magick])); + + // Without "save every frame" a video → JPG is a still frame the image engine turns, as always. + var turned = await Run(queue, [video], new Preset { TargetFormat = "jpg", Image = { Rotation = ImageRotation.Left90 } }); + Assert.Equal(JobState.Completed, turned.State); + Assert.Equal([("mp4", "png", false)], ffmpeg.Steps); + Assert.Equal([("jpg", ImageRotation.Left90)], magick.Asked); + + // With it: one step that saves the frames. + ffmpeg.Steps.Clear(); + var frames = await Run(queue, [video], new Preset { TargetFormat = "jpg", Media = { SaveEveryFrame = true } }); + Assert.Equal(JobState.Completed, frames.State); + Assert.Equal([("mp4", "jpg", true)], ffmpeg.Steps); + + // A target frames can't be (left over from an earlier PNG target) or a picture: converted as usual. + ffmpeg.Steps.Clear(); + await Run(queue, [video], new Preset { TargetFormat = "webp", Media = { SaveEveryFrame = true } }); + await Run(queue, [photo], new Preset { TargetFormat = "jpg", Media = { SaveEveryFrame = true } }); + Assert.Equal([("mp4", "png", false)], ffmpeg.Steps); + } + + [Fact] + public async Task A_refused_conversion_says_why_in_the_users_language() + { + using var dir = new TempDir(); + var video = dir.File("long.mp4"); + var ffmpeg = new FakeConverter("ffmpeg", new ConversionEdge("mp4", "png", FrameSequence: true)) + { + Failure = new ConversionRefusedException("error.too_many_frames", "216,000 > 100,000"), + }; + await using var queue = new JobQueue(new ConverterCatalog([ffmpeg])); + + var job = await Run(queue, [video], new Preset { TargetFormat = "png", Media = { SaveEveryFrame = true } }); + + var file = job.Files.Single(); + Assert.Equal(FileState.Failed, file.State); + Assert.Equal(("error.too_many_frames", "216,000 > 100,000"), (file.ErrorKey, file.ErrorDetail)); + } + [Fact] public async Task Multi_step_route_keeps_intermediates_out_of_the_output_folder() { diff --git a/tests/Filee.Core.Tests/RoutePlannerTests.cs b/tests/Filee.Core.Tests/RoutePlannerTests.cs index 973f56b5..8a0fb7db 100644 --- a/tests/Filee.Core.Tests/RoutePlannerTests.cs +++ b/tests/Filee.Core.Tests/RoutePlannerTests.cs @@ -51,6 +51,45 @@ public void Edges_not_for_image_sources_leave_images_to_tracing() Assert.Equal(["pdf→svg"], planner.Plan("pdf", "svg")!.Steps.Select(s => $"{s.From}→{s.To}")); } + [Fact] + public void Direct_only_edges_are_never_a_step_of_a_longer_route() + { + // PDF → PPTX puts pictures of the pages on slides: only for a PDF the user asked to turn into PPTX. + var slides = new FakeConverter("slides", new ConversionEdge("pdf", "pptx", DirectOnly: true)); + var word = new FakeConverter("word", new ConversionEdge("docx", "pdf")); + var office = new FakeConverter("office", new ConversionEdge("pptx", "odp"), new ConversionEdge("pptx", "ppsx")); + var planner = new RoutePlanner([slides, word, office]); + + Assert.Equal(["pdf→pptx"], planner.Plan("pdf", "pptx")!.Steps.Select(s => $"{s.From}→{s.To}")); + Assert.Null(planner.Plan("docx", "pptx")); // not after another step + Assert.Null(planner.Plan("pdf", "odp")); // nor before one + Assert.Null(planner.Plan("pdf", "ppsx")); + Assert.Equal(["pptx→odp"], planner.Plan("pptx", "odp")!.Steps.Select(s => $"{s.From}→{s.To}")); + } + + [Fact] + public void Frame_sequence_edges_are_a_route_of_their_own_only_when_asked_for() + { + var ffmpeg = new FakeConverter("ffmpeg", new ConversionEdge("mp4", "png"), + new ConversionEdge("mp4", "png", FrameSequence: true), new ConversionEdge("mp4", "jpg", FrameSequence: true)); + var magick = new FakeConverter("magick", new ConversionEdge("png", "jpg"), new ConversionEdge("png", "webp")); + var planner = new RoutePlanner([ffmpeg, magick]); + + // Ordinary routes: one still frame, then the image engine. The frame edge to JPG is never taken. + var still = planner.Plan("mp4", "jpg")!; + Assert.Equal(["mp4→png", "png→jpg"], still.Steps.Select(s => $"{s.From}→{s.To}")); + Assert.DoesNotContain(still.Steps, s => s.FrameSequence); + Assert.False(planner.Plan("mp4", "png")!.Steps.Single().FrameSequence); + + // Frames: the frame edge alone, never as a step towards another format. + var frames = planner.Plan("mp4", "jpg", frames: true)!; + Assert.Equal(("mp4", "jpg", true), (frames.Steps.Single().From, frames.Steps.Single().To, frames.Steps.Single().FrameSequence)); + Assert.True(planner.Plan("mp4", "png", frames: true)!.Steps.Single().FrameSequence); + Assert.Null(planner.Plan("mp4", "webp", frames: true)); + Assert.Null(planner.Plan("png", "jpg", frames: true)); + Assert.False(planner.CanConvert("mov", "jpg", frames: true)); + } + [Fact] public void Cheaper_route_wins() { diff --git a/tests/Filee.Core.Tests/SmallParsersTests.cs b/tests/Filee.Core.Tests/SmallParsersTests.cs index b33fe152..4ef9f44f 100644 --- a/tests/Filee.Core.Tests/SmallParsersTests.cs +++ b/tests/Filee.Core.Tests/SmallParsersTests.cs @@ -115,4 +115,43 @@ public void Preset_targets_are_known_formats() Assert.True(preset.TargetFormat == BuiltInData.SameAsSource || FormatRegistry.FindById(preset.TargetFormat) is not null, preset.Id); } } + + [Theory] + [InlineData("mp4", "png", true)] + [InlineData("mkv", "jpg", true)] + [InlineData("mov", "webp", false)] // frames are PNG or JPG only + [InlineData("mp4", "jpeg", false)] // a format id, not an extension + [InlineData("gif", "png", false)] // a picture, not a video + [InlineData("mp3", "png", false)] + public void Every_frame_is_saved_for_videos_to_png_or_jpg(string source, string target, bool expected) + { + Assert.Equal(expected, new Preset { TargetFormat = target, Media = { SaveEveryFrame = true } }.SavesFramesOf(source)); + Assert.False(new Preset { TargetFormat = target }.SavesFramesOf(source)); + } + + [Fact] + public void Extract_frames_preset_is_properly_configured() + { + var presets = BuiltInData.CreatePresets(); + var pngPreset = Assert.Single(presets, p => p.Id == "extract-frames-png" || p.NameKey == "preset.extract_frames_png"); + + Assert.Equal("png", pngPreset.TargetFormat); + Assert.True(pngPreset.Media.SaveEveryFrame); + Assert.Equal(1, pngPreset.Media.FrameInterval); + + var jpgPreset = Assert.Single(presets, p => p.Id == "extract-frames-jpg" || p.NameKey == "preset.extract_frames_jpg"); + + Assert.Equal("jpg", jpgPreset.TargetFormat); + Assert.True(jpgPreset.Media.SaveEveryFrame); + Assert.Equal(1, jpgPreset.Media.FrameInterval); + Assert.Equal(90, jpgPreset.Image.Quality); + + // Ensure they are NOT added to any default donut profile to prevent accidental frame splitting + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + { + Assert.DoesNotContain(pngPreset.Id, profile.PresetIds); + Assert.DoesNotContain(jpgPreset.Id, profile.PresetIds); + } + } } diff --git a/tests/Filee.Core.Tests/UserDataStoreTests.cs b/tests/Filee.Core.Tests/UserDataStoreTests.cs index 36e7c4bc..b8f96879 100644 --- a/tests/Filee.Core.Tests/UserDataStoreTests.cs +++ b/tests/Filee.Core.Tests/UserDataStoreTests.cs @@ -118,6 +118,34 @@ public void Round_trips_settings_presets_and_profiles() Assert.Equal("mine", reloaded.Profiles[0].PresetIds[0]); } + [Fact] + public void Hardware_encoding_is_saved_and_stays_off_for_presets_from_before_it() + { + using var dir = new TempDir(); + var store = new UserDataStore(dir.Path); + store.Load(); + Assert.All(store.Presets, p => Assert.False(p.Media.HardwareEncoding)); // no built-in preset turns it on + store.Presets.Add(new Preset { Id = "fast", Name = "Fast", TargetFormat = "mp4", Media = { HardwareEncoding = true, VideoCodec = VideoCodec.Hevc } }); + store.Presets.Add(new Preset { Id = "old", Name = "Old", TargetFormat = "mp4" }); + store.SaveLibrary(); + + // A Filee from before the option saved no "hardwareEncoding". + var path = Path.Combine(dir.Path, "presets.json"); + var presets = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(path))!.AsArray(); + Assert.True((bool)presets.Single(p => (string?)p!["id"] == "fast")!["media"]!["hardwareEncoding"]!); + Assert.True(presets.Single(p => (string?)p!["id"] == "old")!["media"]!.AsObject().Remove("hardwareEncoding")); + File.WriteAllText(path, presets.ToJsonString()); + + var reloaded = new UserDataStore(dir.Path); + reloaded.Load(); + + var fast = reloaded.FindPreset("fast")!; + Assert.True(fast.Media.HardwareEncoding); + Assert.Equal(VideoCodec.Hevc, fast.Media.VideoCodec); + Assert.True(fast.Clone().Media.HardwareEncoding); + Assert.False(reloaded.FindPreset("old")!.Media.HardwareEncoding); + } + [Fact] public void History_is_kept_until_turned_off_and_can_be_cleared() { @@ -148,7 +176,7 @@ public void Version_1_settings_are_upgraded_to_the_current_engine_list() var store = new UserDataStore(dir.Path); store.Load(); - Assert.Equal(["magick", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "email", "data", "vector", "icns", "font", "cad", "model3d", "archive", "ebook", "subtitle", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(["magick", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "archive", "ebook", "subtitle", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice"], store.Settings.EnginePriority); Assert.Equal(AppSettings.CurrentSchemaVersion, store.Settings.SchemaVersion); } @@ -200,7 +228,7 @@ public void Version_4_libraries_get_spreadsheet_and_presentation_profiles() var store = new UserDataStore(dir.Path); store.Load(); - Assert.Equal(["magick", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "email", "data", "vector", "icns", "font", "cad", "model3d", "archive", "ebook", "subtitle", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(["magick", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "archive", "ebook", "subtitle", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice"], store.Settings.EnginePriority); var ids = store.Profiles.Select(p => p.Id).ToList(); Assert.Equal(ids.IndexOf("office") + 1, ids.IndexOf("spreadsheets")); Assert.Equal(ids.IndexOf("office") + 2, ids.IndexOf("presentations")); @@ -302,6 +330,33 @@ public void Version_9_libraries_get_the_remove_background_preset_on_no_donut() Assert.False(SettingsMigrations.ApplyToLibrary(9, presets, profiles)); // once only } + [Fact] + public void Version_18_libraries_get_the_extract_frames_presets_after_the_pdf_presets_on_no_donut() + { + var presets = BuiltInData.CreatePresets(); + presets.RemoveAll(p => p.Id is "extract-frames-png" or "extract-frames-jpg"); + var profiles = BuiltInData.CreateProfiles(); + + Assert.True(SettingsMigrations.ApplyToLibrary(18, presets, profiles)); + + var pngPreset = presets.Single(p => p.Id == "extract-frames-png"); + Assert.True(pngPreset.Media.SaveEveryFrame); + Assert.Equal("png", pngPreset.TargetFormat); + + var jpgPreset = presets.Single(p => p.Id == "extract-frames-jpg"); + Assert.True(jpgPreset.Media.SaveEveryFrame); + Assert.Equal("jpg", jpgPreset.TargetFormat); + + // Where a new library has them. + Assert.Equal(BuiltInData.CreatePresets().Select(p => p.Id), presets.Select(p => p.Id)); + var slides = presets.FindIndex(p => p.Id == "pdf-slides"); + Assert.Equal(["extract-frames-png", "extract-frames-jpg"], presets.Skip(slides + 1).Take(2).Select(p => p.Id)); + + Assert.DoesNotContain(profiles, p => p.PresetIds.Contains("extract-frames-png")); + Assert.DoesNotContain(profiles, p => p.PresetIds.Contains("extract-frames-jpg")); + Assert.False(SettingsMigrations.ApplyToLibrary(18, presets, profiles)); // once only + } + [Fact] public void Version_13_libraries_get_the_data_donut_msg_files_and_json_for_spreadsheets() { @@ -345,8 +400,9 @@ public void Version_13_settings_get_the_data_engine_after_email() var store = new UserDataStore(dir.Path); store.Load(); - // ... and, from schema 15, the 3D model engine before Pandoc (no CAD or archive engine in this list). - Assert.Equal(["magick", "email", "data", "vector", "model3d", "pandoc", "libreoffice"], store.Settings.EnginePriority); + // ... and, from schema 15, the 3D model engine before Pandoc (no CAD or archive engine in this list), from + // schema 18 the PDF slides engine there too (no PDF text engine). + Assert.Equal(["magick", "email", "data", "vector", "model3d", "pdf-slides", "pandoc", "libreoffice"], store.Settings.EnginePriority); } [Fact] @@ -525,6 +581,70 @@ public void Version_16_libraries_get_rotate_and_flip_presets_and_groups_on_no_do Assert.Equal("rotate-left", presets[background + 1].Id); } + /// Presets and donuts as a Filee before PDF → PPTX wrote them (schema 17). + private static (List Presets, List Profiles) Version17Library() + { + var presets = BuiltInData.CreatePresets(); + presets.RemoveAll(p => p.Id == "pdf-slides"); + var profiles = BuiltInData.CreateProfiles(); + profiles.Single(p => p.Id == "pdf").PresetIds = [.. BuiltInData.PdfDonutBefore18]; + return (presets, profiles); + } + + [Fact] + public void Version_17_libraries_get_pptx_slides_on_the_unchanged_pdf_donut_in_place_of_tiff() + { + var (presets, profiles) = Version17Library(); + + Assert.True(SettingsMigrations.ApplyToLibrary(17, presets, profiles)); + + var slides = presets.Single(p => p.Id == "pdf-slides"); + Assert.Equal(("pptx", "preset.pdf_slides"), (slides.TargetFormat, slides.NameKey)); + Assert.Equal("pdf-split", presets[presets.IndexOf(slides) - 1].Id); // where a new library has it + var pdf = profiles.Single(p => p.Id == "pdf").PresetIds; + Assert.Equal(BuiltInData.CreateProfiles().Single(p => p.Id == "pdf").PresetIds, pdf); + Assert.Equal(pdf.IndexOf("to-docx") + 1, pdf.IndexOf("pdf-slides")); + Assert.DoesNotContain("to-tiff", pdf); + Assert.NotNull(presets.SingleOrDefault(p => p.Id == "to-tiff")); // still a preset to add again + Assert.False(SettingsMigrations.ApplyToLibrary(17, presets, profiles)); // once only + } + + [Fact] + public void A_pdf_donut_the_user_changed_gets_pptx_slides_only_when_it_has_room() + { + // Full, but not the default: the user's choice of twelve stays. + var (presets, profiles) = Version17Library(); + var pdf = profiles.Single(p => p.Id == "pdf"); + pdf.PresetIds[pdf.PresetIds.IndexOf("to-tiff")] = "to-webp"; + + Assert.True(SettingsMigrations.ApplyToLibrary(17, presets, profiles)); // the preset is added + Assert.DoesNotContain("pdf-slides", pdf.PresetIds); + Assert.Contains("to-webp", pdf.PresetIds); + + // With room: after DOCX. + (presets, profiles) = Version17Library(); + pdf = profiles.Single(p => p.Id == "pdf"); + pdf.PresetIds.RemoveAll(id => id is "to-md" or "to-epub"); + + Assert.True(SettingsMigrations.ApplyToLibrary(17, presets, profiles)); + Assert.Equal(pdf.PresetIds.IndexOf("to-docx") + 1, pdf.PresetIds.IndexOf("pdf-slides")); + Assert.Contains("to-tiff", pdf.PresetIds); + } + + [Fact] + public void Version_17_settings_get_the_pdf_slides_engine_after_pdf_text() + { + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "settings.json"), + """{ "schemaVersion": 17, "enginePriority": ["magick", "pdfium", "docx-writer", "pdf-text", "email", "pandoc", "libreoffice"] }"""); + + var store = new UserDataStore(dir.Path); + store.Load(); + + Assert.Equal(["magick", "pdfium", "docx-writer", "pdf-text", "pdf-slides", "email", "pandoc", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(AppSettings.CurrentSchemaVersion, store.Settings.SchemaVersion); + } + [Fact] public void A_rotation_from_a_newer_Filee_reads_as_none_and_the_other_presets_still_load() { @@ -570,6 +690,63 @@ public void Back_from_an_older_Filee_rotate_and_flip_work_again_and_keep_the_use Assert.Single(presets, p => p.Id == "rotate-left"); // repaired, not added twice } + [Fact] + public void Version_17_libraries_get_compress_pdf_after_split_pdf_on_no_donut() + { + var presets = BuiltInData.CreatePresets(); + presets.RemoveAll(p => p.Id == "compress-pdf"); + var profiles = BuiltInData.CreateProfiles(); + + Assert.True(SettingsMigrations.ApplyToLibrary(17, presets, profiles)); + + var compress = presets.Single(p => p.Id == "compress-pdf"); + Assert.Equal(("pdf", PdfCompression.Ebook, false), (compress.TargetFormat, compress.Pdf.Compression, compress.Pdf.CompressWithGhostscript)); + Assert.Equal("pdf-split", presets[presets.IndexOf(compress) - 1].Id); // where a new library has it + Assert.DoesNotContain(profiles, p => p.PresetIds.Contains("compress-pdf")); + Assert.Equal(BuiltInData.CreateProfiles().Single(p => p.Id == "pdf").PresetIds, profiles.Single(p => p.Id == "pdf").PresetIds); + Assert.False(SettingsMigrations.ApplyToLibrary(17, presets, profiles)); // once only + } + + [Fact] + public void Back_from_an_older_Filee_compress_pdf_compresses_again_and_keeps_the_users_changes() + { + // What a Filee before 20 leaves after saving: the compression level dropped, the rest as it was. + var presets = BuiltInData.CreatePresets(); + var compress = presets.Single(p => p.Id == "compress-pdf"); + compress.Pdf.Compression = PdfCompression.None; + compress.Name = "Shrink"; + + Assert.True(SettingsMigrations.ApplyToLibrary(17, presets, BuiltInData.CreateProfiles())); + + Assert.Equal(PdfCompression.Ebook, compress.Pdf.Compression); + Assert.Equal("Shrink", compress.Name); + Assert.Single(presets, p => p.Id == "compress-pdf"); + } + + [Fact] + public void Pdf_compression_survives_a_save_and_load_and_a_newer_level_reads_as_none() + { + using var dir = new TempDir(); + var store = new UserDataStore(dir.Path); + store.Load(); + var preset = store.Presets.Single(p => p.Id == "compress-pdf"); + preset.Pdf.Compression = PdfCompression.Print; + preset.Pdf.CompressWithGhostscript = true; + store.SaveLibrary(); + + var reloaded = new UserDataStore(dir.Path); + reloaded.Load(); + var pdf = reloaded.Presets.Single(p => p.Id == "compress-pdf").Pdf; + Assert.Equal((PdfCompression.Print, true), (pdf.Compression, pdf.CompressWithGhostscript)); + + var path = Path.Combine(dir.Path, "presets.json"); + File.WriteAllText(path, File.ReadAllText(path).Replace("\"compression\": \"Print\"", "\"compression\": \"Extreme\"")); + var newer = new UserDataStore(dir.Path); + newer.Load(); + Assert.Equal(PdfCompression.None, newer.FindPreset("compress-pdf")!.Pdf.Compression); + Assert.False(File.Exists(path + ".broken")); + } + [Fact] public void Catalog_migration_ignores_extensions_checked_on_mixed_profiles() { diff --git a/tests/Filee.Engines.Tests/EngineProbeTests.cs b/tests/Filee.Engines.Tests/EngineProbeTests.cs new file mode 100644 index 00000000..8a3a932e --- /dev/null +++ b/tests/Filee.Engines.Tests/EngineProbeTests.cs @@ -0,0 +1,48 @@ +// Engine checks that must not hang: a network share on the PATH that doesn't answer, an FFmpeg that never exits. + +using System.Diagnostics; +using Filee.Engines.Infrastructure; +using Filee.Engines.Media; + +namespace Filee.Engines.Tests; + +public class EngineProbeTests +{ + [Theory] + [InlineData(@"\\server\share\tools\bin", @"\\server\share")] + [InlineData(@"\\server\share", @"\\server\share")] + [InlineData(@"\\server\share\", @"\\server\share")] + [InlineData(@"\\server", @"\\server")] + [InlineData(@"C:\Tools\bin", null)] + [InlineData(@"\\?\C:\Tools", null)] + [InlineData(@"\\.\pipe\x", null)] + [InlineData("/usr/local/bin", null)] + [InlineData("", null)] + public void Network_folders_on_the_path_are_recognised(string folder, string? share) => + Assert.Equal(share, EngineEnvironment.ShareOf(folder)); + + [Fact] + public void A_program_that_never_exits_is_given_up_on_and_killed() + { + // Both run for about half a minute (ping waits a second between replies). + var (program, arguments) = OperatingSystem.IsWindows() + ? ("ping.exe", new[] { "-n", "30", "127.0.0.1" }) + : ("/bin/sleep", new[] { "30" }); + var watch = Stopwatch.StartNew(); + + Assert.Throws(() => FfmpegRunner.RunBriefly(program, arguments, TimeSpan.FromSeconds(1))); + + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(10), $"took {watch.Elapsed}"); + } + + [Fact] + public void Output_of_a_program_that_exits_is_returned() + { + var result = OperatingSystem.IsWindows() + ? FfmpegRunner.RunBriefly("cmd.exe", ["/d", "/c", "echo filee-probe"], TimeSpan.FromSeconds(10)) + : FfmpegRunner.RunBriefly("/bin/sh", ["-c", "echo filee-probe"], TimeSpan.FromSeconds(10)); + + Assert.Contains("filee-probe", result.StandardOutput); + Assert.Equal(0, result.ExitCode); + } +} diff --git a/tests/Filee.Engines.Tests/GraphicsTests.cs b/tests/Filee.Engines.Tests/GraphicsTests.cs index cef351d1..c1354809 100644 --- a/tests/Filee.Engines.Tests/GraphicsTests.cs +++ b/tests/Filee.Engines.Tests/GraphicsTests.cs @@ -321,6 +321,45 @@ public async Task Svg_to_png_is_rendered_large_enough_and_keeps_transparency() Assert.True(Pixel(flat, 3, 3).R > 245, "JPG gets the preset background"); } + [Fact] + public async Task Svg_with_a_loose_ampersand_and_html_entities_still_converts() + { + // Issue #120: generated SVGs with "A & B" in a stopped the XML parser ("parsing EntityName"). + var dir = fx.NewFolder(); + var svg = Write(dir, "gemini.svg", """ + + + Q&A & R D © 2026 &bogus; + + """); + + var job = await fx.ConvertAsync([svg], new Preset { TargetFormat = "png" }); + + AssertDone(job); + using var png = new MagickImage(job.Outputs.Single()); + Assert.True(Pixel(png, 3, 3).R > 240, "the drawing was rendered"); + } + + [Theory] + [InlineData("A & B", "A & B")] + [InlineData("R D ©", "R D ©")] + [InlineData("&bogus; ©", "&bogus; &copy")] + [InlineData("", "")] + public void Loose_ampersands_are_escaped_and_html_entities_become_references(string svg, string repaired) => + Assert.Equal(repaired, Encoding.UTF8.GetString(SvgXmlRepair.FixAmpersands(Encoding.UTF8.GetBytes(svg))!)); + + [Theory] + [InlineData("A & B < & &")] + [InlineData("]>")] + [InlineData("x && y")] + [InlineData("no ampersand")] + public void Valid_xml_is_left_as_it_is(string svg) => + Assert.Null(SvgXmlRepair.FixAmpersands(Encoding.UTF8.GetBytes(svg))); + + [Fact] + public void Text_in_another_encoding_is_not_touched() => + Assert.Null(SvgXmlRepair.FixAmpersands(Encoding.Latin1.GetBytes("café & co"))); + [Fact] public async Task Svgz_round_trip() { diff --git a/tests/Filee.Engines.Tests/HardwareEncodingTests.cs b/tests/Filee.Engines.Tests/HardwareEncodingTests.cs new file mode 100644 index 00000000..d9b873d4 --- /dev/null +++ b/tests/Filee.Engines.Tests/HardwareEncodingTests.cs @@ -0,0 +1,384 @@ +// Hardware video encoding: which encoders are tried where, their options (no GPU or FFmpeg needed), the software retry +// when a hardware encode fails, and the time limit of FFmpeg's quick queries. Real hardware encodes run only on a +// computer with a working GPU encoder and Filee's FFmpeg; CI has neither, so those tests skip there. + +using System.Diagnostics; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Infrastructure; +using Filee.Engines.Media; +using Microsoft.Extensions.Logging; + +namespace Filee.Engines.Tests; + +[Collection(nameof(FfmpegCollection))] +public class HardwareEncodingTests(EngineFixture fx) : IClassFixture +{ + private static readonly (string Ffmpeg, string Ffprobe)? Tools = FfmpegConverter.Locate(); + + private static readonly VideoStreamInfo Hd = new(1920, 1080, 1, 0, 29.97, "h264", "yuv444p"); + + private static readonly MediaInfo HdClip = new(TimeSpan.FromSeconds(10), Hd, new AudioStreamInfo(2, 48000, "aac"), null); + + private static readonly MediaInfo HdrClip = new(TimeSpan.FromSeconds(10), + new VideoStreamInfo(3840, 2160, 1, 0, 30, "hevc", "yuv420p10le", new VideoColor("bt2020", "smpte2084", "bt2020nc", "tv")), + new AudioStreamInfo(2, 48000, "aac"), null); + + private static string After(IReadOnlyList args, string option) => args[args.ToList().IndexOf(option) + 1]; + + private static IReadOnlyList Args(string target, MediaOptions options, HardwareEncoder? hardware, MediaInfo? info = null) => + MediaEncoding.Plan("in.mov", "out." + target, target, options, info ?? HdClip, "work", hardware: hardware).Single().Arguments; + + // ───────────────────────── Which encoders, in which order ───────────────────────── + + [Fact] + public void Each_system_tries_its_own_encoders_best_first() + { + Assert.Equal( + [new HardwareEncoder("hevc_nvenc", HardwareApi.Nvenc), new("hevc_qsv", HardwareApi.Qsv), new("hevc_amf", HardwareApi.Amf)], + HardwareVideo.Candidates("hevc", HostPlatform.Windows, encoders: null, vaapiDevice: false)); + Assert.Equal( + [new HardwareEncoder("h264_videotoolbox", HardwareApi.VideoToolbox), + new("h264_videotoolbox", HardwareApi.VideoToolbox, HardwareRateControl.Bitrate)], + HardwareVideo.Candidates("h264", HostPlatform.MacOS, encoders: null, vaapiDevice: false)); + Assert.Equal([new HardwareEncoder("h264_nvenc", HardwareApi.Nvenc)], + HardwareVideo.Candidates("h264", HostPlatform.Linux, encoders: null, vaapiDevice: false)); + Assert.Equal([new HardwareEncoder("h264_nvenc", HardwareApi.Nvenc), new("h264_vaapi", HardwareApi.Vaapi)], + HardwareVideo.Candidates("h264", HostPlatform.Linux, encoders: null, vaapiDevice: true)); + Assert.Empty(HardwareVideo.Candidates("h264", HostPlatform.Other, encoders: null, vaapiDevice: true)); + Assert.Empty(HardwareVideo.Candidates("av1", HostPlatform.Windows, encoders: null, vaapiDevice: false)); + + // Only what this FFmpeg was built with: a build without NVENC goes straight to Quick Sync. + Assert.Equal([new HardwareEncoder("h264_qsv", HardwareApi.Qsv), new("h264_amf", HardwareApi.Amf)], + HardwareVideo.Candidates("h264", HostPlatform.Windows, new HashSet { "libx264", "h264_qsv", "h264_amf", "hevc_nvenc" }, false)); + Assert.Empty(HardwareVideo.Candidates("hevc", HostPlatform.MacOS, new HashSet { "libx265" }, false)); + + Assert.Equal("hevc", new HardwareEncoder("hevc_videotoolbox", HardwareApi.VideoToolbox).Codec); + Assert.Equal("h264", new HardwareEncoder("h264_qsv", HardwareApi.Qsv).Codec); + } + + [Fact] + public void Only_h264_and_hevc_encodes_ask_for_a_hardware_encoder() + { + var on = new MediaOptions { HardwareEncoding = true }; + Assert.Equal("h264", MediaEncoding.HardwareCodec("mp4", on, HdClip)); + Assert.Equal("h264", MediaEncoding.HardwareCodec("ts", on, HdClip)); + Assert.Equal("hevc", MediaEncoding.HardwareCodec("mkv", new MediaOptions { HardwareEncoding = true, VideoCodec = VideoCodec.Hevc }, HdClip)); + // Off, another codec, or a video that is only rewrapped. + Assert.Null(MediaEncoding.HardwareCodec("mp4", new MediaOptions(), HdClip)); + Assert.Null(MediaEncoding.HardwareCodec("webm", on, HdClip)); + Assert.Null(MediaEncoding.HardwareCodec("avi", on, HdClip)); + Assert.Null(MediaEncoding.HardwareCodec("mp4", new MediaOptions { HardwareEncoding = true, VideoCodec = VideoCodec.Av1 }, HdClip)); + Assert.Null(MediaEncoding.HardwareCodec("mp3", on, HdClip)); + var playable = HdClip with { Video = Hd with { PixelFormat = "yuv420p" } }; + Assert.Null(MediaEncoding.HardwareCodec("mp4", on, playable)); + Assert.Equal("h264", MediaEncoding.HardwareCodec("mp4", new MediaOptions { HardwareEncoding = true, Quality = MediaQuality.Small }, playable)); + + // The editor's list of H.264 targets is the encoder table's. + Assert.Equal(MediaEncoding.VideoTargets.Where(t => t.Value.VideoCodec == "libx264").Select(t => t.Key).Order(), MediaCodecs.H264Targets.Order()); + } + + // ───────────────────────── Options per encoder ───────────────────────── + + [Fact] + public void Quality_levels_map_to_each_encoders_rate_control() + { + IReadOnlyList Codec(HardwareEncoder encoder, string target, MediaQuality quality, VideoStreamInfo? video = null, int maxHeight = 0) => + HardwareVideo.CodecArguments(encoder, target, quality, video ?? Hd, maxHeight); + + Assert.Equal(["-c:v", "h264_nvenc", "-preset", "p5", "-rc", "vbr", "-cq", "24", "-b:v", "0", "-profile:v", "high", "-pix_fmt", "nv12"], + Codec(new("h264_nvenc", HardwareApi.Nvenc), "mp4", MediaQuality.Balanced)); + Assert.Equal(["-c:v", "hevc_qsv", "-preset", "medium", "-global_quality", "23", "-profile:v", "main", "-pix_fmt", "nv12", "-tag:v", "hvc1"], + Codec(new("hevc_qsv", HardwareApi.Qsv), "mp4", MediaQuality.High)); + Assert.Equal(["-c:v", "h264_amf", "-quality", "balanced", "-rc", "cqp", "-qp_i", "27", "-qp_p", "29", "-qp_b", "29", "-profile:v", "high", "-pix_fmt", "nv12"], + Codec(new("h264_amf", HardwareApi.Amf), "mkv", MediaQuality.Small)); + Assert.Equal(["-c:v", "hevc_amf", "-quality", "balanced", "-rc", "cqp", "-qp_i", "25", "-qp_p", "27", "-profile:v", "main", "-pix_fmt", "nv12"], + Codec(new("hevc_amf", HardwareApi.Amf), "mkv", MediaQuality.Balanced)); + Assert.Equal(["-c:v", "h264_videotoolbox", "-q:v", "60", "-profile:v", "high", "-pix_fmt", "nv12"], + Codec(new("h264_videotoolbox", HardwareApi.VideoToolbox), "mov", MediaQuality.Balanced)); + Assert.Equal(["-c:v", "hevc_vaapi", "-rc_mode", "CQP", "-qp", "31", "-profile:v", "main"], + Codec(new("hevc_vaapi", HardwareApi.Vaapi), "mkv", MediaQuality.Small)); + + // Intel Macs: a bitrate from the output picture, 1080p30 H.264 Balanced ≈ 5.6 Mbit/s; less when scaled to 720p. + var bitrate = new HardwareEncoder("h264_videotoolbox", HardwareApi.VideoToolbox, HardwareRateControl.Bitrate); + Assert.Equal(["-c:v", "h264_videotoolbox", "-b:v", "5593k", "-profile:v", "high", "-pix_fmt", "nv12"], + Codec(bitrate, "mp4", MediaQuality.Balanced)); + Assert.Equal("2486k", After(Codec(bitrate, "mp4", MediaQuality.Balanced, maxHeight: 720), "-b:v")); + Assert.True(HardwareVideo.BitrateKbps("hevc", MediaQuality.High, Hd, 0) < HardwareVideo.BitrateKbps("h264", MediaQuality.High, Hd, 0)); + Assert.True(HardwareVideo.BitrateKbps("h264", MediaQuality.Small, Hd, 0) < HardwareVideo.BitrateKbps("h264", MediaQuality.Balanced, Hd, 0)); + Assert.Equal(300, HardwareVideo.BitrateKbps("hevc", MediaQuality.Small, new VideoStreamInfo(64, 64, 1, 0, 5), 0)); + + // Coarser than the software CRF by one, finer for High than for Small. + Assert.Equal([19, 24, 29], new[] { MediaQuality.High, MediaQuality.Balanced, MediaQuality.Small }.Select(q => HardwareVideo.Quantizer("h264", q))); + Assert.Equal([23, 27, 31], new[] { MediaQuality.High, MediaQuality.Balanced, MediaQuality.Small }.Select(q => HardwareVideo.Quantizer("hevc", q))); + } + + [Fact] + public void Ten_bit_and_hdr_stay_ten_bit_on_hevc_and_h264_stays_eight_bit() + { + var hdr = HdrClip.Video!; + var nvenc = HardwareVideo.CodecArguments(new("hevc_nvenc", HardwareApi.Nvenc), "mp4", MediaQuality.Balanced, hdr, 0); + Assert.Equal(("main10", "p010le", "hvc1"), (After(nvenc, "-profile:v"), After(nvenc, "-pix_fmt"), After(nvenc, "-tag:v"))); + Assert.Equal(("bt2020", "smpte2084", "bt2020nc"), (After(nvenc, "-color_primaries"), After(nvenc, "-color_trc"), After(nvenc, "-colorspace"))); + + // H.264 is 8-bit 4:2:0 whatever the source, as with x264. + var h264 = HardwareVideo.CodecArguments(new("h264_qsv", HardwareApi.Qsv), "mp4", MediaQuality.Balanced, hdr, 0); + Assert.Equal(("high", "nv12"), (After(h264, "-profile:v"), After(h264, "-pix_fmt"))); + Assert.DoesNotContain("-color_trc", h264); + Assert.False(HardwareVideo.TenBit("h264", hdr)); + Assert.True(HardwareVideo.TenBit("hevc", hdr)); + Assert.True(HardwareVideo.TenBit("hevc", Hd with { PixelFormat = "p010le" })); + Assert.False(HardwareVideo.TenBit("hevc", Hd)); + + // VA-API: 10-bit frames are uploaded as P010. + var vaapi = new HardwareEncoder("hevc_vaapi", HardwareApi.Vaapi); + Assert.Equal(",format=p010le,hwupload", HardwareVideo.FilterSuffix(vaapi, tenBit: true)); + Assert.Equal(",format=nv12,hwupload", HardwareVideo.FilterSuffix(vaapi, tenBit: false)); + Assert.Equal("", HardwareVideo.FilterSuffix(new("hevc_nvenc", HardwareApi.Nvenc), tenBit: true)); + } + + [Fact] + public void The_plan_uses_the_hardware_encoder_only_where_it_applies() + { + var on = new MediaOptions { HardwareEncoding = true }; + var nvenc = new HardwareEncoder("h264_nvenc", HardwareApi.Nvenc); + var mp4 = Args("mp4", on, nvenc); + Assert.Equal(("h264_nvenc", "24", "copy"), (After(mp4, "-c:v"), After(mp4, "-cq"), After(mp4, "-c:a"))); // the AAC sound is kept as it is + Assert.Equal("scale=w='trunc(iw/2)*2':h='trunc(ih/2)*2'", After(mp4, "-vf")); + Assert.Equal("+faststart", After(mp4, "-movflags")); + Assert.DoesNotContain("-crf", mp4); + + // HEVC keeps the hvc1 tag in MP4 and MOV, and the 10 bits of an HDR source. + var hevcOptions = new MediaOptions { HardwareEncoding = true, VideoCodec = VideoCodec.Hevc, MaxHeight = 1080 }; + var hevc = Args("mov", hevcOptions, new HardwareEncoder("hevc_videotoolbox", HardwareApi.VideoToolbox), HdrClip); + Assert.Equal(("hevc_videotoolbox", "hvc1", "p010le", "main10"), (After(hevc, "-c:v"), After(hevc, "-tag:v"), After(hevc, "-pix_fmt"), After(hevc, "-profile:v"))); + Assert.DoesNotContain("-tag:v", Args("mkv", hevcOptions, new HardwareEncoder("hevc_nvenc", HardwareApi.Nvenc), HdrClip)); + + // VA-API: the device before the input, the upload after the scaling. + var vaapi = Args("mkv", on, new HardwareEncoder("h264_vaapi", HardwareApi.Vaapi)); + Assert.True(vaapi.ToList().IndexOf("-vaapi_device") < vaapi.ToList().IndexOf("-i")); + Assert.Equal(HardwareVideo.VaapiDevice, After(vaapi, "-vaapi_device")); + Assert.Equal("scale=w='trunc(iw/2)*2':h='trunc(ih/2)*2',format=nv12,hwupload", After(vaapi, "-vf")); + Assert.DoesNotContain("-pix_fmt", vaapi); + + // Software when the preset doesn't ask, when the encoder is for another codec, and the copy rules stay. + Assert.Equal("libx264", After(Args("mp4", new MediaOptions(), nvenc), "-c:v")); + Assert.Equal("libx265", After(Args("mp4", new MediaOptions { HardwareEncoding = true, VideoCodec = VideoCodec.Hevc }, nvenc), "-c:v")); + Assert.Equal("libvpx-vp9", After(Args("webm", on, nvenc), "-c:v")); + var phone = HdClip with { Video = new VideoStreamInfo(1920, 1080, 1, 0, 30, "h264", "yuv420p") }; + Assert.Equal("copy", After(Args("mp4", on, nvenc, phone), "-c:v")); + // A missing x265 doesn't matter while the hardware encoder writes HEVC. + Assert.Equal("hevc_nvenc", After(MediaEncoding.Plan("in.mov", "out.mkv", "mkv", new MediaOptions { HardwareEncoding = true, VideoCodec = VideoCodec.Hevc }, + HdClip, "work", encoders: new HashSet { "aac" }, hardware: new HardwareEncoder("hevc_nvenc", HardwareApi.Nvenc)).Single().Arguments, "-c:v")); + } + + [Fact] + public void The_probe_is_a_tiny_test_encode_with_the_real_options() + { + var qsv = HardwareVideo.ProbeArguments(new HardwareEncoder("hevc_qsv", HardwareApi.Qsv), tenBit: true); + Assert.Equal(["-f", "lavfi", "-i", "testsrc=size=256x144:rate=30:duration=0.1"], qsv.SkipWhile(a => a != "-f").Take(4)); + Assert.Equal(("hevc_qsv", "p010le", "main10", "27"), (After(qsv, "-c:v"), After(qsv, "-pix_fmt"), After(qsv, "-profile:v"), After(qsv, "-global_quality"))); + Assert.Equal(["-f", "null", "-"], qsv.TakeLast(3)); + + var vaapi = HardwareVideo.ProbeArguments(new HardwareEncoder("h264_vaapi", HardwareApi.Vaapi), tenBit: false); + Assert.True(vaapi.IndexOf("-vaapi_device") < vaapi.IndexOf("-i")); + Assert.Equal("null,format=nv12,hwupload", After(vaapi, "-vf")); + + var intelMac = HardwareVideo.ProbeArguments(new HardwareEncoder("h264_videotoolbox", HardwareApi.VideoToolbox, HardwareRateControl.Bitrate), false); + Assert.Contains("-b:v", intelMac); + Assert.DoesNotContain("-q:v", intelMac); + } + + // ───────────────────────── Software retry ───────────────────────── + + private sealed class ListLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (Entries) + Entries.Add((logLevel, formatter(state, exception))); + } + } + + private static IReadOnlyList FakePlan(HardwareEncoder? encoder) => + [new FfmpegPass(["-c:v", encoder?.Name ?? "libx264"], 1)]; + + private static Task Fallback(HardwareEncoder? hardware, Func, Task> run, string output, ILogger logger, + CancellationToken cancellationToken) => + FfmpegConverter.RunWithFallbackAsync(FakePlan(hardware), hardware, FakePlan, run, output, logger, cancellationToken); + + [Fact] + public async Task A_failing_hardware_encode_is_redone_in_software_and_logged() + { + var output = Path.Combine(fx.NewFolder(), "out.mp4"); + var runs = new List(); + var logger = new ListLogger(); + Task Run(IReadOnlyList passes) + { + var encoder = After(passes[0].Arguments, "-c:v"); + runs.Add(encoder); + if (encoder != "h264_nvenc") + return Task.CompletedTask; + File.WriteAllText(output, "half-written"); + throw new InvalidOperationException("FFmpeg failed (exit code -22): No capable devices found"); + } + + await Fallback(new HardwareEncoder("h264_nvenc", HardwareApi.Nvenc), Run, output, logger, CancellationToken.None); + + Assert.Equal(["h264_nvenc", "libx264"], runs); + Assert.False(File.Exists(output)); // the partial hardware output is gone before the retry + var (level, message) = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, level); + Assert.Contains("h264_nvenc", message); + Assert.Contains("No capable devices found", message); + } + + [Fact] + public async Task Success_cancellation_and_software_failures_are_not_retried() + { + var output = Path.Combine(fx.NewFolder(), "out.mp4"); + var logger = new ListLogger(); + var nvenc = new HardwareEncoder("h264_nvenc", HardwareApi.Nvenc); + + var runs = 0; + await Fallback(nvenc, _ => { runs++; return Task.CompletedTask; }, output, logger, CancellationToken.None); + Assert.Equal(1, runs); + + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + runs = 0; + await Assert.ThrowsAnyAsync(() => Fallback(nvenc, + _ => { runs++; throw new OperationCanceledException(cancelled.Token); }, output, logger, cancelled.Token)); + Assert.Equal(1, runs); + + // Without hardware a failure is the user's error message, as before. + var error = await Assert.ThrowsAsync(() => Fallback(null, + _ => throw new InvalidOperationException("FFmpeg failed"), output, logger, CancellationToken.None)); + Assert.Equal("FFmpeg failed", error.Message); + // And when the software retry fails too, its error is the one reported. + error = await Assert.ThrowsAsync(() => Fallback(nvenc, + passes => throw new InvalidOperationException("failed with " + After(passes[0].Arguments, "-c:v")), output, logger, CancellationToken.None)); + Assert.Equal("failed with libx264", error.Message); + Assert.DoesNotContain(logger.Entries, e => e.Level > LogLevel.Warning); + } + + [Fact] + public async Task An_encoder_ffmpeg_does_not_have_falls_back_to_x264_in_a_real_conversion() + { + Assert.SkipUnless(Tools is not null, "FFmpeg is not installed (pwsh build/fetch-engines.ps1 -Only ffmpeg)"); + var folder = fx.NewFolder(); + var source = await MakeClipAsync(folder, "clip.mov"); + var logger = new ListLogger(); + var converter = new FfmpegConverter(logger) + { + SelectHardware = (_, codec, _, _, _) => Task.FromResult(new HardwareEncoder($"{codec}_filee_missing", HardwareApi.Nvenc)), + }; + Assert.True(converter.GetStatus().IsAvailable); + + var outputs = await converter.ConvertAsync(Step(source, "mov", "mkv", folder, new MediaOptions { HardwareEncoding = true, Quality = MediaQuality.Small }), + null, CancellationToken.None); + + Assert.Equal("h264", await CodecOfAsync(Assert.Single(outputs))); + Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("h264_filee_missing", StringComparison.Ordinal)); + } + + /// Runs only where a GPU encoder works (e.g. a laptop with Intel graphics); CI has none. + [Theory] + [InlineData(VideoCodec.Default, "h264")] + [InlineData(VideoCodec.Hevc, "hevc")] + public async Task A_working_hardware_encoder_writes_the_video(VideoCodec videoCodec, string codec) + { + Assert.SkipUnless(Tools is not null, "FFmpeg is not installed (pwsh build/fetch-engines.ps1 -Only ffmpeg)"); + var ffmpeg = Tools!.Value.Ffmpeg; + var logger = new ListLogger(); + var hardware = await HardwareProbe.SelectAsync(ffmpeg, codec, false, FfmpegConverter.EncodersOf(ffmpeg), logger, CancellationToken.None); + Assert.SkipUnless(hardware is not null, $"No {codec} hardware encoder works on this computer"); + + var folder = fx.NewFolder(); + var source = await MakeClipAsync(folder, "clip.mov"); + var converter = new FfmpegConverter(logger); + converter.GetStatus(); + var outputs = await converter.ConvertAsync(Step(source, "mov", "mp4", folder, + new MediaOptions { HardwareEncoding = true, Quality = MediaQuality.Small, VideoCodec = videoCodec }), null, CancellationToken.None); + + Assert.Equal(codec, await CodecOfAsync(Assert.Single(outputs))); + Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning); // no software retry + } + + // ───────────────────────── Quick queries ───────────────────────── + + [Fact] + public void Version_and_encoder_queries_give_up_on_an_ffmpeg_that_hangs() + { + var hanging = HangingProgram(fx.NewFolder()); + var watch = Stopwatch.StartNew(); + Assert.Throws(() => FfmpegConverter.VersionOf(hanging, TimeSpan.FromSeconds(1))); + Assert.Null(FfmpegConverter.EncodersOf(hanging, TimeSpan.FromSeconds(1))); + // Before, both read the output to its end first and waited as long as the program hung (here a minute). + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(20), $"took {watch.Elapsed}"); + } + + [Fact] + public void Encoder_lists_are_read() + { + var encoders = FfmpegConverter.ParseEncoders( + "Encoders:\n V..... = Video\n ------\n V....D libx264 libx264 H.264 / AVC\n" + + " V..... h264_qsv H.264 (Intel Quick Sync Video acceleration) (codec h264)\r\n A....D aac AAC\n"); + Assert.Contains("libx264", encoders); + Assert.Contains("h264_qsv", encoders); + Assert.Contains("aac", encoders); + Assert.DoesNotContain("Video", encoders); + } + + /// A program that writes nothing and runs for a minute, keeping its output open (a hung FFmpeg). + private static string HangingProgram(string folder) + { + if (OperatingSystem.IsWindows()) + { + var script = Path.Combine(folder, "hanging-ffmpeg.cmd"); + File.WriteAllText(script, "@ping -n 60 127.0.0.1 >nul\r\n"); + return script; + } + var shell = Path.Combine(folder, "hanging-ffmpeg"); + File.WriteAllText(shell, "#!/bin/sh\nsleep 60\n"); + File.SetUnixFileMode(shell, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return shell; + } + + // ───────────────────────── Helpers ───────────────────────── + + private sealed class FolderAllocator(string folder) : IOutputAllocator + { + public string? Allocate(string extension, string? suffix = null) => Path.Combine(folder, $"out{suffix}.{extension}"); + } + + private static ConversionStep Step(string input, string from, string to, string folder, MediaOptions media) => + new(input, from, to, new Preset { TargetFormat = to, Media = media }, new FolderAllocator(folder), folder); + + /// A one-second 320×240 test pattern with a tone, in a yuv444p H.264 that every target re-encodes. + private static async Task MakeClipAsync(string folder, string name) + { + var path = Path.Combine(folder, name); + var result = await ProcessRunner.RunAsync(Tools!.Value.Ffmpeg, + ["-hide_banner", "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25:duration=1", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=44100:duration=1", "-c:v", "libx264", "-pix_fmt", "yuv444p", path], + TimeSpan.FromMinutes(1), CancellationToken.None); + Assert.True(result.ExitCode == 0, result.StandardError); + return path; + } + + private static async Task CodecOfAsync(string file) + { + var result = await ProcessRunner.RunAsync(Tools!.Value.Ffprobe, + ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name", "-of", "default=nw=1:nk=1", file], + TimeSpan.FromMinutes(1), CancellationToken.None); + Assert.True(result.ExitCode == 0, result.StandardError); + return result.StandardOutput.Trim(); + } +} diff --git a/tests/Filee.Engines.Tests/MediaTests.cs b/tests/Filee.Engines.Tests/MediaTests.cs index 06f8f3dd..5aafaf98 100644 --- a/tests/Filee.Engines.Tests/MediaTests.cs +++ b/tests/Filee.Engines.Tests/MediaTests.cs @@ -54,6 +54,7 @@ private static void SkipUnlessInstalled() => [InlineData("mov", "apng")] [InlineData("rm", "mp4")] [InlineData("dss", "mp3")] + [InlineData("mp4", "png")] [InlineData("ape", "flac")] [InlineData("wv", "mp3")] [InlineData("tta", "wv")] @@ -82,6 +83,55 @@ public void Video_to_images_goes_through_one_still_frame_not_an_animated_gif(str var route = Planner().Plan(from, to); Assert.NotNull(route); Assert.Equal([("ffmpeg", from, "png"), (imageEngine, "png", to)], route.Steps.Select(s => (s.Converter.Id, s.From, s.To))); + Assert.DoesNotContain(route.Steps, s => s.FrameSequence); + } + + [Theory] + [InlineData("mp4", "png")] + [InlineData("mov", "jpg")] + [InlineData("mkv", "jpg")] + public void Every_frame_is_one_ffmpeg_step_only_when_asked_for(string from, string to) + { + var frames = Planner().Plan(from, to, frames: true); + Assert.NotNull(frames); + var step = Assert.Single(frames.Steps); + Assert.Equal(("ffmpeg", from, to, true), (step.Converter.Id, step.From, step.To, step.FrameSequence)); + + // Without it the same conversion is the still frame, as before. + var still = Planner().Plan(from, to); + Assert.NotNull(still); + Assert.DoesNotContain(still.Steps, s => s.FrameSequence); + Assert.Equal(("ffmpeg", from, "png"), (still.Steps[0].Converter.Id, still.Steps[0].From, still.Steps[0].To)); + } + + [Theory] + [InlineData("mp4", "webp")] // frames are PNG or JPG only, never a step towards another format + [InlineData("png", "jpg")] // not a video + [InlineData("gif", "png")] + public void Every_frame_has_no_route_but_its_own(string from, string to) => + Assert.Null(Planner().Plan(from, to, frames: true)); + + [Fact] + public void A_video_to_jpg_is_still_turned_by_the_image_engine() + { + // Rotate and flip presets on a video: the still frame is turned by ImageMagick, as before frames existed. + var route = Planner().Plan("mp4", "jpg"); + Assert.NotNull(route); + Assert.Equal(1, route.FirstTransformStep()); + } + + [Fact] + public void Ordinary_routes_never_take_a_frame_step() + { + var planner = Planner(); + foreach (var from in FormatRegistry.Known.Where(f => f.Category == FormatCategory.Video).Select(f => f.Id)) + { + foreach (var to in new[] { "jpg", "png", "webp", "pdf", "tiff", "gif", "mp4" }) + { + if (planner.Plan(from, to) is { } route) + Assert.DoesNotContain(route.Steps, s => s.FrameSequence); + } + } } [Theory] @@ -536,7 +586,8 @@ private async Task ConvertAsync(string input, string target, MediaOption var job = await fx.ConvertAsync([input], preset); Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); var output = Assert.Single(job.Outputs); - Assert.Equal("." + FormatRegistry.Get(target).PrimaryExtension, Path.GetExtension(output)); + if (!Directory.Exists(output)) + Assert.Equal("." + FormatRegistry.Get(target).PrimaryExtension, Path.GetExtension(output)); return output; } @@ -812,6 +863,106 @@ public async Task Video_gives_a_still_frame_for_images() Assert.Equal((640u, 480u), (image.Width, image.Height)); } + [Fact] + public async Task Video_to_image_slices_frames_to_unique_subfolders() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var source = await MakeClipAsync(folder, "seq.mkv", seconds: 2, size: "640x360"); + + var options = new MediaOptions { SaveEveryFrame = true }; + var pngFolder1 = await ConvertAsync(source, "png", options); + Assert.True(Directory.Exists(pngFolder1)); + Assert.Equal(Path.Combine(folder, "seq_frames"), pngFolder1); + var png1Files = Directory.GetFiles(pngFolder1, "frame_*.png"); + Assert.NotEmpty(png1Files); + Assert.Contains(png1Files, f => Path.GetFileName(f) == "frame_000001.png"); + + // A second run creates a unique folder seq_frames (2) instead of writing into an existing one. + var pngFolder2 = await ConvertAsync(source, "png", options); + Assert.True(Directory.Exists(pngFolder2)); + Assert.Equal(Path.Combine(folder, "seq_frames (2)"), pngFolder2); + Assert.NotEmpty(Directory.GetFiles(pngFolder2, "frame_*.png")); + + // JPG sequence extraction also creates unique folder and valid images + var jpgFolder = await ConvertAsync(source, "jpg", options); + Assert.True(Directory.Exists(jpgFolder)); + Assert.Equal(Path.Combine(folder, "seq_frames (3)"), jpgFolder); + var jpgFiles = Directory.GetFiles(jpgFolder, "frame_*.jpg"); + Assert.NotEmpty(jpgFiles); + Assert.Contains(jpgFiles, f => Path.GetFileName(f) == "frame_000001.jpg"); + using var image = new ImageMagick.MagickImage(jpgFiles.First()); + Assert.True(image.Width > 0 && image.Height > 0); + } + + [Fact] + public void Video_frame_sequence_arguments_use_proper_flags() + { + var video = new VideoStreamInfo(720, 480, 8.0 / 9.0, 0, 30); + var jpg = MediaEncoding.FrameSequence("in.mp4", "out_frames/frame_%06d.jpg", "jpg", video, quality: 100, frameInterval: 5); + Assert.Contains("-q:v", jpg); + Assert.Equal("2", After(jpg, "-q:v")); + Assert.Contains("-vf", jpg); + var vf = After(jpg, "-vf"); + Assert.Contains("scale=w='trunc(iw*sar/2)*2':h=ih,setsar=1", vf); + Assert.Contains("select='not(mod(n\\,5))'", vf); + Assert.Contains("-fps_mode", jpg); + Assert.Equal("vfr", After(jpg, "-fps_mode")); + Assert.Equal("out_frames/frame_%06d.jpg", jpg[^1]); + + Assert.Equal("0:V:0", After(jpg, "-map")); + + // Every frame: each decoded frame once, never paced to a constant rate. + var every = MediaEncoding.FrameSequence("in.mp4", "out_frames/frame_%06d.png", "png", video); + Assert.Equal("passthrough", After(every, "-fps_mode")); + Assert.Equal("0:V:0", After(every, "-map")); + Assert.Equal("png", After(every, "-c:v")); + + Assert.DoesNotContain("-q:v", every); + Assert.EndsWith("setsar=1,null", After(every, "-vf")); // square pixels, no frame left out + Assert.Equal("out_frames/frame_%06d.png", every[^1]); + } + + [Fact] + public void Every_frame_is_estimated_before_anything_is_written() + { + var hd = new VideoStreamInfo(1920, 1080, 1, 0, 30); + var hour = new MediaInfo(TimeSpan.FromHours(1), hd, null, null); + // An hour at 30 fps: 108,000 pictures, over the limit; every second frame is fine. + Assert.Equal(108_000, FrameSequenceWriter.EstimatedFrames(hour, 1)); + Assert.Equal(54_000, FrameSequenceWriter.EstimatedFrames(hour, 2)); + Assert.True(FrameSequenceWriter.EstimatedFrames(hour, 1) > MediaOptions.MaxFrames); + // Unknown duration or frame rate: no estimate, no refusal. + Assert.Null(FrameSequenceWriter.EstimatedFrames(hour with { Duration = null }, 1)); + Assert.Null(FrameSequenceWriter.EstimatedFrames(hour with { Video = hd with { FrameRate = 0 } }, 1)); + + // Full HD: about 3 MB a PNG, half a MB a JPG; 10-bit video becomes 16-bit PNG, twice the size. + Assert.Equal(3_110_400, FrameSequenceWriter.EstimatedBytes(hd, "png", 1)); + Assert.Equal(518_400, FrameSequenceWriter.EstimatedBytes(hd, "jpg", 1)); + Assert.Equal(6_220_800, FrameSequenceWriter.EstimatedBytes(hd with { PixelFormat = "yuv420p10le" }, "png", 1)); + } + + [Fact] + public async Task Every_frame_of_a_variable_frame_rate_video_is_saved_once() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + // One second at 30 fps, then three seconds at 10 fps: 60 frames, as a phone records a darker scene. Paced to a + // constant 30 fps the slow part would come out three times over (120 pictures). + var source = Path.Combine(folder, "vfr.mkv"); + await RunAsync(Tools!.Value.Ffmpeg, + [ + "-hide_banner", "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=30:duration=2", + "-vf", "setpts='if(lt(N,30),N/30,1+(N-30)/10)/TB'", "-fps_mode", "vfr", "-c:v", "libx264", "-pix_fmt", "yuv420p", source, + ]); + var decoded = (await ProbeAsync(source)).Video!.Frames; + Assert.Equal(60, decoded); + + var frames = await ConvertAsync(source, "png", new MediaOptions { SaveEveryFrame = true }); + + Assert.Equal(decoded, Directory.GetFiles(frames, "*.png").Length); + } + [Fact] public async Task Odd_sized_surround_video_is_encoded_everywhere() { @@ -958,7 +1109,7 @@ private sealed class FolderAllocator(string folder) : IOutputAllocator public string? Allocate(string extension, string? suffix = null) { - var path = Path.Combine(folder, $"out{suffix}.{extension}"); + var path = Path.Combine(folder, extension.Length == 0 ? $"out{suffix}" : $"out{suffix}.{extension}"); Allocated.Add(path); return path; } @@ -1045,6 +1196,56 @@ public async Task Cancelling_kills_ffmpeg_and_removes_the_partial_output() Assert.False(RunningFfmpeg(Tools.Value.Ffmpeg)); } + [Fact] + public async Task Cancelling_every_frame_leaves_no_pictures_behind() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var source = await MakeClipAsync(folder, "cancelframes.mp4", seconds: 60, size: "1280x720", audio: false, + extra: ["-c:v", "libx264", "-preset", "ultrafast"]); + using var cancellation = new CancellationTokenSource(); + var recorder = new Recorder(v => + { + if (v > 0.05) + cancellation.Cancel(); + }); + var converter = new FfmpegConverter(); + converter.GetStatus(); + var preset = new Preset { TargetFormat = "png", Media = { SaveEveryFrame = true } }; + var step = new ConversionStep(source, "mp4", "png", preset, new FolderAllocator(folder), folder, FrameSequence: true); + cancellation.CancelAfter(TimeSpan.FromSeconds(30)); // in case no progress arrives at all + + await Assert.ThrowsAnyAsync(() => converter.ConvertAsync(step, recorder, cancellation.Token)); + + // Neither the output folder nor the hidden one the frames were written into is left, and FFmpeg has ended. + Assert.Equal([Path.Combine(folder, "out_frames")], ((FolderAllocator)step.Output).Allocated); + Assert.Equal([source], Directory.GetFileSystemEntries(folder)); + Assert.False(RunningFfmpeg(Tools!.Value.Ffmpeg)); + } + + [Fact] + public async Task Every_frame_goes_into_its_own_folder_named_like_other_outputs() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var source = await MakeClipAsync(folder, "100% clip.mkv", seconds: 1, size: "160x120", fps: 10, audio: false); + var preset = new Preset { TargetFormat = "jpg", Media = { SaveEveryFrame = true } }; + + var first = Assert.Single((await fx.ConvertAsync([source], preset)).Outputs); + var second = Assert.Single((await fx.ConvertAsync([source], preset)).Outputs); + preset.Output.Conflict = ConflictPolicy.Skip; + var skipped = await fx.ConvertAsync([source], preset); + + Assert.Equal(Path.Combine(folder, "100% clip_frames"), first); + Assert.Equal(Path.Combine(folder, "100% clip_frames (2)"), second); + Assert.Empty(skipped.Outputs); + Assert.Equal(FileState.Skipped, skipped.Files.Single().State); + Assert.Equal(10, Directory.GetFiles(first, "frame_*.jpg").Length); + Assert.False(new DirectoryInfo(first).Attributes.HasFlag(FileAttributes.Hidden)); + // Nothing else next to the source: no hidden folder left over. + Assert.Equal(3, Directory.GetFileSystemEntries(folder).Length); + } + /// /// True if a process started from is running (tests of this class run one at a time, so /// it can only be the one that should have been killed). diff --git a/tests/Filee.Engines.Tests/PdfCompressionTests.cs b/tests/Filee.Engines.Tests/PdfCompressionTests.cs new file mode 100644 index 00000000..bdea2285 --- /dev/null +++ b/tests/Filee.Engines.Tests/PdfCompressionTests.cs @@ -0,0 +1,373 @@ +// PDF → smaller PDF ("Compress PDF"): pictures scaled down to the level's resolution where they are drawn (also inside +// forms), soft masks kept, text untouched, page ranges and one file per page, nothing made bigger, password-protected +// files refused like the other PDF conversions, and Ghostscript's rewrite when it is installed. The PDFs are built in +// the tests: photos (noise, so they don't compress to nothing) drawn small at a high resolution, and Helvetica text. + +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Pdf; +using Filee.Engines.Vector; +using ImageMagick; +using PdfSharp.Drawing; +using PdfSharp.Pdf; +using PdfSharp.Pdf.IO; +using PigDocument = UglyToad.PdfPig.PdfDocument; + +namespace Filee.Engines.Tests; + +public class PdfCompressionTests(EngineFixture fx) : IClassFixture +{ + private const string Sentence = "Quarterly report with photos"; + + private static bool GhostscriptInstalled => GhostscriptConverter.Locate() is not null; + + /// A photo-like picture: a gradient with noise, so JPEG and Flate can't make it tiny. + private static string Photo(string folder, string name, uint width, uint height, MagickFormat format, bool transparentCorner = false) + { + using var image = new MagickImage("gradient:#C04020-#2060C0", width, height); + image.AddNoise(NoiseType.Gaussian, 0.6); + if (transparentCorner) + { + image.Alpha(AlphaOption.Set); + using var pixels = image.GetPixels(); + pixels.SetArea(0, 0, width / 4, height / 4, new byte[width / 4 * (height / 4) * image.ChannelCount]); + } + image.Quality = 95; + var path = Path.Combine(folder, name); + image.Write(path, format); + return path; + } + + /// + /// pages, each with a line of text and a 2400 × 1600 picture drawn 3 × 2 inches (800 dpi). + /// + private static string ReportPdf(string folder, int pages = 1, MagickFormat format = MagickFormat.Jpeg, bool transparent = false) + { + var picture = Photo(folder, format == MagickFormat.Jpeg ? "photo.jpg" : "photo.png", 2400, 1600, format, transparent); + var pdf = new PdfBuilder(); + for (var i = 1; i <= pages; i++) + { + pdf.NewPage(); + pdf.Line($"{Sentence} {i}", 14, pdf.Font(), 72, 72); + pdf.Picture(picture, 72, 100, 216, 144); + } + return pdf.Save(Path.Combine(folder, "report.pdf")); + } + + private static Preset Compress(PdfCompression level = PdfCompression.Ebook, bool ghostscript = false) => new() + { + TargetFormat = "pdf", + Pdf = { Compression = level, CompressWithGhostscript = ghostscript }, + Output = { FileNamePattern = "{name}_compressed" }, + }; + + private static void AssertDone(ConversionJob job) + { + var errors = string.Join("; ", job.Files.Where(f => f.State == FileState.Failed).Select(f => $"{f.ErrorKey} {f.ErrorDetail}")); + Assert.True(job.State == JobState.Completed, $"{job.State}: {errors}"); + } + + /// Width and height in pixels of every picture on every page, with whether it has a soft mask. + private static List<(int Width, int Height, bool Masked)> Pictures(string path) + { + using var pdf = PigDocument.Open(path); + return pdf.GetPages().SelectMany(p => p.GetImages()) + .Select(i => (i.WidthInSamples, i.HeightInSamples, i.MaskImage is not null)) + .ToList(); + } + + private static List Texts(string path) + { + using var pdf = PigDocument.Open(path); + return pdf.GetPages().Select(p => p.Text).ToList(); + } + + [Theory] + [InlineData(PdfCompression.Screen, 216)] + [InlineData(PdfCompression.Ebook, 450)] + [InlineData(PdfCompression.Print, 900)] + public async Task Pictures_are_scaled_to_the_level_resolution_and_text_stays(PdfCompression level, int expectedWidth) + { + var dir = fx.NewFolder(); + var source = ReportPdf(dir, pages: 2); + + var job = await fx.ConvertAsync([source], Compress(level)); + + AssertDone(job); + var output = job.Outputs.Single(); + Assert.Equal("report_compressed.pdf", Path.GetFileName(output)); + Assert.True(new FileInfo(output).Length < new FileInfo(source).Length / 4, $"{new FileInfo(output).Length} of {new FileInfo(source).Length}"); + Assert.Equal(Texts(source), Texts(output)); + Assert.All(Pictures(output), p => Assert.InRange(p.Width, expectedWidth - 2, expectedWidth + 2)); + Assert.All(Pictures(output), p => Assert.Equal(p.Width * 2 / 3, p.Height, 2.0)); + Assert.Null(job.Files.Single().NoticeKey); + } + + [Fact] + public async Task A_picture_embedded_on_every_page_is_stored_once() + { + var dir = fx.NewFolder(); + var source = ReportPdf(dir, pages: 3, format: MagickFormat.Png, transparent: true); + using (var input = PdfReader.Open(source, PdfDocumentOpenMode.Import)) + Assert.Equal(6, ImagesOf(input).Count); // three pictures, three soft masks + + var job = await fx.ConvertAsync([source], Compress()); + + AssertDone(job); + using var output = PdfReader.Open(job.Outputs.Single(), PdfDocumentOpenMode.Import); + Assert.Equal(2, ImagesOf(output).Count); + Assert.Equal(3, Pictures(job.Outputs.Single()).Count(p => p.Masked)); + } + + [Fact] + public async Task A_transparent_picture_keeps_its_soft_mask() + { + var dir = fx.NewFolder(); + var source = ReportPdf(dir, format: MagickFormat.Png, transparent: true); + Assert.True(Pictures(source).Single().Masked); + + var job = await fx.ConvertAsync([source], Compress(PdfCompression.Screen)); + + AssertDone(job); + var output = job.Outputs.Single(); + Assert.True(new FileInfo(output).Length < new FileInfo(source).Length); + var picture = Pictures(output).Single(); + Assert.True(picture.Masked); + Assert.InRange(picture.Width, 214, 218); + + using var pdf = PigDocument.Open(output); + var mask = pdf.GetPage(1).GetImages().Single().MaskImage!; + Assert.Equal(picture.Width, mask.WidthInSamples); + // The transparent corner is still transparent, the rest still opaque. + Assert.True(mask.TryGetPng(out var png)); + using var alpha = new MagickImage(png); + using var values = alpha.GetPixels(); + Assert.True(values.GetPixel(2, 2).GetChannel(0) < 16); + Assert.True(values.GetPixel((int)alpha.Width - 3, (int)alpha.Height - 3).GetChannel(0) > 240); + } + + [Fact] + public async Task Gray_pictures_stay_gray() + { + var dir = fx.NewFolder(); + var photo = Photo(dir, "gray.jpg", 2000, 2000, MagickFormat.Jpeg); + using (var gray = new MagickImage(photo)) + { + gray.Grayscale(); + gray.Write(photo, MagickFormat.Jpeg); + } + var pdf = new PdfBuilder().NewPage(); + pdf.Picture(photo, 72, 72, 144, 144); + var source = pdf.Save(Path.Combine(dir, "gray.pdf")); + + var job = await fx.ConvertAsync([source], Compress()); + + AssertDone(job); + using var output = PdfReader.Open(job.Outputs.Single(), PdfDocumentOpenMode.Import); + var image = ImagesOf(output).Single(); + Assert.Equal("/DeviceGray", image.Elements.GetName("/ColorSpace")); + Assert.Equal(300, image.Elements.GetInteger("/Width")); + Assert.Equal("/DCTDecode", image.Elements.GetName("/Filter")); + } + + [Fact] + public async Task Pictures_in_forms_are_sized_where_the_form_draws_them() + { + var dir = fx.NewFolder(); + var photo = Photo(dir, "photo.jpg", 3000, 3000, MagickFormat.Jpeg); + var source = Path.Combine(dir, "form.pdf"); + using (var document = new PdfDocument()) + { + var page = document.AddPage(); + // The form holds the picture at 1000 × 1000 points; the page draws the form at a tenth: 100 pt = 1.39 in. + var form = new XForm(document, XUnit.FromPoint(1000), XUnit.FromPoint(1000)); + using (var image = XImage.FromFile(photo)) + using (var formGraphics = XGraphics.FromForm(form)) + formGraphics.DrawImage(image, 0, 0, 1000, 1000); + using (var graphics = XGraphics.FromPdfPage(page)) + graphics.DrawImage(form, 72, 72, 100, 100); + document.Save(source); + } + + var job = await fx.ConvertAsync([source], Compress(PdfCompression.Ebook)); + + AssertDone(job); + using var output = PdfReader.Open(job.Outputs.Single(), PdfDocumentOpenMode.Import); + // 100 pt at 150 dpi = 208 px. + Assert.InRange(ImagesOf(output).Single().Elements.GetInteger("/Width"), 206, 210); + } + + [Fact] + public async Task Page_ranges_and_one_file_per_page_are_compressed_too() + { + var dir = fx.NewFolder(); + var source = ReportPdf(dir, pages: 3); + + var range = Compress(); + range.Pdf.PageRange = "2"; + var one = await fx.ConvertAsync([source], range); + var split = Compress(); + split.Pdf.SplitPages = true; + var each = await fx.ConvertAsync([source], split); + + AssertDone(one); + Assert.Equal([$"{Sentence} 2"], Texts(one.Outputs.Single())); + Assert.InRange(Pictures(one.Outputs.Single()).Single().Width, 448, 452); + AssertDone(each); + Assert.Equal(["report_compressed_p1.pdf", "report_compressed_p2.pdf", "report_compressed_p3.pdf"], + each.Outputs.Select(Path.GetFileName).Order(StringComparer.Ordinal)); + Assert.All(each.Outputs, o => Assert.InRange(Pictures(o).Single().Width, 448, 452)); + } + + [Fact] + public async Task Bookmarks_survive_when_the_whole_file_is_compressed() + { + var dir = fx.NewFolder(); + var source = ReportPdf(dir, pages: 2); + using (var document = PdfReader.Open(source, PdfDocumentOpenMode.Modify)) + { + document.Outlines.Add("Second page", document.Pages[1]); + document.Save(source); + } + + var job = await fx.ConvertAsync([source], Compress()); + + AssertDone(job); + using var output = PdfReader.Open(job.Outputs.Single(), PdfDocumentOpenMode.Import); + Assert.Equal("Second page", output.Outlines.Single().Title); + } + + [Fact] + public async Task Page_metadata_goes_only_when_the_preset_drops_it() + { + var dir = fx.NewFolder(); + var source = ReportPdf(dir); + using (var document = PdfReader.Open(source, PdfDocumentOpenMode.Modify)) + { + document.Pages[0].Elements["/Metadata"] = Xmp(document, "filee-page-xmp").Reference; + // A large packet, as image editors leave them: PDFsharp writes the document's XMP afresh, so the old one goes. + document.Internals.Catalog.Elements["/Metadata"] = Xmp(document, "filee-document-xmp" + new string(' ', 20_000)).Reference; + document.Save(source); + } + + var keep = await fx.ConvertAsync([source], Compress()); + var drop = Compress(); + drop.Image.KeepMetadata = false; + drop.Output.FileNamePattern = "{name}_stripped"; + var dropped = await fx.ConvertAsync([source], drop); + + AssertDone(keep); + AssertDone(dropped); + Assert.Contains("filee-page-xmp", Latin1(keep.Outputs.Single())); + Assert.DoesNotContain("filee-page-xmp", Latin1(dropped.Outputs.Single())); + Assert.DoesNotContain("filee-document-xmp", Latin1(keep.Outputs.Single())); + Assert.DoesNotContain("filee-document-xmp", Latin1(dropped.Outputs.Single())); + + static PdfDictionary Xmp(PdfDocument document, string text) + { + var xmp = new PdfDictionary(document); + xmp.Elements.SetName("/Type", "/Metadata"); + xmp.Elements.SetName("/Subtype", "/XML"); + xmp.CreateStream(System.Text.Encoding.ASCII.GetBytes($"")); + document.Internals.AddObject(xmp); + return xmp; + } + } + + [Fact] + public async Task A_pdf_that_cant_get_smaller_is_copied_as_it_is_with_a_notice() + { + var dir = fx.NewFolder(); + // Compressed once already: nothing left to gain the second time. + var once = (await fx.ConvertAsync([ReportPdf(dir)], Compress(PdfCompression.Screen))).Outputs.Single(); + + var job = await fx.ConvertAsync([once], Compress(PdfCompression.Screen)); + + AssertDone(job); + Assert.Equal(File.ReadAllBytes(once), File.ReadAllBytes(job.Outputs.Single())); + Assert.Equal("notice.pdf_not_smaller", job.Files.Single().NoticeKey); + } + + [Fact] + public async Task A_pdf_without_pictures_never_gets_bigger() + { + var dir = fx.NewFolder(); + var pdf = new PdfBuilder().NewPage(); + var y = 72.0; + for (var i = 0; i < 40; i++) + y = pdf.Paragraph($"Line {i}: the quick brown fox jumps over the lazy dog.", 10, pdf.Font(), 72, y, 450, 14); + var source = pdf.Save(Path.Combine(dir, "text.pdf")); + + var job = await fx.ConvertAsync([source], Compress(PdfCompression.Screen)); + + AssertDone(job); + Assert.True(new FileInfo(job.Outputs.Single()).Length <= new FileInfo(source).Length); + Assert.Equal(Texts(source), Texts(job.Outputs.Single())); + } + + [Fact] + public async Task Password_protected_pdf_fails_with_a_clear_message() + { + var dir = fx.NewFolder(); + var source = ReportPdf(dir); + PdfBuilder.Protect(source, "1234"); + + var job = await fx.ConvertAsync([source], Compress()); + + Assert.Equal(JobState.Failed, job.State); + Assert.Contains("password", job.Files.Single().ErrorDetail, StringComparison.OrdinalIgnoreCase); + Assert.Empty(job.Outputs); + } + + [Fact] + public async Task A_pdf_locked_only_against_changes_is_compressed_like_a_split_one() + { + var dir = fx.NewFolder(); + var source = ReportPdf(dir, pages: 2); + using (var document = PdfReader.Open(source, PdfDocumentOpenMode.Modify)) + { + document.SecuritySettings.OwnerPassword = "owner"; + document.Save(source); + } + + var job = await fx.ConvertAsync([source], Compress()); + + AssertDone(job); + Assert.True(new FileInfo(job.Outputs.Single()).Length < new FileInfo(source).Length / 4); + Assert.Equal(Texts(source), Texts(job.Outputs.Single())); + } + + [Fact] + public async Task Ghostscript_is_used_when_asked_and_installed() + { + Assert.SkipUnless(GhostscriptInstalled, "Ghostscript is not installed (pwsh build/fetch-engines.ps1 -Only ghostscript)."); + var dir = fx.NewFolder(); + var source = ReportPdf(dir, pages: 2); + + var job = await fx.ConvertAsync([source], Compress(PdfCompression.Screen, ghostscript: true)); + + AssertDone(job); + var output = job.Outputs.Single(); + Assert.True(new FileInfo(output).Length < new FileInfo(source).Length / 4); + Assert.Equal(Texts(source).Select(Words), Texts(output).Select(Words)); + Assert.All(Pictures(output), p => Assert.True(p.Width <= 300, $"{p.Width} px")); + } + + [Fact] + public void Compression_adds_no_route() + { + // PDF → PDF stays one PDFsharp step; Ghostscript is called from it, never planned. + var planner = fx.Catalog.CreatePlanner(["ghostscript"]); + Assert.Equal("pdfsharp", string.Join(',', planner.Plan("pdf", "pdf")!.Steps.Select(s => s.Converter.Id))); + Assert.Equal("pdfium", string.Join(',', planner.Plan("pdf", "png")!.Steps.Select(s => s.Converter.Id))); + Assert.Equal("ghostscript", string.Join(',', planner.Plan("pdf", "eps")!.Steps.Select(s => s.Converter.Id))); + Assert.Equal("docx-writer", string.Join(',', planner.Plan("pdf", "docx")!.Steps.Select(s => s.Converter.Id))); + } + + private static string Words(string text) => string.Concat(text.Where(c => !char.IsWhiteSpace(c))); + + private static string Latin1(string path) => System.Text.Encoding.Latin1.GetString(File.ReadAllBytes(path)); + + private static List ImagesOf(PdfDocument document) => + [.. document.Internals.GetAllObjects().OfType().Where(d => d.Elements.GetName("/Subtype") == "/Image")]; +} diff --git a/tests/Filee.Engines.Tests/PdfSlidesTests.cs b/tests/Filee.Engines.Tests/PdfSlidesTests.cs new file mode 100644 index 00000000..03498a89 --- /dev/null +++ b/tests/Filee.Engines.Tests/PdfSlidesTests.cs @@ -0,0 +1,300 @@ +// PDF → PPTX with a picture of each page on its own slide: slide count and size, pictures fitted and centred, the page +// range, the speaker notes, the Open XML SDK validator, Filee's own PPTX reader as a second reader, clear failures, and +// routes: the picture deck is reached from a PDF only and is never a step towards another format. +// The test PDFs are generated (PdfPig's writer, PDFsharp for rotation and passwords) and use the standard 14 fonts. + +using System.IO.Compression; +using System.Xml.Linq; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Validation; +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Engines.Hwp.Hwpx; +using Filee.Engines.Hwp.Hwpx.Pptx; +using Filee.Engines.Pdf; +using ImageMagick; +using PdfSharp.Pdf.IO; +using P = DocumentFormat.OpenXml.Presentation; + +namespace Filee.Engines.Tests; + +public class PdfSlidesTests(EngineFixture fx) : IClassFixture +{ + private const double A4Width = 595, A4Height = 842; + + /// A PDF with one page per size, each with "Page n" and a line of text on it. + private static string MakePdf(string folder, params (double Width, double Height)[] pages) + { + var pdf = new PdfBuilder(); + for (var i = 0; i < pages.Length; i++) + { + pdf.NewPage(pages[i].Width, pages[i].Height); + pdf.Line($"Page {i + 1}", 18, pdf.Font(bold: true), 40, 60); + pdf.Line($"Text on page number {i + 1} of the deck.", 11, pdf.Font(), 40, 90); + } + return pdf.Save(Path.Combine(folder, "deck.pdf")); + } + + private async Task RunAsync(string input, Preset? preset = null) => + await fx.ConvertAsync([input], preset ?? new Preset { TargetFormat = "pptx" }); + + private async Task ConvertAsync(string input, Preset? preset = null) + { + var job = await RunAsync(input, preset); + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var output = job.Outputs.Single(); + Assert.Equal(".pptx", Path.GetExtension(output)); + return output; + } + + /// What a slide shows: its picture's name, place and pixel size, and the speaker notes. + private sealed record Slide(string Name, long X, long Y, long Width, long Height, int PixelWidth, int PixelHeight, string Notes); + + private static (long Width, long Height, List Slides) Read(string pptx) + { + using var document = PresentationDocument.Open(pptx, false); + var presentation = document.PresentationPart!; + var size = presentation.Presentation!.SlideSize!; + var slides = new List(); + foreach (var id in presentation.Presentation.SlideIdList!.Elements()) + { + var part = (SlidePart)presentation.GetPartById(id.RelationshipId!.Value!); + var picture = Assert.Single(part.Slide!.Descendants()); + Assert.Empty(part.Slide!.Descendants()); // nothing but the page + var image = (ImagePart)part.GetPartById(picture.BlipFill!.Blip!.Embed!.Value!); + Assert.Equal("image/jpeg", image.ContentType); + MagickImageInfo info; + using (var stream = image.GetStream()) + info = new MagickImageInfo(stream); + Assert.Equal(MagickFormat.Jpeg, info.Format); + var xfrm = picture.ShapeProperties!.Transform2D!; + slides.Add(new Slide(picture.NonVisualPictureProperties!.NonVisualDrawingProperties!.Name!.Value!, + xfrm.Offset!.X!.Value, xfrm.Offset.Y!.Value, xfrm.Extents!.Cx!.Value, xfrm.Extents.Cy!.Value, + (int)info.Width, (int)info.Height, part.NotesSlidePart?.NotesSlide?.InnerText ?? "")); + } + return (size.Cx!.Value, size.Cy!.Value, slides); + } + + /// + /// No schema or semantic error for PowerPoint (Open XML SDK), every part has a content type and every relationship + /// a target. + /// + private static void ValidPackage(string pptx) + { + using (var document = PresentationDocument.Open(pptx, false)) + { + var errors = new OpenXmlValidator(FileFormatVersions.Microsoft365).Validate(document) + .Select(e => $"{e.Part?.Uri} {e.Path?.XPath}: {e.Description}") + .ToList(); + Assert.True(errors.Count == 0, string.Join("\n", errors)); + } + + XNamespace types = "http://schemas.openxmlformats.org/package/2006/content-types"; + XNamespace rels = "http://schemas.openxmlformats.org/package/2006/relationships"; + using var zip = ZipFile.OpenRead(pptx); + var contentTypes = XDocument.Load(zip.GetEntry("[Content_Types].xml")!.Open()).Root!; + var overrides = contentTypes.Elements(types + "Override").Select(o => ((string)o.Attribute("PartName")!).TrimStart('/')).ToHashSet(); + var defaults = contentTypes.Elements(types + "Default").Select(d => (string)d.Attribute("Extension")!).ToHashSet(); + foreach (var entry in zip.Entries.Where(e => e.FullName != "[Content_Types].xml")) + Assert.True(overrides.Contains(entry.FullName) || defaults.Contains(Path.GetExtension(entry.FullName).TrimStart('.')), $"{entry.FullName} has no content type"); + Assert.All(overrides, part => Assert.NotNull(zip.GetEntry(part))); + foreach (var entry in zip.Entries.Where(e => e.FullName.EndsWith(".rels", StringComparison.Ordinal))) + { + var folder = entry.FullName[..entry.FullName.IndexOf("_rels/", StringComparison.Ordinal)]; + foreach (var target in XDocument.Load(entry.Open()).Root!.Elements(rels + "Relationship").Select(r => (string)r.Attribute("Target")!)) + { + var resolved = new Uri(new Uri("pptx:///" + folder), target).AbsolutePath.TrimStart('/'); + Assert.True(zip.GetEntry(resolved) is not null, $"{entry.FullName} → {target}"); + } + } + } + + [Fact] + public async Task Every_page_becomes_a_picture_slide_shaped_like_the_first_page() + { + var pptx = await ConvertAsync(MakePdf(fx.NewFolder(), (A4Width, A4Height), (A4Width, A4Height), (A4Width, A4Height))); + + ValidPackage(pptx); + var (width, height, slides) = Read(pptx); + // A4 portrait: the slide is the page's own size (points × 12700 EMU). + Assert.Equal((7556500, 10693400), (width, height)); + Assert.Equal(["Page 1", "Page 2", "Page 3"], slides.Select(s => s.Name)); + Assert.All(slides, s => Assert.Equal((0, 0, width, height), (s.X, s.Y, s.Width, s.Height))); + // 150 dpi (the default): 595 pt = 8.26 in → about 1240 px. + Assert.All(slides, s => Assert.InRange(s.PixelWidth, 1238, 1241)); + Assert.All(slides, s => Assert.Equal(A4Height / A4Width, (double)s.PixelHeight / s.PixelWidth, 2)); + // The page's text is in the speaker notes, so it can be searched and copied. + for (var i = 0; i < slides.Count; i++) + { + Assert.Contains($"Page {i + 1}", slides[i].Notes); + Assert.Contains($"Text on page number {i + 1} of the deck.", slides[i].Notes); + } + } + + [Fact] + public async Task Pages_of_another_size_or_orientation_are_fitted_and_centred() + { + // A 16:9 first page (PowerPoint's widescreen size, 13.33 × 7.5 in) then an A4 portrait page. + var pptx = await ConvertAsync(MakePdf(fx.NewFolder(), (960, 540), (A4Width, A4Height))); + + ValidPackage(pptx); + var (width, height, slides) = Read(pptx); + Assert.Equal((12192000, 6858000), (width, height)); + Assert.Equal((0, 0, width, height), (slides[0].X, slides[0].Y, slides[0].Width, slides[0].Height)); + // The portrait page is as tall as the slide and centred across it. + var portrait = slides[1]; + Assert.Equal((0, height), (portrait.Y, portrait.Height)); + Assert.Equal(height * A4Width / A4Height, portrait.Width, 1.0); + Assert.Equal((width - portrait.Width) / 2, portrait.X); + } + + [Fact] + public async Task A_page_turned_in_the_pdf_is_shown_turned() + { + var folder = fx.NewFolder(); + var path = MakePdf(folder, (A4Width, A4Height), (A4Width, A4Height)); + using (var document = PdfReader.Open(path, PdfDocumentOpenMode.Modify)) + { + document.Pages[0].Rotate = 90; // shown landscape + document.Save(path); + } + + var (width, height, slides) = Read(await ConvertAsync(path)); + + // The turned first page makes landscape slides; the upright second page is fitted onto one, centred. + Assert.Equal((10693400, 7556500), (width, height)); + Assert.True(slides[0].PixelWidth > slides[0].PixelHeight); + Assert.Equal((0, height), (slides[1].Y, slides[1].Height)); + Assert.Equal((width - slides[1].Width) / 2, slides[1].X); + } + + [Fact] + public async Task The_page_range_picks_the_slides_and_the_resolution_is_at_least_150_dpi() + { + var path = MakePdf(fx.NewFolder(), (A4Width, A4Height), (A4Width, A4Height), (A4Width, A4Height), (A4Width, A4Height)); + var preset = new Preset { TargetFormat = "pptx", Pdf = { PageRange = "2-3", RenderDpi = 72 } }; + + var pptx = await ConvertAsync(path, preset); + + ValidPackage(pptx); + var (_, _, slides) = Read(pptx); + Assert.Equal(["Page 2", "Page 3"], slides.Select(s => s.Name)); + Assert.Contains("Text on page number 2 of the deck.", slides[0].Notes); + Assert.Contains("Text on page number 3 of the deck.", slides[1].Notes); + Assert.All(slides, s => Assert.InRange(s.PixelWidth, 1238, 1241)); // 72 dpi asked, 150 used + } + + [Fact] + public async Task Filees_own_pptx_reader_sees_a_picture_on_each_slide() + { + var folder = fx.NewFolder(); + var pptx = await ConvertAsync(MakePdf(folder, (960, 540), (960, 540))); + + var document = PptxReader.Read(pptx, Path.Combine(folder, "media")); + + var section = Assert.Single(document.Sections); + Assert.Equal(16.0 / 9, (double)section.Page!.Width / section.Page.Height, 3); + var pages = section.Blocks.OfType().ToList(); + Assert.Equal(2, pages.Count); + Assert.All(pages, page => Assert.Single(page.Inlines.OfType())); + } + + [Fact] + public async Task A_scan_without_text_has_no_notes() + { + var folder = fx.NewFolder(); + var pdf = new PdfBuilder().NewPage(); + pdf.Picture(PdfBuilder.Photo(folder, "scan.jpg", MagickFormat.Jpeg, 300, 400), 0, 0, A4Width, A4Height); + var path = pdf.Save(Path.Combine(folder, "scan.pdf")); + + var pptx = await ConvertAsync(path); + + ValidPackage(pptx); + Assert.Equal("", Assert.Single(Read(pptx).Slides).Notes); + using var zip = ZipFile.OpenRead(pptx); + Assert.DoesNotContain(zip.Entries, e => e.FullName.Contains("notes", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Password_protected_pdf_fails_with_a_clear_message() + { + var folder = fx.NewFolder(); + var path = MakePdf(folder, (A4Width, A4Height)); + PdfBuilder.Protect(path, "1234"); + + var job = await RunAsync(path); + + Assert.Equal(JobState.Failed, job.State); + Assert.Contains("password", job.Files.Single().ErrorDetail, StringComparison.OrdinalIgnoreCase); + Assert.Empty(Directory.GetFiles(folder, "*.pptx*")); + } + + [Fact] + public async Task A_broken_pdf_fails_and_leaves_no_file() + { + var folder = fx.NewFolder(); + var path = Path.Combine(folder, "broken.pdf"); + await File.WriteAllTextAsync(path, "%PDF-1.7\nthis is not really a PDF\n", TestContext.Current.CancellationToken); + + var job = await RunAsync(path); + + Assert.Equal(JobState.Failed, job.State); + Assert.Empty(Directory.GetFiles(folder, "*.pptx*")); + } + + [Theory] + [InlineData(A4Width, A4Height, 7556500, 10693400)] + [InlineData(960, 540, 12192000, 6858000)] + [InlineData(10000, 5000, PictureDeckWriter.MaxSlideSide, PictureDeckWriter.MaxSlideSide / 2)] // larger than 56 in + [InlineData(36, 18, PictureDeckWriter.MinSlideSide * 2, PictureDeckWriter.MinSlideSide)] // smaller than 1 in + public void Slides_take_the_page_size_within_powerpoints_limits(double width, double height, long slideWidth, long slideHeight) => + Assert.Equal((slideWidth, slideHeight), PictureDeckWriter.SlideSize(width, height)); + + [Fact] + public void Huge_pages_render_at_a_lower_resolution() + { + Assert.Equal(300, PdfSlidesConverter.PageDpi(A4Width, A4Height, 300)); + // A0 (841 × 1189 mm) at 300 dpi would be 139 megapixels. + var dpi = PdfSlidesConverter.PageDpi(2384, 3370, 300); + Assert.InRange(dpi, 100, 299); + Assert.True(2384 / 72.0 * dpi * (3370 / 72.0 * dpi) <= PdfSlidesConverter.MaxPagePixels); + } + + // ───────────────────────── Routes ───────────────────────── + + [Fact] + public void Picture_slides_come_from_a_pdf_only_and_leave_every_other_route_as_it_was() + { + var everyEngine = fx.Converters.Select(c => c.Id).ToArray(); + var with = fx.Catalog.CreatePlanner(everyEngine); + var without = new ConverterCatalog(fx.Converters.Where(c => c is not PdfSlidesConverter)) { Priority = fx.Catalog.Priority } + .CreatePlanner(everyEngine); + + Assert.Equal(["pdf-slides"], with.Plan("pdf", "pptx")!.Steps.Select(s => s.Converter.Id)); + + var formats = FormatRegistry.Known.Select(f => f.Id).ToList(); + var checkedRoutes = 0; + foreach (var from in formats) + { + foreach (var to in formats.Where(t => t != from && (from, t) != ("pdf", "pptx"))) + { + var route = with.Plan(from, to); + var before = without.Plan(from, to); + Assert.False(route?.Steps.Any(s => s.Converter is PdfSlidesConverter) ?? false, + $"{from} → {to} goes through picture slides: {string.Join(" → ", route?.Steps.Select(s => s.To) ?? [])}"); + Assert.Equal(before is null, route is null); + if (before is null) + continue; + Assert.Equal(before.Cost, route!.Cost); + Assert.Equal(before.Steps.Select(s => (s.Converter.Id, s.To)), route.Steps.Select(s => (s.Converter.Id, s.To))); + checkedRoutes++; + } + } + Assert.True(checkedRoutes > 1000); + + // Presentations keep their own routes to PDF and images (PPTX → PDF is not changed by a PDF → PPTX edge). + Assert.DoesNotContain(with.Plan("pptx", "pdf")!.Steps, s => s.Converter is PdfSlidesConverter); + } +}