diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
new file mode 100644
index 0000000000..d6b8aa2879
--- /dev/null
+++ b/.github/workflows/dotnet.yml
@@ -0,0 +1,38 @@
+name: .NET
+
+on: [push, pull_request]
+
+jobs:
+ build:
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-2022, ubuntu-24.04, macos-15]
+ steps:
+ - name: Install OS dependencies
+ if: matrix.os == 'ubuntu-24.04'
+ run: sudo apt-get install -y fonts-liberation2 fonts-noto-core fonts-noto-cjk
+ - uses: actions/checkout@v4
+ - name: Setup .NET 9
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
+ - name: Install workloads
+ if: matrix.os == 'macos-15'
+ run: dotnet workload install macos
+ - name: Build
+ run: dotnet run --project NAPS2.Tools -- build debug -v
+ - name: Test
+ if: matrix.os != 'macos-15'
+ run: dotnet run --project NAPS2.Tools -- test -v --nogui
+ - name: Test (mac)
+ if: matrix.os == 'macos-15'
+ run: dotnet run --project NAPS2.Tools -- test -v --nogui --nonetwork
+ - name: Test (WPF images)
+ if: matrix.os == 'windows-2022'
+ run: dotnet run --project NAPS2.Tools -- test -v --nogui --images wpf --scope sdk
+ - name: Test (ImageSharp images)
+ if: matrix.os == 'ubuntu-24.04'
+ run: dotnet run --project NAPS2.Tools -- test -v --nogui --images is --scope sdk
diff --git a/.gitignore b/.gitignore
index e93598953e..4cb7c8c5bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
Desktop.ini
+.DS_Store
packages/
*.user
*.suo
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000000..ec96756d6d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,798 @@
+Changes in 8.2.1:
+- Added a review prompt in the Microsoft Store version after a month's use
+- Windows: Added TWAIN support to arm64 installer
+- Windows: Fixed some OCR compatibility issues
+- Windows: Fixed preview window being cut off
+- Mac: Fixed SANE crash
+- Escl: Fixed some compatibility issues
+- Sane: Fixed some duplex compability issues
+- Linux: Fixed Gmail/Outlook Web options not showing
+- Linux: Fixed hidden buttons after disabling PDF encryption
+- Sdk: Fixed extraneous error log
+
+Changes in 8.2.0:
+- NAPS2 is [now available](https://apps.microsoft.com/detail/9N3QQ9W0B23Q?cid=changelog) on the Microsoft Store
+ - It costs a small fee to support the developer and provide automatic updates
+ - NAPS2 will continue to be freely available at www.naps2.com
+- Added "Edit with" under the "Image" menu for using an external image editor
+- Added "Share even when NAPS2 is closed" option for Scanner Sharing
+ - This will show a system tray icon and restart on login
+- Imported file names are now used as the default file name when saving
+- The "Apply to all selected" checkbox now stays checked
+- Escl: Increased maximum time searching for devices from 5s to 60s
+- Escl: Scanner IPs are now cached for faster and more reliable scanning
+- Windows: Added an arm64 installer
+- Windows: Replaced the "No friendly name" device name from some drivers with "Unknown Scanner"
+- Mac: Fixed an issue where saved files didn't always have the right extension
+- Mac: Disabled the "Apple Mail" email provider when not the default email reader
+- Mac: Updated icons for Split/Combine
+- Linux: Fixed issues with the Save dialog
+
+Changes in 8.1.4:
+- Windows: Added a "Theme" setting to switch between Light and Dark mode
+- Linux: Fixed OCR on older Linux versions (e.g. Ubuntu 20.04)
+ - Ubuntu 18.04 is no longer supported (use NAPS2 7.5.3 if needed)
+
+Changes in 8.1.3:
+- Twain: Fixed issues with Kyocera Ecosys scanners
+- Sane: Fixed duplex with some scanners
+- Windows: Fixed issues with multiple monitors with different DPI
+
+Changes in 8.1.2:
+- Added "Apply to all selected" for Split action
+- Fixed "unsaved changes" prompt after auto saving
+- Fixed issues with Placeholders in Default File Path
+- Windows: Fixed OCR errors on some systems
+- Windows: Fixed crash with right-to-left languages
+
+Changes in 8.1.1:
+- Fixed an issue with dpi selection
+
+Changes in 8.1.0:
+- Added the ability to pick a custom resolution
+- Added email provider for "New Outlook"
+- Improved "Keyboard Shortcuts" interface
+- Fixed an issue with shared TWAIN scanners
+- Fixed missing screen reader text for some buttons
+- Fixed excessive Flatpak size
+
+Changes in 8.0b3:
+- Added "Keyboard Shortcuts" settings
+- Added "--waitscan" and "--firstnow" console options
+- Added support for importing zip files
+- Upgraded Tesseract from 5.3.4 to 5.5.0 for OCR
+- Fixed an issue with antivirus false positives
+- Bug fixes
+
+Changes in 8.0b2:
+- Fixes from 7.5.3
+- Fixed WIA scanning
+- Fixed drag and drop on Windows
+- Fixed notifications on Linux
+- Fixed sidebar settings being shown with "Use native UI" selected
+- Linux flatpak runtime has been upgraded to 24.08
+
+Changes in 8.0b1:
+- [Beta feedback thread](https://github.com/cyanfish/naps2/discussions/467)
+- Added a scanning sidebar
+ - Quickly change basic profile settings
+ - Click the icon in the bottom-left (top-left on Mac) to open/close
+ - Admins can set [HideSidebar](https://www.naps2.com/doc/org-use#hide-sidebar) to remove it entirely
+- Changed system requirements
+ - Windows 7, 8 and 8.1 are no longer supported
+ - Windows 32-bit is no longer supported
+ - Windows 10 1607+ is required
+ - macOS 10.15 and 11 are no longer supported
+ - macOS 12+ is required
+- Improved support for high-dpi screens
+- Windows: Added support for dark themes
+- Twain: Removed "Legacy" twain implementation option
+- Changed the "Clear" icon to a broom
+- Performance improvements
+- Bug fixes
+
+Changes in 7.5.3:
+- Windows: Fixed a bug with maximized windows
+- Mac: Fixed bugs with Apple Driver
+- Linux: Fixed compatibility with Fedora 41 and others
+
+Changes in 7.5.2:
+- ~~NAPS2 is now available on the Microsoft Store & the Mac App Store~~
+ - ~~It costs a small fee to support the developer and provide automatic updates~~
+ - ~~NAPS2 will continue to be freely available at www.naps2.com~~
+- Windows: Installers and executables are now EV code-signed
+- Fixed NAPS2.Console issues with cancellation
+- Fixed ESCL compatibility with AirSane
+- Fixed an issue with Apple Driver and out-of-order pages
+- Fixed auto save file prompts to cancel correctly
+
+Changes in 7.5.1:
+- Mac: Use more native icons
+- Fixed an issue loading profiles
+
+Changes in 7.5.0:
+- Reworked device selection
+ - Driver selection is now in the "Choose device" window
+ - Click the top-right button to toggle between icon and list views
+ - You can no longer create a profile without selecting a device
+ - To prompt for a device each time you scan, "Always Ask" must be explicitly selected
+- Added "Manual IP" option for ESCL
+- Available profile options now change based on scanner support
+- Improved the error message when the worker process crashes
+- Sane: Fixed an issue with selecting the wrong grayscale mode
+- Fixed an issue with auto save paths that include Unicode
+- Fixed an issue with "Combine" for black and white images
+
+Changes in 7.4.3:
+- Fixed some ESCL connection issues
+- Fixed email compatibility with HCL Notes
+- Fixed issues with "Outlook Web Access" email provider
+- Fixed SANE compability with some HP devices
+- Fixed Fraktur-based languages for OCR
+
+Changes in 7.4.2:
+- Bug fixes
+
+Changes in 7.4.1:
+- Improved OCR text alignment
+- Added "Open With" support for PDF and image files
+- Changed some labels to improve clarity
+ - "Automatically run OCR after scanning" → "Pre-emptively run OCR after scanning"
+ - "Flip duplexed pages" → "Flip back sides of duplex pages"
+- Added HTTPS support for scanner sharing
+ - Uses an auto-generated self-signed certificate by default
+ - Admins can set [EsclServerCertificatePath](https://www.naps2.com/doc/org-use#escl-server-certificate-path) to use a custom certificate
+ - Admins can set [EsclSecurityPolicy](https://www.naps2.com/doc/org-use#escl-security-policy) to force servers/clients to only use HTTPS
+ - This affects all ESCL devices, not just shared scanners
+- Improved ESCL reliability with network interruptions
+- Fixed some issues with Preview window zooming
+- Made confirmation dialogs more consistent (OK/Cancel vs. Yes/No)
+- Added more default keyboard shortcuts
+- Mac: Fixed issues with keyboard shortcuts
+- Mac: Added some missing menu items (Zoom In/Out, Move Up/Down, Profiles)
+- Linux: Added a signature to .deb/.rpm packages
+- Windows: The .msi installer can no longer be used to upgrade over .exe
+- Bug fixes
+
+Changes in 7.4.0:
+- Added Undo/Redo (from the right-click menu or Ctrl+Z)
+ - Deletions can't be undone
+- Added Split/Combine (under the Image menu)
+ - Split can be used for book scanning to separate pages
+ - Combine can be used to include front/back sides of an ID card in one image
+- Added "Multiple Languages" as an option for OCR (in the OCR language dropdown)
+- Added a "Fix white balance and remove noise" OCR option
+ - This can improve OCR with low-quality scans, but will make OCR slower
+ - This is equivalent to using "Document Correction" before OCR
+- Upgraded Tesseract from 5.2.0 to 5.3.4 for OCR
+- Added a "Show native TWAIN progress" profile option (under Advanced)
+- Bug fixes
+
+Changes in 7.3.1:
+- Improved loading time for "Keep images across sessions"
+- PDF encryption settings are now hidden until enabled
+- Fixed some SANE devices incorrectly appearing offline
+- Fixed some SANE devices not respecting page size
+- Fixed OCR issues with non-Latin alphabets
+- Bug fixes
+
+Changes in 7.3.0:
+- Added a general "Settings" window with new options (some not available on Mac/Linux):
+ - Show page numbers
+ - Show Profiles toolbar
+ - Scan menu changes default profile
+ - Scan button default action
+ - Save button default action
+ - Clear images after saving
+ - Keep images across sessions
+ - Only allow a single NAPS2 instance
+- Added corresponding appsettings.xml options
+ - See https://www.naps2.com/doc/org-use
+- Added "mode" attribute to some settings in appsettings.xml:
+ - mode="default" provides a default value for the user
+ - mode="lock" prevents the user from changing the value
+- Added new console options:
+ - "--noprofile" to only use CLI options (not GUI profiles)
+ - "--listdevices" to see available scanning devices
+ - "--driver", "--device", "--source", "--pagesize", "--dpi", "--bitdepth" scanning options
+ - "--deskew", "--rotate" post-processing options
+ - See https://www.naps2.com/doc/command-line
+- Windows: Updated .exe installer style
+- Windows: Added back "Alt" hotkeys
+- Windows: Fixed an issue sending email with Outlook 2010-2016
+- Bug fixes
+
+Changes in 7.2.2:
+- Bug fixes
+
+Changes in 7.2.1:
+- Bug fixes
+
+Changes in 7.2.0:
+- Scanner Sharing
+ - Share scanners with other computers on the local network, for example:
+ - Turn a desktop-connected USB scanner into a wireless scanner usable from your laptop or phone
+ - Allow Windows-only scanners to be used from Mac/Linux using a virtual machine
+ - Set up a Raspberry Pi to turn a USB scanner into a wireless scanner
+ - On the host computer, in the Profiles window, click Scanner Sharing and choose the scanners to share
+ - On the client computer, select "ESCL Driver" in your profile settings and you should be able to select the shared scanner
+ - NAPS2 currently must be kept open on the host for sharing to work
+ - Shared scanners can be used from any ESCL-capable client, not just NAPS2
+ - Try [Mopria Scan](https://play.google.com/store/apps/details?id=org.mopria.scan.application) for Android
+ - Use NoScannerSharing in appsettings.xml to disable
+- Slightly updated icons in the Profiles window
+- Old unrecoverable files are now cleaned up on startup
+- Mac/Linux have been upgraded to the .NET 8 runtime
+- Linux flatpak runtime has been upgraded to 23.08
+- Bug fixes
+
+Changes in 7.1.2:
+- Mac: Fixed scanning with macOS 14 Sonoma
+
+Changes in 7.1.1:
+- Bug fixes
+
+Changes in 7.1.0:
+- Windows: Added ESCL Driver option
+ - This allows using most network scanners without needing to install a separate driver
+- PDF saving is much faster in some cases
+- Imported PDFs now render forms and annotations
+- Added Hindi language
+- Bug fixes
+- NAPS2.Sdk is now available on [Nuget](https://www.nuget.org/packages/NAPS2.Sdk)
+
+Changes in 7.0b9:
+- Improved accuracy of PDF page sizes
+- Improved UI responsiveness when OCR is running
+- Mac: Improved color accuracy for scans with Apple Driver
+- Mac: Added support for dark themes
+- Linux: Added support for dark themes
+- Linux: Added arm64 .deb/.rpm builds
+- Bug fixes
+
+Changes in 7.0b8:
+- Added "Email PDF" support to Mac and Linux
+ - Mac: Apple Mail, Gmail, and Outlook Web options
+ - Linux: Thunderbird, Gmail, and Outlook Web options
+- Added "Print" support to Mac and Linux
+- Added notifications to Mac and Linux
+ - Also updated notification appearance in general
+- Linux: Added drag & drop support
+- Linux: Improved compatibility with older Linux (e.g. Ubuntu 18.04)
+- Linux: Added dependencies to .deb package
+- Sane: Show IP addresses for escl/airscan backends
+- Windows: Changed installer publisher to "NAPS2 Software"
+- Improved error log formatting
+- Added debug logging for scanning diagnostics
+ - Turn on by checking "Enable debug logging" in the About window
+ - This will record information about scanning activity on disk
+ - You can find debuglog.txt in the [same folder](https://www.naps2.com/doc/troubleshooting#error-log) as errorlog.txt
+ - Use NoDebugLogging in appsettings.xml to hide the option
+- Added Bosnian and Indonesian languages
+- Bug fixes
+
+Changes in 7.0b7:
+- Bug fixes
+
+Changes in 7.0b6:
+- Bug fixes
+
+Changes in 7.0b5:
+- Added 2400/4800 dpi options
+- Linux: Added .deb/.rpm packages
+- Sane: Show devices incrementally (only with Mac / Linux flatpak)
+- Crop improvements
+- Fixed formatting for OCR of non-NAPS2 PDFs
+- Bug fixes
+
+Changes in 7.0b4:
+- Twain: Changed default transfer mode
+ - "Alternative Transfer" has been renamed "Memory Transfer" and is now used when "Default" is selected
+ - "Native Transfer" can be used to revert to the old transfer mode
+- Saved images now use optimized bit depths for smaller file sizes
+- Bug fixes
+
+Changes in 7.0b3:
+- Bug fixes
+
+Changes in 7.0b2:
+- Bug fixes
+
+Changes in 7.0b1:
+- Most NAPS2 code has been rewritten. Things should mostly look the same but under the hood there are many differences.
+ - [Beta feedback thread](https://github.com/cyanfish/naps2/discussions/35)
+- Added Mac support
+ - Supports macOS 10.15 and later
+ - The Universal download should work for all users. Or you can use the Intel/Apple Silicon downloads for a smaller download/install size if you know which one your Mac has.
+ - NAPS2 on Mac bundles SANE drivers for USB scanners, allowing supported scanners to be used even on new M1/M2 Macs (which normally wouldn't work without manufacturer-provided drivers)
+- Added native Linux support
+ - Requires Flatpak for installation (https://flatpak.org/setup/)
+ - Mono is no longer required
+ - The UI should now feel like a native Linux app
+ - Much better performance and reliability
+- TWAIN support has been reworked
+ - Some lifecycle-related issues should hopefully be fixed (e.g. only being able to scan once)
+ - With "Use predefined settings", TWAIN now uses the built-in NAPS2 progress window, which allows multitasking
+ - TWAIN UI should no longer be visible in console and batch mode
+ - TWAIN should also now support scanning larger images (e.g. 1200dpi) without out-of-memory issues
+- Upgraded Tesseract to 5.2.0 for OCR
+ - Up to 30% faster OCR performance
+ - Tesseract is now bundled with the NAPS2 download, so no extra download is required (though you still need to download language data if you don't already have it).
+- PDF import and export have been rewritten to leverage Pdfium
+ - This means better support for importing different kinds of PDFs
+ - In some cases this means much faster import/export
+ - Pdfium is bundled with the NAPS2 download so there is no longer an extra download needed to import non-NAPS2 PDFs
+- New Crop UI
+- Minor tweaks to blank page detection
+- Image list tweaks
+ - Selected images appear with just a blue border
+ - Spacing has been optimized
+- New automatic image correction functionality (work in progress)
+ - "Document Correction" under the Image menu
+ - Automatic fixing of color calibration, noise, skew, and other common scanning issues
+ - Eventually this will be integrated into profiles
+- JPEG2000 support for importing/saving images (Mac only for now)
+- Dropped support for rarely-used image file formats (.emf, .exif, .gif)
+ - Please request if you want this back
+- NAPS2 on Windows now requires .NET Framework 4.6.2
+ - This means no more support for Windows XP
+ - Windows 7 SP1 is now the minimum requirement
+- The 64-bit Windows install location is now "Program Files" instead of "Program Files (x86)"
+- The MSI installer now has separate 64-bit and 32-bit downloads
+- The AppData format for config.xml and Tesseract files has changed (will be automatically migrated)
+- Improved icon quality
+- Translations have been moved to Crowdin
+ - See [translate.naps2.com](https://translate.naps2.com)
+- Various performance and reliability improvements
+- Bug fixes
+
+Changes in 6.1.2:
+- Added --autosend support for Gmail in NAPS2.Console
+- Bug fixes
+
+Changes in 6.1.1:
+- Faster and more accurate deskew
+- Bug fixes
+
+Changes in 6.1.0:
+- Added a "Single page files" option in PDF Settings
+- Improved accessibility
+- Faster cropping
+- Event logging now uses an XML format
+- Bug fixes
+
+Changes in 6.0b4:
+- Beta feedback thread: https://sourceforge.net/p/naps2/discussion/general/thread/8776c818/
+- Upgraded WIA version from 1.0 to 2.0; can be changed back in your profile under Advanced
+- Improved WIA compatibility with feeders and duplex
+- Added support for background scanning with WIA
+ - Does not work with "Use native UI"
+ - This means you can scan with multiple devices at the same time
+- Removed some obsolete WIA compatibility options
+- Bug fixes
+
+Changes in 6.0b3:
+- Beta feedback thread: https://sourceforge.net/p/naps2/discussion/general/thread/8776c818/
+- Added optional event logging
+ - See https://www.naps2.com/doc-org-use.html#event-logging
+- Improved console import speed
+- Bug fixes
+
+Changes in 6.0b2:
+- Beta feedback thread: https://sourceforge.net/p/naps2/discussion/general/thread/8776c818/
+- OCR users from 6.0b1 will need to click the OCR button and re-download
+- Fixed an issue with OCR missing a DLL on some systems
+- Fixed an issue with OCR not terminating
+- Other minor fixes and improvements
+
+Changes in 6.0b1:
+- Beta feedback thread: https://sourceforge.net/p/naps2/discussion/general/thread/8776c818/
+- Linux support (download one of the portable archives - currently experimental, please give feedback!)
+ - Requires Mono (5.17+ preferably), see https://www.naps2.com/doc-getting-started.html#system-requirements
+- Added an automatic update check
+ - Opt in from the About window
+ - Not available if installed from the MSI
+- New OCR version, significantly more accurate in many cases
+ - The OCR button will prompt to update. This can be disabled with the NoUpdatePrompt flag in appsettings.xml
+ - Not supported on Windows XP (will use the older version instead)
+ - You can choose between multiple modes: Fast (recommended), Best (slow), and Legacy (to simulate the older version)
+- Added the ability to choose an email provider
+ - When you first click Email PDF, you will be prompted to choose. Afterwards use Email Settings to change
+ - Switch between installed clients (Outlook, Thunderbird, etc.)
+ - Webmail integration for Gmail and Outlook Web Access
+- Added support for Unicode in email attachment names
+- Crop selection will be remembered (in case you're cropping multiple images but need to adjust them individually)
+- Added the ability to run most operations in the background for multitasking
+- Improved performance with very large images
+- Substantially reduced installation footprint and portable zip size
+- Minimized TWAIN UI in console and batch mode
+- NAPS2 installers are now signed
+ - This should eventually help remove SmartScreen notifications
+- NAPS2 will now run in 64-bit mode on compatible systems
+ - If you have a 64-bit system, NAPS2 will better handle memory-intensive operations
+ - If you downloaded the add-on to open any PDF (gsdll32.dll), you may need to re-download the 64-bit version
+- Improved documentation and usability for developers (see https://www.naps2.com/doc-dev-onboarding.html)
+- Bug fixes
+
+Changes in 5.8.2:
+- Added Japanese language
+- Fixed a bug with importing some PDFs
+- Fixed a bug with the Alternative Transfer TWAIN option
+
+Changes in 5.8.1:
+- Fixed a bug with PDF/A support
+
+Changes in 5.8.0:
+- PDF/A support
+ - PDF/A1-b, PDF/A2-b, PDF/A3-b, and PDF/A3-u support
+ - In the "Save PDF" menu, click "PDF Settings", and select it under "Compatibility"
+ - Use --pdfcompat in NAPS2.Console. See www.naps2.com/doc-command-line.html#pdf-options
+ - Use ForcePdfCompat in appsettings.xml. See www.naps2.com/doc-org-use.html#force-pdf-compat
+- TIFF changes
+ - Better compression for black and white TIFF files by default
+ - Added a "Compression" option under Image Settings
+ - Added a "Single page files" option under Image Settings that prevents saving multi-page TIFF files
+ - Use --tiffcomp and --split in NAPS2.Console. See www.naps2.com/doc-command-line.html#image-options
+- Donate button
+ - The About window now has a Donate button
+ - An unobtrusive donation prompt is shown after a month of use
+ - Use HideDonateButton in appsettings.xml to disable both. See www.naps2.com/doc-org-use.html#hide-donate-button
+ - The prompt is disabled by default in the MSI distribution
+- Added multi-language support to the EXE installation wizard
+
+Changes in 5.7.1:
+- Added --split, --splitscans, --splitpatcht, and --splitsize options to NAPS2.Console
+ - See www.naps2.com/doc-command-line.html#split-options
+- Added slice support to --import in NAPS2.Console
+ - See www.naps2.com/doc-command-line.html#slicing-imported-files
+
+Changes in 5.7.0:
+- Fixed downloads for OCR (etc.)
+- Improved deskew
+- Added a confirmation for batch cancel
+- Minor performance improvements
+- Bug fixes
+
+Changes in 5.6.2:
+- Bug fixes
+
+Changes in 5.6.1:
+- Fixed a crash
+
+Changes in 5.6.0:
+- Increased the maximum thumbnail size from 256x256 to 1024x1024
+- Improved PDF import to allow many more types of PDFs to be imported
+- OCR can now be used on imported PDFs (if they don't already have text)
+- Improved PDF file size for some black and white images
+- Combined Brightness and Contrast adjustments into a single window
+- Added Hue, Saturation, Black+White, and Sharpen image adjustments
+- Added more keyboard shortcuts in the preview window (arrow keys to change pages, Ctrl/Alt/Shift + arrow keys to pan)
+- Added "HideImportButton", "HideOcrButton", "HideSavePdfButton", and "HideSaveImages" options to appsettings.xml
+- Added "OcrState" and "OcrDefaultLanguage" options to appsettings.xml
+- Bug fixes
+
+Changes in 5.5.0:
+- Added support for importing any PDF (requires an additional download, can be disabled by NoUpdatePrompt or DisableGenericPdfImport in appsettings.xml)
+- Added the ability to install optional components using NAPS2.Console (with the "--install" argument)
+- Added "Alternative Transfer" TWAIN compatibility option
+- Added .txt extension to license/contributor file names
+- Bug fixes
+
+Changes in 5.4.0:
+- Added automatic deskew option (under the Rotate menu or under Advanced in your profile settings) (credit to Peter Hommel)
+- Added single-page save buttons to the preview window
+- Added "Prompt for file path" option to Auto Save Settings
+- Split "Force matching page size" option into "Stretch to page size" and "Crop to page size" options
+- Added "Retry on failure" and "Delay between scans" WIA compatibility options
+- Added support for environment variables in most paths
+- Added LICENSE and CONTRIBUTORS files to the root directory (this replaces most copyright notices elsewhere)
+- Added Nynorsk language
+- Bug fixes
+
+Changes in 5.3.3:
+- Bug fixes
+
+Changes in 5.3.2:
+- Added Slovenian language
+- Fixed AV false positive issue
+
+Changes in 5.3.1:
+- Added Afrikaans and Vietnamese languages
+
+Changes in 5.3.0:
+- Significantly improved OCR speed on multi-core systems
+- Improved OCR text alignment
+- Patch-T is now supported for all scanners, with both WIA and TWAIN
+- Improved and added technical details to some error messages
+- Tweaked the spacing between thumbnails for less wasted space
+- Added Latvian language
+- Fixed OCR on Windows XP (requires an extra download, can be disabled by NoUpdatePrompt in appsettings.xml)
+- Fixed Auto Save and Batch to use a default file name when a directory is specified instead of a file path
+
+Changes in 5.2.1:
+- Added an "OcrTimeoutInSeconds" option to appsettings.xml
+- Bug fixes
+
+Changes in 5.2.0:
+- Added the ability to copy/paste and drag/drop profiles
+- Changed the way "LockSystemProfiles" behaves to allow users to specify a device if not specified by the admin
+- Added "NoUserProfiles", "AlwaysRememberDevice", and "LockUnspecifiedDevices" options to appsettings.xml
+- Added "HideEmailButton" and "HidePrintButton" options to appsettings.xml
+- Added "PromptIfSelected" as a possible value for the "SaveButtonDefaultAction" option in appsettings.xml
+- Added Arabic, Serbian (Latin + Cyrillic), and Slovak languages
+
+Changes in 5.1.1:
+- Updated the default appsettings.xml to be easier to edit
+- Bug fixes
+
+Changes in 5.1.0:
+- Custom page sizes can now be named and reused across multiple profiles
+- Added the ability to draw a line to align the page in Custom Rotation
+- Added a "Restore Defaults" button to Advanced Profile Settings
+- Added a "ComponentsPath" option to appsettings.xml
+- Added a "SingleInstance" option to appsettings.xml
+- Placeholders can now be used in --subject and --body arguments in NAPS2.Console
+- Bug fixes
+
+Changes in 5.0b3:
+- Added save notifications (use DisableSaveNotifications in appsettings.xml to disable)
+- Added a "Skip save prompt" option to PDF and Image settings. Also changed "Default File Name" to "Default File Path" (can be a file name, folder, or full path now)
+- Bug fixes
+
+Changes in 5.0b2:
+- Added a "Flip duplexed pages" compatibility option
+- Added a "DeleteAfterSaving" option to appsettings.xml
+- Bug fixes
+
+Changes in 5.0b1:
+- Updated tesseract-ocr (from 3.02 to 3.04)
+ - The OCR button will prompt to update. This can be disabled with the NoUpdatePrompt flag in appsettings.xml
+ - If you have the old version it will continue to function normally
+- Updated the default TWAIN implementation
+ - Choose the "Old DSM" implementation under advanced profile settings to revert
+- Changed the default Horizontal Align in profile settings from Left to Right to match most scanners
+ - If you deploy your own appsettings.xml the specified alignment specified will continue to be used as default
+- Added a "LockSystemProfiles" flag to appsettings.xml that allows an administrator better control over user profiles
+ - See www.naps2.com/doc-org-use.html#lock-system-profiles
+- Added an "Offset width based on alignment (WIA)" compatibility option (for ticket #124)
+- Added Farsi and Korean languages to installers
+
+Changes in 4.7.2:
+- Fixed a TWAIN issue
+
+Changes in 4.7.1:
+- Improved memory capabilities on 64-bit systems
+- Fixed a WIA issue
+
+Changes in 4.7.0:
+- Added option in NAPS2.Console to use auto-save settings (-a/--autosave)
+- Added click-and-drag scrolling in the preview window
+- Improved cropping (can now click and drag to select an area)
+- Added more descriptive error messages for some WIA errors (e.g. device busy)
+- Fixed button alignment on left/right toolbar placements
+- Added Korean, Lithuanian, and Farsi languages
+- Various performance improvements
+- Various bug fixes
+
+Changes in 4.6.1:
+- Bug fixes
+
+Changes in 4.6.0:
+- New feature: Exclude blank pages (under "Advanced" in profile settings)
+- New options in NAPS2.Console for reordering (e.g. interleave)
+- Keyboard shortcuts are now customizable in appsettings.xml (and some more default shortcuts added)
+- Optional file type filters when importing
+- Importing multiple files at once now sorts the files better
+- Fix an issue with the left side of the scanned page being cut off with WIA
+- Other bug fixes
+
+Changes in 4.5.1:
+- Improved performance when editing and rearranging thumbnails
+- Automatically scroll the thumbnail list when trying to drag thumbnails up or down
+- Display an indicator while dragging thumbnails to show where they'll drop
+- Fixed Thai/Tagalog OCR language download
+- Fixed minor translation issues
+
+Changes in 4.5.0:
+- New feature: Auto Save - Enable it from the profile editor (can be disabled by organizations in appsettings.xml)
+- New feature: Drag and Drop support (re-order images within NAPS2, import files into NAPS2, or copy images between different instances of NAPS2)
+- New feature: "Advanced" profile options for image quality and scanner compatibility
+- New feature: Copy/Paste within NAPS2 (previously could only copy, not paste)
+- New progress dialogs for Import, Save, etc. with cancellation
+- Better contrast implementation
+- Selected images are now kept in view when editing and reordering images
+- The default action when clicking on Save PDF, Save Images, and Email PDF can be configured in appsettings.xml (SaveAll, SaveSelected, or AlwaysPrompt)
+- New command-line options for NAPS2.exe to enable/disable scanning from a physical "Scan" button in portable versions ("/RegisterSti", "/UnregisterSti", and "/Silent")
+- Improved TWAIN error logging
+- Bug fixes
+
+Changes in 4.4.1:
+- Tool strip location in the main form is remembered
+- Bug fixes
+
+Changes in 4.4.0:
+- New feature: NAPS2 can be started and/or instantly scan when you press the physical "Scan" button on your scanner (requires reboot after installation)
+- Added "Delete" to the context menu in the main window
+- Fixed file size of black and white images after rotate/crop
+- Fixed cancel in OCR download progress window
+- Fixed issues with the default profile logic
+- Fixed various translation-related issues
+
+Changes in 4.3.1:
+- Bug fixes
+
+Changes in 4.3.0:
+- New feature: Batch scan (under the Scan menu)
+- New feature: Bulk image editing (brightness/contrast/crop/custom rotation)
+- Added "Alternative Interleave" function for interleaving duplexed pages in a different order
+- Added Finnish language
+- Bug fixes
+
+Changes in 4.2.3:
+- Added Greek and Estonian languages
+- Added support for multiple OCR languages on the command line (e.g. "--ocrlang eng+fra")
+- Fixed an issue with importing certain PDFs
+- Fixed an issue that caused a black background when rotating and saving as certain formats
+- Fixed an issue that caused duplicate close prompts
+- Improved responsiveness while importing large PDFs
+
+Changes in 4.2.2:
+- Fixed an issue with OCR for non-English languages
+- Fixed an issue with missing translations for Move Up/Down buttons
+
+Changes in 4.2.1:
+- Fixed an issue where focus is lost when scanning from the Profiles window
+- Fixed an issue where Native WIA doesn't work properly
+
+Changes in 4.2.0:
+- Added a "Delete" button to the preview window
+- Added new keyboard shortcuts to the preview window: Esc (close), Page Up (prev), Page Down (next)
+- Added unicode support to PDF metadata and OCR text
+- Bug fixes
+
+Changes in 4.1.1:
+- New language: Romanian
+- New language: Norwegian (Bokmål)
+- Bug fixes
+
+Changes in 4.1.0:
+- Changed the website link in the About window to www.naps2.com
+- Changed "Substitutions" to "Placeholders" for consistency with other software
+- Bug fixes
+
+Changes in 4.0b3:
+- New feature: Thumbnails can be resized for easier viewing
+- New feature: Substitutions can be used in both the GUI and NAPS2.Console when saving (e.g. "$(YYYY)-$(MM)-$(DD) $(nn).pdf" to include the date and an incrementing number)
+- New feature: Image settings (default file name, jpeg quality), and default file name setting in PDF settings
+- Bug fixes
+
+Changes in 4.0b2:
+- New feature: PDF settings (metadata, encryption) and email settings (can change attachment name)
+- Changed format of standalone/portable archives for easier usage
+- Scanning multiple pages with WIA no longer steals focus from other applications
+- Scanning with WIA in NAPS2.Console no longer displays a separate window
+- Bug fixes
+
+Changes in 4.0b1:
+- Merged the Quick Scan functionality into the toolbar
+- Merged the previous Scan functionality into the Profiles window
+- New feature: Image Editing - Crop, Brightness, Contrast, Custom Rotation
+- New feature: Enhanced Preview Window - Can now browse through the images one-by-one and also edit them
+- New feature: Print scanned images directly from NAPS2
+- New feature: Prompt when trying to exit with unsaved changes
+- New feature: The file type used when saving images is remembered
+- Added more keyboard shortcuts (Ctrl+S for save all as PDF, Ctrl+O for import, Ctrl+Enter for scan)
+
+Changes in 3.3.5:
+- Bug fix: Added missing OCR languages
+
+Changes in 3.3.4:
+- New language: Turkish
+- Bug fix: Searching PDFs generated with OCR should now work for all readers
+- Bug fix: Fixed issue with some TWAIN devices when scanning fails
+
+Changes in 3.3.3:
+- Minor bug fixes
+
+Changes in 3.3.2:
+- Bug fixes
+
+Changes in 3.3.1:
+- Bug fix: Fixed issue with TWAIN
+
+Changes in 3.3.0:
+- New feature: TWAIN with predefined settings
+- New feature: OCR options in command-line interface
+- New language: Chinese (Taiwan)
+
+Changes in 3.2.1:
+- New language: Albanian
+- Bug fix: Increase time allotted for OCR
+
+Changes in 3.2.0:
+- New feature: Custom page sizes
+- Added built-in B5 and B4 page size options
+- Added 400 and 800 dpi options
+
+Changes in 3.1.1:
+- New language: Swedish
+- Bug fix: Dutch language added to installer
+
+Changes in 3.1.0:
+- New feature: One-click scan
+- New feature: Can reverse the order of all or some pages with a single click
+- New languages: Croatian, Dutch
+- Bug fix: Prevent downloading corrupted OCR files
+- Bug fix: Resolve some issues when scanning from document feeders
+
+Changes in 3.0b1:
+- New feature: OCR (Optical Character Recognition) to make PDF files searchable
+- New feature: Can import PDF and image files (e.g. to resume a previous scanning session)
+- New feature: Option to save selected pages only
+- New feature: Can re-order (interleave) pages with a single click
+- New feature: Added a right-click menu and the ability to copy images to the clipboard
+- New feature: Added a 150dpi option to WIA settings
+- Bug fix: Incorrect page size for black and white images
+- Bug fix: Duplex scanning (for some models)
+- Various other changes and bug fixes
+
+Changes in 2.6.3:
+- Added Bulgarian translation
+- Added Portuguese translations
+
+Changes in 2.6.2:
+- Added Danish translation
+
+Changes in 2.6.1:
+- Fixed a bug when scanning after clearing previously scanned images
+- Fixed an error in NAPS2.Console's help text
+
+Changes in 2.6:
+- Added Czech, French, and Polish translations
+- Fixed Catalan translation when using EXE installer
+
+Changes in 2.5:
+- Command-line interface (naps2.console.exe) can send emails
+- More windows can be resized, and all windows remember their size and position
+- NAPS2 will offer to recover scanned images if it previously closed unexpectedly
+- Substantially reduced memory usage
+- Added Hebrew and Catalan translations
+- Bug fixes
+
+Changes in 2.4:
+- Profiles can now be created without specifying a device (the device will be chosen when scanning)
+- Organizations can now configure some application settings in appsettings.xml (see Wiki)
+- Updated German translation
+- Bug fixes
+
+Changes in 2.3:
+- Added German and Italian translations
+
+Changes in 2.2:
+- Added Russian translation
+- Updated Ukrainian translation
+- Various bug fixes
+
+Changes in 2.1:
+- Added language dropdown
+- Added translations for Spanish and Ukrainian
+
+Changes in 2.0:
+- Major bug fixes for TWAIN on x64 and native WIA
+- Added command-line interface (naps2.console.exe)
+- Added logging capabilities for error reporting
+- Changed .NET dependency from 3.5 Client Profile to 4.0 Client Profile
+
+Changes in 1.0b2:
+- Added Clear button to toolbar
+- Added Ctrl+A shortcut to select all thumbnails
+- The last-used profile is now remembered and used as the default
+- Fix for crash when scanning with the "Black and White" option (credit to Peter De Leeuw)
+- Fix for crash when trying to use an offline scanner (WIA)
+
+Changes in 1.0b1:
+- Now requires .NET framework 3.5 (or later)
+- New icons
+- Better user experience
+- Admin no longer required to save profiles
+- Various other bug fixes and minor enhancements
\ No newline at end of file
diff --git a/CONTRIBUTORS b/CONTRIBUTORS
index 21ca92c557..6d8674b4d0 100644
--- a/CONTRIBUTORS
+++ b/CONTRIBUTORS
@@ -1,5 +1,5 @@
Primary NAPS2 developer:
-Ben Olden-Cooligan (Copyright 2012-2022)
+Ben Olden-Cooligan (Copyright 2012-2025)
Original NAPS developer:
Pavel Sorejs (Copyright 2009)
@@ -10,3 +10,6 @@ Luca De Petrillo (Copyright 2015)
Peter De Leeuw
Peter Hommel
Alexander Rabenstein
+
+And others:
+https://github.com/cyanfish/naps2/graphs/contributors
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
index 95e68d8e5c..d759310349 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,7 +1,7 @@
NAPS2 - Not Another PDF Scanner
https://www.naps2.com
-Copyright 2009-2022 NAPS2 Contributors
+Copyright 2009-2025 NAPS2 Contributors
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
diff --git a/NAPS2.App.Console/NAPS2.App.Console.csproj b/NAPS2.App.Console/NAPS2.App.Console.csproj
index bf8162006c..4f94379706 100644
--- a/NAPS2.App.Console/NAPS2.App.Console.csproj
+++ b/NAPS2.App.Console/NAPS2.App.Console.csproj
@@ -1,22 +1,21 @@
- net6-windows;net462
+ net9-windows
true
Exe
- app.config
- true
NAPS2.Console
NAPS2.Console
-
+
+ true
+ win-x64;win-arm64
+
+ false
+ none
+ true
+
NAPS2 - Not Another PDF Scanner
NAPS2 - Not Another PDF Scanner
- Copyright 2009, 2012-2020 NAPS2 Contributors; Icons from http://www.fatcow.com/free-icons
-
-
- None
@@ -24,13 +23,17 @@
-
-
-
+
+
-
+
+
+ Always
+ appsettings.xml
+ appsettings.xml
+
-
+
\ No newline at end of file
diff --git a/NAPS2.App.Console/Program.cs b/NAPS2.App.Console/Program.cs
index f9a642981c..5ea10e425b 100644
--- a/NAPS2.App.Console/Program.cs
+++ b/NAPS2.App.Console/Program.cs
@@ -1,4 +1,5 @@
-using NAPS2.EntryPoints;
+using System.Runtime;
+using NAPS2.EntryPoints;
namespace NAPS2.Console;
@@ -10,7 +11,11 @@ static class Program
[STAThread]
static int Main(string[] args)
{
- // Use reflection to avoid antivirus false positives (yes, really)
- return (int) typeof(WindowsConsoleEntryPoint).GetMethod("Run")!.Invoke(null, new object[] { args })!;
+ var profilesPath = Path.Combine(Paths.AppData, "jit");
+ Directory.CreateDirectory(profilesPath);
+ ProfileOptimization.SetProfileRoot(profilesPath);
+ ProfileOptimization.StartProfile("naps2.console.jit");
+
+ return WindowsConsoleEntryPoint.Run(args);
}
}
\ No newline at end of file
diff --git a/NAPS2.App.Console/app.config b/NAPS2.App.Console/app.config
deleted file mode 100644
index 1230633e9a..0000000000
--- a/NAPS2.App.Console/app.config
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/NAPS2.App.Gtk/NAPS2.App.Gtk.csproj b/NAPS2.App.Gtk/NAPS2.App.Gtk.csproj
index 1e604f6da3..784a6a27d9 100644
--- a/NAPS2.App.Gtk/NAPS2.App.Gtk.csproj
+++ b/NAPS2.App.Gtk/NAPS2.App.Gtk.csproj
@@ -1,34 +1,33 @@
- net6
+ net9
Exe
NAPS2
naps2
- 7.0.1
../NAPS2.Lib/Icons/favicon.ico
true
true
true
+ partial
- linux-x64
+ linux-x64;linux-arm64
NAPS2 - Not Another PDF Scanner
NAPS2 - Not Another PDF Scanner
- Copyright 2009, 2012-2022 NAPS2 Contributors
-
-
+
+
-
+
diff --git a/NAPS2.App.Gtk/Program.cs b/NAPS2.App.Gtk/Program.cs
index e2de74b715..26f6df58ae 100644
--- a/NAPS2.App.Gtk/Program.cs
+++ b/NAPS2.App.Gtk/Program.cs
@@ -1,4 +1,5 @@
-using NAPS2.EntryPoints;
+using System.Runtime;
+using NAPS2.EntryPoints;
namespace NAPS2;
@@ -9,6 +10,11 @@ static class Program
///
static void Main(string[] args)
{
+ var profilesPath = Path.Combine(Paths.AppData, "jit");
+ Directory.CreateDirectory(profilesPath);
+ ProfileOptimization.SetProfileRoot(profilesPath);
+ ProfileOptimization.StartProfile("naps2.jit");
+
// Use reflection to avoid antivirus false positives (yes, really)
typeof(GtkEntryPoint).GetMethod("Run").Invoke(null, new object[] { args });
}
diff --git a/NAPS2.App.Mac/Entitlements.plist b/NAPS2.App.Mac/Entitlements.plist
index 446fe171da..853adcac37 100644
--- a/NAPS2.App.Mac/Entitlements.plist
+++ b/NAPS2.App.Mac/Entitlements.plist
@@ -4,5 +4,13 @@
com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
diff --git a/NAPS2.App.Mac/Icon.icns b/NAPS2.App.Mac/Icon.icns
index 5e8d6ff8c8..96bb89a763 100644
Binary files a/NAPS2.App.Mac/Icon.icns and b/NAPS2.App.Mac/Icon.icns differ
diff --git a/NAPS2.App.Mac/Info.plist b/NAPS2.App.Mac/Info.plist
index 0c847a4706..e7498de2df 100644
--- a/NAPS2.App.Mac/Info.plist
+++ b/NAPS2.App.Mac/Info.plist
@@ -2,14 +2,16 @@
+
+
CFBundleName
NAPS2
CFBundleIdentifier
com.naps2.desktop
CFBundleShortVersionString
- 7.0.1
+ 8.2.1
LSMinimumSystemVersion
- 10.15
+ 12.0
CFBundleDevelopmentRegion
en
NSHumanReadableCopyright
@@ -17,6 +19,77 @@
CFBundleIconFile
Icon.icns
NSPrincipalClass
+
+
NSApplication
+ LSBackgroundOnly
+
+
+
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleTypeName
+ PDF
+ LSItemContentTypes
+
+ com.adobe.pdf
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleTypeName
+ JPG
+ LSItemContentTypes
+
+ public.jpeg
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleTypeName
+ JP2
+ LSItemContentTypes
+
+ public.jpeg-2000
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleTypeName
+ PNG
+ LSItemContentTypes
+
+ public.png
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleTypeName
+ TIFF
+ LSItemContentTypes
+
+ public.tiff
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleTypeName
+ BMP
+ LSItemContentTypes
+
+ com.microsoft.bmp
+
+
+
+
diff --git a/NAPS2.App.Mac/NAPS2.App.Mac.csproj b/NAPS2.App.Mac/NAPS2.App.Mac.csproj
index 4f5a43f7e3..a692a0d6c6 100644
--- a/NAPS2.App.Mac/NAPS2.App.Mac.csproj
+++ b/NAPS2.App.Mac/NAPS2.App.Mac.csproj
@@ -1,31 +1,31 @@
- net7-macos10.15
+ net9-macos
Exe
NAPS2
NAPS2
- 7.0.1
../NAPS2.Lib/Icons/favicon.ico
- osx-x64;osx-arm64
+ 12.0
+ osx-x64;osx-arm64
+ partial
NAPS2 - Not Another PDF Scanner
NAPS2 - Not Another PDF Scanner
- Copyright 2009, 2012-2022 NAPS2 Contributors
-
-
-
+
+
+
-
+
diff --git a/NAPS2.App.Mac/Program.cs b/NAPS2.App.Mac/Program.cs
index 5ec76fe769..e8c5fc82b5 100644
--- a/NAPS2.App.Mac/Program.cs
+++ b/NAPS2.App.Mac/Program.cs
@@ -1,3 +1,4 @@
+using System.Runtime;
using NAPS2.EntryPoints;
namespace NAPS2;
@@ -9,6 +10,11 @@ static class Program
///
static void Main(string[] args)
{
+ var profilesPath = Path.Combine(Paths.AppData, "jit");
+ Directory.CreateDirectory(profilesPath);
+ ProfileOptimization.SetProfileRoot(profilesPath);
+ ProfileOptimization.StartProfile("naps2.jit");
+
// Use reflection to avoid antivirus false positives (yes, really)
typeof(MacEntryPoint).GetMethod("Run").Invoke(null, new object[] { args });
}
diff --git a/NAPS2.App.PortableLauncher/NAPS2.App.PortableLauncher.csproj b/NAPS2.App.PortableLauncher/NAPS2.App.PortableLauncher.csproj
index e10496ba52..cca05817e1 100644
--- a/NAPS2.App.PortableLauncher/NAPS2.App.PortableLauncher.csproj
+++ b/NAPS2.App.PortableLauncher/NAPS2.App.PortableLauncher.csproj
@@ -1,14 +1,14 @@
- net6;net462
+ net8;net462
WinExe
NAPS2.Portable
NAPS2.Portable
+ ../NAPS2.Lib/Icons/favicon.ico
NAPS2 - Not Another PDF Scanner
NAPS2 - Not Another PDF Scanner
- Copyright 2009, 2012-2020 NAPS2 Contributors; Icons from http://www.fatcow.com/free-icons
diff --git a/NAPS2.App.PortableLauncher/Program.cs b/NAPS2.App.PortableLauncher/Program.cs
index 459723f9b7..8e6e55abf2 100644
--- a/NAPS2.App.PortableLauncher/Program.cs
+++ b/NAPS2.App.PortableLauncher/Program.cs
@@ -1,4 +1,5 @@
using System.Reflection;
+using System.Threading;
namespace NAPS2.Portable;
@@ -9,28 +10,55 @@ static void Main(string[] args)
var portableExeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
if (portableExeDir != null)
{
+ bool failedUpdate = false;
try
{
if (args.Length == 3 && args[0] == "/Update")
{
+ failedUpdate = true;
UpdatePortableApp(portableExeDir, args[1], args[2]);
+ failedUpdate = false;
}
}
finally
{
var portableExePath = Path.Combine(portableExeDir, "App", "NAPS2.exe");
- typeof(Process).GetMethod("Start", new[] {typeof(string)}).Invoke(null, new object[] {portableExePath});
+ if (failedUpdate)
+ {
+ Process.Start(portableExePath, "/FailedUpdate");
+ }
+ else
+ {
+ Process.Start(portableExePath);
+ }
}
}
}
private static void UpdatePortableApp(string portableExeDir, string procId, string newAppFolderPath)
{
- // Wait for the starting process to finish so we don't try to mess with files in use
+ // Wait for the starting process and workers to finish so we don't try to mess with files in use
try
{
- var proc = Process.GetProcessById(int.Parse(procId));
- proc.WaitForExit();
+ var stopwatch = Stopwatch.StartNew();
+
+ // Wait for the starting process to exit
+ var mainProc = Process.GetProcessById(int.Parse(procId));
+ mainProc.WaitForExit();
+
+ // Assume any process named NAPS2 or NAPS2.Worker could be a worker, although this isn't necessarily true
+ // if there are multiple NAPS2 installations.
+ var processesToWaitOn =
+ Process.GetProcessesByName("NAPS2")
+ .Concat(Process.GetProcessesByName("NAPS2.Worker"))
+ .ToList();
+
+ // Wait at most 10 seconds for them to exit (which is WorkerEntryPoint.ParentCheckInterval)
+ const int waitTimeout = 10_000;
+ while (stopwatch.ElapsedMilliseconds < waitTimeout && processesToWaitOn.Any(x => !x.HasExited))
+ {
+ Thread.Sleep(100);
+ }
}
catch (ArgumentException)
{
diff --git a/NAPS2.App.PortableLauncher/scanner-app.ico b/NAPS2.App.PortableLauncher/scanner-app.ico
deleted file mode 100644
index 4ce0e7cc20..0000000000
Binary files a/NAPS2.App.PortableLauncher/scanner-app.ico and /dev/null differ
diff --git a/NAPS2.App.Tests/AppTestData.cs b/NAPS2.App.Tests/AppTestData.cs
index 1a773f4302..8e45faeb0e 100644
--- a/NAPS2.App.Tests/AppTestData.cs
+++ b/NAPS2.App.Tests/AppTestData.cs
@@ -7,9 +7,10 @@ public class AppTestData : IEnumerable
{
public IEnumerator GetEnumerator()
{
+#if NET6_0_OR_GREATER
if (OperatingSystem.IsWindows())
{
- yield return new object[] { new WinNet462AppTestTarget() };
+ yield return new object[] { new WindowsAppTestTarget() };
}
else if (OperatingSystem.IsMacOS())
{
@@ -19,6 +20,9 @@ public IEnumerator GetEnumerator()
{
yield return new object[] { new LinuxAppTestTarget() };
}
+#else
+ yield return new object[] { new WindowsAppTestTarget() };
+#endif
}
IEnumerator IEnumerable.GetEnumerator()
diff --git a/NAPS2.App.Tests/AppTestHelper.cs b/NAPS2.App.Tests/AppTestHelper.cs
index 9a65f0c07b..cba7dfec0a 100644
--- a/NAPS2.App.Tests/AppTestHelper.cs
+++ b/NAPS2.App.Tests/AppTestHelper.cs
@@ -1,6 +1,7 @@
using System.Runtime.InteropServices;
using System.Threading;
using NAPS2.App.Tests.Targets;
+using NAPS2.Scan;
using Xunit;
namespace NAPS2.App.Tests;
@@ -46,7 +47,8 @@ public static string GetBaseDirectory(AppTestExe exe)
public static string GetExePath(AppTestExe exe)
{
var dir = GetBaseDirectory(exe);
- if (dir != exe.DefaultRootPath && exe.TestRootSubPath != null)
+ if (!File.Exists(Path.Combine(dir, exe.ExeSubPath))
+ && dir != exe.DefaultRootPath && exe.TestRootSubPath != null)
{
dir = Path.Combine(dir, exe.TestRootSubPath);
}
@@ -102,5 +104,21 @@ public static void AssertErrorLog(string appData)
Assert.True(File.Exists(path), path);
}
- public static string SolutionRoot => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
+ public static string SolutionRoot =>
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
+
+ public static string GetDeviceName(Driver driver)
+ {
+ var deviceFileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".naps2", "devices");
+ if (!File.Exists(deviceFileName)) return null;
+ var driverStr = driver.ToString().ToLowerInvariant();
+ foreach (var line in File.ReadLines(deviceFileName))
+ {
+ if (line.StartsWith(driverStr + "="))
+ {
+ return line.Trim().Substring(driverStr.Length + 1);
+ }
+ }
+ return null;
+ }
}
\ No newline at end of file
diff --git a/NAPS2.App.Tests/Appium/AppiumTestData.cs b/NAPS2.App.Tests/Appium/AppiumTestData.cs
index ca039d1c3a..8b8a6f3be9 100644
--- a/NAPS2.App.Tests/Appium/AppiumTestData.cs
+++ b/NAPS2.App.Tests/Appium/AppiumTestData.cs
@@ -7,9 +7,10 @@ public class AppiumTestData : IEnumerable
{
public IEnumerator GetEnumerator()
{
+#if NET6_0_OR_GREATER
if (OperatingSystem.IsWindows())
{
- yield return new object[] { new WinNet462AppTestTarget() };
+ yield return new object[] { new WindowsAppTestTarget() };
}
else if (OperatingSystem.IsMacOS())
{
@@ -19,6 +20,9 @@ public IEnumerator GetEnumerator()
{
// No Appium impl yet
}
+#else
+ yield return new object[] { new WindowsAppTestTarget() };
+#endif
}
IEnumerator IEnumerable.GetEnumerator()
diff --git a/NAPS2.App.Tests/Appium/AppiumTests.cs b/NAPS2.App.Tests/Appium/AppiumTests.cs
index 18bbe78e24..be33192b08 100644
--- a/NAPS2.App.Tests/Appium/AppiumTests.cs
+++ b/NAPS2.App.Tests/Appium/AppiumTests.cs
@@ -1,7 +1,7 @@
+using System.Linq.Expressions;
using System.Threading;
using NAPS2.App.Tests.Targets;
using NAPS2.Sdk.Tests;
-using OpenQA.Selenium;
using OpenQA.Selenium.Appium;
using OpenQA.Selenium.Appium.Windows;
@@ -21,41 +21,53 @@ private static WindowsDriver StartSession(AppTestExe exe, string
public void Init(IAppTestTarget target)
{
+ Thread.Sleep(5000);
_session = StartSession(target.Gui, FolderPath);
+ ResetMainWindow();
}
public override void Dispose()
{
- _session.Dispose();
+ try
+ {
+ _session.Dispose();
+ }
+ catch (Exception)
+ {
+ // Ignore disposal errors
+ }
base.Dispose();
}
- protected void WaitUntilGone(string name, int timeoutInMs)
+ protected void ResetMainWindow()
+ {
+ _session.SwitchTo().Window(WaitFor(() => _session.WindowHandles.Single()));
+ }
+
+ protected T WaitFor(Expression> expr, int timeoutInMs = 10_000)
{
+ var func = expr.Compile();
var stopwatch = Stopwatch.StartNew();
- try
+ while (true)
{
- while (true)
+ try
{
- if (_session.FindElementsByName(name).Count == 0)
+ var value = func();
+ if (value is null or false)
{
- break;
+ throw new Exception();
}
+ return value;
+ }
+ catch (Exception)
+ {
if (stopwatch.ElapsedMilliseconds > timeoutInMs)
{
- throw new WebDriverException("Timeout waiting for element to be gone");
+ throw new Exception($"Timed out waiting for \"{expr.Body}\"");
}
Thread.Sleep(100);
}
}
- catch (InvalidOperationException)
- {
- }
- }
-
- protected void ResetMainWindow()
- {
- _session.SwitchTo().Window(_session.WindowHandles[0]);
}
protected void ClickAt(WindowsElement element)
@@ -68,7 +80,7 @@ protected void ClickAt(WindowsElement element)
protected void ClickAtName(string name)
{
- ClickAt(WaitAndFindElementByName(name));
+ ClickAt(WaitFor(() => _session.FindElementByName(name)));
}
protected void DoubleClickAt(WindowsElement element)
@@ -82,23 +94,11 @@ protected void DoubleClickAt(WindowsElement element)
protected void DoubleClickAtName(string name)
{
- DoubleClickAt(WaitAndFindElementByName(name));
+ DoubleClickAt(WaitFor(() => _session.FindElementByName(name)));
}
- protected WindowsElement WaitAndFindElementByName(string name)
+ protected bool HasElementWithName(string name)
{
- int i = 0;
- while(true)
- {
- try
- {
- return _session.FindElementByName(name);
- }
- catch (WebDriverException)
- {
- if (++i > 10) throw;
- Thread.Sleep(100);
- }
- }
+ return _session.FindElementsByName(name).Count > 0;
}
}
\ No newline at end of file
diff --git a/NAPS2.App.Tests/Appium/ImportAndSaveTests.cs b/NAPS2.App.Tests/Appium/ImportAndSaveTests.cs
index 06956d3626..73aac0f7b4 100644
--- a/NAPS2.App.Tests/Appium/ImportAndSaveTests.cs
+++ b/NAPS2.App.Tests/Appium/ImportAndSaveTests.cs
@@ -10,7 +10,7 @@ namespace NAPS2.App.Tests.Appium;
[Collection("appium")]
public class ImportAndSaveTests : AppiumTests
{
- [VerifyTheory(AllowDebug = true)]
+ [VerifyTheory(AllowDebug = true, WindowsAppium = true)]
[ClassData(typeof(AppiumTestData))]
public void ImportVariousAndSavePdfWithOcr(IAppTestTarget target)
{
@@ -34,14 +34,13 @@ public void ImportVariousAndSavePdfWithOcr(IAppTestTarget target)
ClickAtName("Save PDF");
ResetMainWindow();
- var fileNameElements = _session.FindElementsByName("File name:");
- var fileTextBox = fileNameElements.Last();
+ var fileTextBox = WaitFor(() => _session.FindElementsByName("File name:").Last());
ClickAt(fileTextBox);
fileTextBox.SendKeys("test.pdf");
ClickAtName("Save");
// Wait for the save to finish
Thread.Sleep(100);
- WaitUntilGone("Cancel", 10_000);
+ WaitFor(() => !HasElementWithName("Cancel"), 30_000);
var path = Path.Combine(FolderPath, "test.pdf");
PdfAsserts.AssertImages(path,
diff --git a/NAPS2.App.Tests/Appium/LanguageSelectionTests.cs b/NAPS2.App.Tests/Appium/LanguageSelectionTests.cs
index 05656ef2b9..d2a5574507 100644
--- a/NAPS2.App.Tests/Appium/LanguageSelectionTests.cs
+++ b/NAPS2.App.Tests/Appium/LanguageSelectionTests.cs
@@ -11,9 +11,9 @@ namespace NAPS2.App.Tests.Appium;
[Collection("appium")]
public class LanguageSelectionTests : AppiumTests
{
- private static readonly HashSet ExpectedMissingLanguages = new() { "bn", "hi", "id", "th", "ur" };
+ private static readonly HashSet ExpectedMissingLanguages = ["bn", "ur"];
- [VerifyTheory(AllowDebug = true)]
+ [VerifyTheory(AllowDebug = true, WindowsAppium = true)]
[ClassData(typeof(AppiumTestData))]
public void OpenLanguageDropdown(IAppTestTarget target)
{
diff --git a/NAPS2.App.Tests/Appium/ScanAndSaveTests.cs b/NAPS2.App.Tests/Appium/ScanAndSaveTests.cs
index 616066afeb..5ed6c21d89 100644
--- a/NAPS2.App.Tests/Appium/ScanAndSaveTests.cs
+++ b/NAPS2.App.Tests/Appium/ScanAndSaveTests.cs
@@ -1,6 +1,7 @@
using System.Threading;
using NAPS2.App.Tests.Targets;
using NAPS2.App.Tests.Verification;
+using NAPS2.Scan;
using NAPS2.Sdk.Tests.Asserts;
using Xunit;
@@ -10,74 +11,71 @@ namespace NAPS2.App.Tests.Appium;
[Collection("appium")]
public class ScanAndSaveTests : AppiumTests
{
- private const string WIA_DEVICE_NAME = "";
- private const string TWAIN_DEVICE_NAME = "";
-
- [VerifyTheory(AllowDebug = true)]
+ [VerifyTheory(AllowDebug = true, WindowsAppium = true)]
[ClassData(typeof(AppiumTestData))]
public void ScanWiaSavePdf(IAppTestTarget target)
{
Init(target);
// Clicking Scan without a profile opens the profile settings window
ClickAtName("Scan");
- // WIA driver is selected by default, so we open the WIA device dialog
ClickAtName("Choose device");
- Thread.Sleep(100);
- if (WIA_DEVICE_NAME != "") ClickAtName(WIA_DEVICE_NAME);
- // Click OK in the wia device dialog (selecting the first available device by default)
- // TODO: More consistent way to pick the right OK button
- ClickAt(_session.FindElementsByName("OK")[0]);
- WaitUntilGone("Properties", 1_000);
+ WaitFor(() => HasElementWithName("Always Ask"));
+ var deviceName = AppTestHelper.GetDeviceName(Driver.Wia);
+ // WIA driver is selected by default, so we just click the device
+ if (!string.IsNullOrEmpty(deviceName)) ClickAtName(deviceName);
+ ClickAt(_session.FindElementByName("Select"));
+ WaitFor(() => !HasElementWithName("Select"));
// Click OK in the profile settings window
ClickAtName("OK");
+ WaitFor(() => HasElementWithName("Cancel"));
// Wait for scanning to finish
- WaitUntilGone("Cancel", 30_000);
+ WaitFor(() => !HasElementWithName("Cancel"), 30_000);
ResetMainWindow();
// Save "test.pdf" in the default location (which will be the test data path as NAPS2 knows we're in a test)^
ClickAtName("Save PDF");
ResetMainWindow();
- var fileNameElements = _session.FindElementsByName("File name:");
- var fileTextBox = fileNameElements.Last();
+ var fileTextBox = WaitFor(() => _session.FindElementsByName("File name:").Last());
ClickAt(fileTextBox);
fileTextBox.SendKeys("test.pdf");
ClickAtName("Save");
// Wait for the save to finish, it should be almost instant
- Thread.Sleep(200);
+ Thread.Sleep(1000);
PdfAsserts.AssertPageCount(1, Path.Combine(FolderPath, "test.pdf"));
AppTestHelper.AssertNoErrorLog(FolderPath);
}
- [VerifyTheory(AllowDebug = true)]
+ [VerifyTheory(AllowDebug = true, WindowsAppium = true)]
[ClassData(typeof(AppiumTestData))]
public void ScanTwainSaveImage(IAppTestTarget target)
{
Init(target);
// Clicking Scan without a profile opens the profile settings window
ClickAtName("Scan");
- ClickAtName("TWAIN Driver");
- // Open the TWAIN device dialog
ClickAtName("Choose device");
+ WaitFor(() => HasElementWithName("Always Ask"));
+ var deviceName = AppTestHelper.GetDeviceName(Driver.Twain);
+ ClickAtName("TWAIN Driver");
Thread.Sleep(100);
- if (TWAIN_DEVICE_NAME != "") ClickAtName(TWAIN_DEVICE_NAME);
- // Click Select in the twain device dialog (selecting the first available device by default)
+ WaitFor(() => HasElementWithName("Always Ask"));
+ if (!string.IsNullOrEmpty(deviceName)) ClickAtName(deviceName);
ClickAtName("Select");
- WaitUntilGone("Select", 1_000);
+ WaitFor(() => !HasElementWithName("Select"));
// Click OK in the profile settings window
ClickAtName("OK");
+ WaitFor(() => HasElementWithName("Cancel"));
// Wait for scanning to finish
- WaitUntilGone("Cancel", 30_000);
+ WaitFor(() => !HasElementWithName("Cancel"), 30_000);
ResetMainWindow();
// Save "test.pdf" in the default location (which will be the test data path as NAPS2 knows we're in a test)^
ClickAtName("Save Images");
ResetMainWindow();
- var fileNameElements = _session.FindElementsByName("File name:");
- var fileTextBox = fileNameElements.Last();
+ var fileTextBox = WaitFor(() => _session.FindElementsByName("File name:").Last());
ClickAt(fileTextBox);
fileTextBox.SendKeys("test.jpg");
ClickAtName("Save");
// Wait for the save to finish, it should be almost instant
- Thread.Sleep(200);
+ Thread.Sleep(1000);
ImageAsserts.Inches(Path.Combine(FolderPath, "test.jpg"), 8.5, 11);
AppTestHelper.AssertNoErrorLog(FolderPath);
diff --git a/NAPS2.App.Tests/ConsoleAppTests.cs b/NAPS2.App.Tests/ConsoleAppTests.cs
index 33b722c4a8..7b30d89323 100644
--- a/NAPS2.App.Tests/ConsoleAppTests.cs
+++ b/NAPS2.App.Tests/ConsoleAppTests.cs
@@ -6,6 +6,8 @@ namespace NAPS2.App.Tests;
public class ConsoleAppTests : ContextualTests
{
+ private const int EXIT_TIMEOUT = 30_000;
+
[Theory]
[ClassData(typeof(AppTestData))]
public void ConvertsImportedFile(IAppTestTarget target)
@@ -17,7 +19,7 @@ public void ConvertsImportedFile(IAppTestTarget target)
var process = AppTestHelper.StartProcess(target.Console, FolderPath, args);
try
{
- Assert.True(process.WaitForExit(5000));
+ Assert.True(process.WaitForExit(EXIT_TIMEOUT));
var stdout = process.StandardOutput.ReadToEnd();
Assert.Equal(0, process.ExitCode);
Assert.Empty(stdout);
@@ -41,9 +43,9 @@ public void NonZeroExitCodeForError(IAppTestTarget target)
var process = AppTestHelper.StartProcess(target.Console, FolderPath, args);
try
{
- Assert.True(process.WaitForExit(5000));
+ Assert.True(process.WaitForExit(EXIT_TIMEOUT));
var stdout = process.StandardOutput.ReadToEnd();
- if (OperatingSystem.IsWindows())
+ if (target.IsWindows)
{
// TODO: Figure out why ExitCode always appears as 0 on Mac/Linux
Assert.NotEqual(0, process.ExitCode);
diff --git a/NAPS2.App.Tests/GuiAppTests.cs b/NAPS2.App.Tests/GuiAppTests.cs
index 47331afa8d..a2848a3448 100644
--- a/NAPS2.App.Tests/GuiAppTests.cs
+++ b/NAPS2.App.Tests/GuiAppTests.cs
@@ -1,4 +1,3 @@
-using System.Threading;
using NAPS2.App.Tests.Targets;
using NAPS2.Remoting;
using NAPS2.Sdk.Tests;
@@ -8,14 +7,14 @@ namespace NAPS2.App.Tests;
public class GuiAppTests : ContextualTests
{
- [Theory]
+ [GuiTheory]
[ClassData(typeof(AppTestData))]
public void CreatesWindow(IAppTestTarget target)
{
var process = AppTestHelper.StartGuiProcess(target.Gui, FolderPath);
try
{
- if (OperatingSystem.IsWindows())
+ if (target.IsWindows)
{
AppTestHelper.WaitForVisibleWindow(process);
Assert.Equal("NAPS2 - Not Another PDF Scanner", process.MainWindowTitle);
@@ -23,8 +22,8 @@ public void CreatesWindow(IAppTestTarget target)
}
else
{
- Thread.Sleep(1000);
- Assert.True(Pipes.SendMessage(process, Pipes.MSG_CLOSE_WINDOW));
+ var helper = ProcessCoordinator.CreateDefault();
+ Assert.True(helper.CloseWindow(process, 5000));
}
Assert.True(process.WaitForExit(5000));
AppTestHelper.AssertNoErrorLog(FolderPath);
diff --git a/NAPS2.App.Tests/GuiTheoryAttribute.cs b/NAPS2.App.Tests/GuiTheoryAttribute.cs
new file mode 100644
index 0000000000..58d875a013
--- /dev/null
+++ b/NAPS2.App.Tests/GuiTheoryAttribute.cs
@@ -0,0 +1,14 @@
+using Xunit;
+
+namespace NAPS2.App.Tests;
+
+public class GuiTheoryAttribute : TheoryAttribute
+{
+ public GuiTheoryAttribute()
+ {
+ if (Environment.GetEnvironmentVariable("NAPS2_TEST_NOGUI") == "1")
+ {
+ Skip = "Running in headless mode, skipping GUI tests";
+ }
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.App.Tests/NAPS2.App.Tests.csproj b/NAPS2.App.Tests/NAPS2.App.Tests.csproj
index a0bef07c53..db41c078f7 100644
--- a/NAPS2.App.Tests/NAPS2.App.Tests.csproj
+++ b/NAPS2.App.Tests/NAPS2.App.Tests.csproj
@@ -1,7 +1,8 @@
- net6
+ net9-windows
+ net9
true
None
Debug;Release;DebugLang
@@ -13,16 +14,16 @@
-
-
+
+
-
-
-
-
-
+
+
+
+
+
\ No newline at end of file
diff --git a/NAPS2.App.Tests/ServerAppTests.cs b/NAPS2.App.Tests/ServerAppTests.cs
new file mode 100644
index 0000000000..377f65f646
--- /dev/null
+++ b/NAPS2.App.Tests/ServerAppTests.cs
@@ -0,0 +1,27 @@
+using NAPS2.App.Tests.Targets;
+using NAPS2.Remoting;
+using NAPS2.Sdk.Tests;
+using Xunit;
+
+namespace NAPS2.App.Tests;
+
+public class ServerAppTests : ContextualTests
+{
+ [GuiTheory]
+ [ClassData(typeof(AppTestData))]
+ public void StartAndStopServer(IAppTestTarget target)
+ {
+ var process = AppTestHelper.StartProcess(target.Server, FolderPath);
+ try
+ {
+ var helper = ProcessCoordinator.CreateDefault();
+ Assert.True(helper.StopSharingServer(process, 5000));
+ Assert.True(process.WaitForExit(5000));
+ AppTestHelper.AssertNoErrorLog(FolderPath);
+ }
+ finally
+ {
+ AppTestHelper.Cleanup(process);
+ }
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.App.Tests/Targets/IAppTestTarget.cs b/NAPS2.App.Tests/Targets/IAppTestTarget.cs
index 454095706c..70ecccd713 100644
--- a/NAPS2.App.Tests/Targets/IAppTestTarget.cs
+++ b/NAPS2.App.Tests/Targets/IAppTestTarget.cs
@@ -5,4 +5,6 @@ public interface IAppTestTarget
AppTestExe Console { get; }
AppTestExe Gui { get; }
AppTestExe Worker { get; }
+ AppTestExe Server { get; }
+ bool IsWindows { get; }
}
\ No newline at end of file
diff --git a/NAPS2.App.Tests/Targets/LinuxAppTestTarget.cs b/NAPS2.App.Tests/Targets/LinuxAppTestTarget.cs
index 4e90031aa0..c8f5411567 100644
--- a/NAPS2.App.Tests/Targets/LinuxAppTestTarget.cs
+++ b/NAPS2.App.Tests/Targets/LinuxAppTestTarget.cs
@@ -7,12 +7,14 @@ public class LinuxAppTestTarget : IAppTestTarget
public AppTestExe Console => GetAppTestExe("console");
public AppTestExe Gui => GetAppTestExe(null);
public AppTestExe Worker => GetAppTestExe("worker");
+ public AppTestExe Server => GetAppTestExe("server");
+ public bool IsWindows => false;
private AppTestExe GetAppTestExe(string argPrefix)
{
var runtime = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "linux-arm64" : "linux-x64";
return new AppTestExe(
- Path.Combine(AppTestHelper.SolutionRoot, "NAPS2.App.Gtk", "bin", "Debug", "net6", runtime),
+ Path.Combine(AppTestHelper.SolutionRoot, "NAPS2.App.Gtk", "bin", "Debug", "net9", runtime),
"naps2",
argPrefix);
}
diff --git a/NAPS2.App.Tests/Targets/MacAppTestTarget.cs b/NAPS2.App.Tests/Targets/MacAppTestTarget.cs
index ccec634be3..87152e1c96 100644
--- a/NAPS2.App.Tests/Targets/MacAppTestTarget.cs
+++ b/NAPS2.App.Tests/Targets/MacAppTestTarget.cs
@@ -5,11 +5,13 @@ public class MacAppTestTarget : IAppTestTarget
public AppTestExe Console => GetAppTestExe("console");
public AppTestExe Gui => GetAppTestExe(null);
public AppTestExe Worker => GetAppTestExe("worker");
+ public AppTestExe Server => GetAppTestExe("server");
+ public bool IsWindows => false;
private AppTestExe GetAppTestExe(string argPrefix)
{
return new AppTestExe(
- Path.Combine(AppTestHelper.SolutionRoot, "NAPS2.App.Mac", "bin", "Debug", "net7-macos10.15"),
+ Path.Combine(AppTestHelper.SolutionRoot, "NAPS2.App.Mac", "bin", "Debug", "net9-macos"),
Path.Combine("NAPS2.app", "Contents", "MacOS", "NAPS2"),
argPrefix);
}
diff --git a/NAPS2.App.Tests/Targets/WinNet462AppTestTarget.cs b/NAPS2.App.Tests/Targets/WinNet462AppTestTarget.cs
deleted file mode 100644
index 7b5f24ccaf..0000000000
--- a/NAPS2.App.Tests/Targets/WinNet462AppTestTarget.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace NAPS2.App.Tests.Targets;
-
-public class WinNet462AppTestTarget : IAppTestTarget
-{
- public AppTestExe Console => GetAppTestExe("NAPS2.App.Console", "NAPS2.Console.exe", null);
- public AppTestExe Gui => GetAppTestExe("NAPS2.App.WinForms", "NAPS2.exe", null);
- public AppTestExe Worker => GetAppTestExe("NAPS2.App.Worker", "NAPS2.Worker.exe", "lib");
-
- private AppTestExe GetAppTestExe(string project, string exeName, string testRootSubPath)
- {
- return new AppTestExe(
- Path.Combine(AppTestHelper.SolutionRoot, project, "bin", "Debug", "net462"),
- exeName,
- TestRootSubPath: testRootSubPath);
- }
-
- public override string ToString() => "Windows (net462)";
-}
\ No newline at end of file
diff --git a/NAPS2.App.Tests/Targets/WindowsAppTestTarget.cs b/NAPS2.App.Tests/Targets/WindowsAppTestTarget.cs
new file mode 100644
index 0000000000..98a044e84b
--- /dev/null
+++ b/NAPS2.App.Tests/Targets/WindowsAppTestTarget.cs
@@ -0,0 +1,22 @@
+namespace NAPS2.App.Tests.Targets;
+
+public class WindowsAppTestTarget : IAppTestTarget
+{
+ public AppTestExe Console => GetAppTestExe("NAPS2.App.Console", "NAPS2.Console.exe", "win-x64");
+ public AppTestExe Gui => GetAppTestExe("NAPS2.App.WinForms", "NAPS2.exe", "win-x64");
+ public AppTestExe Worker => GetAppTestExe("NAPS2.App.Worker", "NAPS2.Worker.exe", "win-x86", null, "lib");
+ public AppTestExe Server => GetAppTestExe("NAPS2.App.WinForms", "NAPS2.exe", "win-x64", "server");
+ public bool IsWindows => true;
+
+ private AppTestExe GetAppTestExe(string project, string exeName, string arch, string argPrefix = null,
+ string testRootSubPath = null)
+ {
+ return new AppTestExe(
+ Path.Combine(AppTestHelper.SolutionRoot, project, "bin", "Debug", "net9-windows", arch),
+ exeName,
+ argPrefix,
+ testRootSubPath);
+ }
+
+ public override string ToString() => "Windows";
+}
\ No newline at end of file
diff --git a/NAPS2.App.Tests/Verification/InstallDirTestData.cs b/NAPS2.App.Tests/Verification/InstallDirTestData.cs
index acf8cc2e04..e2e9f5b68b 100644
--- a/NAPS2.App.Tests/Verification/InstallDirTestData.cs
+++ b/NAPS2.App.Tests/Verification/InstallDirTestData.cs
@@ -6,7 +6,12 @@ public class InstallDirTestData : IEnumerable
{
public IEnumerator GetEnumerator()
{
- if (OperatingSystem.IsWindows() && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("NAPS2_TEST_VERIFY")))
+ if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("NAPS2_TEST_VERIFY")))
+ {
+ yield break;
+ }
+#if NET6_0_OR_GREATER
+ if (OperatingSystem.IsWindows())
{
yield return new object[] { Environment.GetEnvironmentVariable("NAPS2_TEST_ROOT") };
}
@@ -18,6 +23,9 @@ public IEnumerator GetEnumerator()
{
// No tests yet
}
+#else
+ yield return new object[] { Environment.GetEnvironmentVariable("NAPS2_TEST_ROOT") };
+#endif
}
IEnumerator IEnumerable.GetEnumerator()
diff --git a/NAPS2.App.Tests/Verification/VerifyFactAttribute.cs b/NAPS2.App.Tests/Verification/VerifyFactAttribute.cs
index eb53405aff..f2b35901a3 100644
--- a/NAPS2.App.Tests/Verification/VerifyFactAttribute.cs
+++ b/NAPS2.App.Tests/Verification/VerifyFactAttribute.cs
@@ -5,6 +5,7 @@ namespace NAPS2.App.Tests.Verification;
public sealed class VerifyTheoryAttribute : TheoryAttribute
{
private bool _allowDebug;
+ private bool _windowsAppium;
public VerifyTheoryAttribute()
{
@@ -33,4 +34,19 @@ public bool AllowDebug
_allowDebug = value;
}
}
+
+ public bool WindowsAppium
+ {
+ get => _windowsAppium;
+ set
+ {
+#if NET6_0_OR_GREATER
+ if (value && Skip == null && !OperatingSystem.IsWindows())
+ {
+ Skip = "Appium tests are only supported on Windows right now.";
+ }
+#endif
+ _windowsAppium = value;
+ }
+ }
}
\ No newline at end of file
diff --git a/NAPS2.App.WinForms/NAPS2.App.WinForms.csproj b/NAPS2.App.WinForms/NAPS2.App.WinForms.csproj
index 0182e57abb..c474a455c2 100644
--- a/NAPS2.App.WinForms/NAPS2.App.WinForms.csproj
+++ b/NAPS2.App.WinForms/NAPS2.App.WinForms.csproj
@@ -1,30 +1,22 @@
- net6-windows;net462
+ net9-windows
true
WinExe
- app.config
- true
NAPS2
NAPS2
- 7.0.1
../NAPS2.Lib/Icons/favicon.ico
-
-
-
-
-
+ true
+ win-x64;win-arm64
+
+ false
+ none
+ true
NAPS2 - Not Another PDF Scanner
NAPS2 - Not Another PDF Scanner
- Copyright 2009, 2012-2020 NAPS2 Contributors; Icons from http://www.fatcow.com/free-icons
-
-
- None
@@ -32,13 +24,10 @@
-
-
+
+
-
-
-
diff --git a/NAPS2.App.WinForms/Program.cs b/NAPS2.App.WinForms/Program.cs
index d41eb00e33..bbfed04e6f 100644
--- a/NAPS2.App.WinForms/Program.cs
+++ b/NAPS2.App.WinForms/Program.cs
@@ -1,3 +1,4 @@
+using System.Runtime;
using NAPS2.EntryPoints;
namespace NAPS2;
@@ -10,7 +11,11 @@ static class Program
[STAThread]
static void Main(string[] args)
{
- // Use reflection to avoid antivirus false positives (yes, really)
- typeof(WinFormsEntryPoint).GetMethod("Run").Invoke(null, new object[] { args });
+ var profilesPath = Path.Combine(Paths.AppData, "jit");
+ Directory.CreateDirectory(profilesPath);
+ ProfileOptimization.SetProfileRoot(profilesPath);
+ ProfileOptimization.StartProfile("naps2.jit");
+
+ WinFormsEntryPoint.Run(args);
}
}
\ No newline at end of file
diff --git a/NAPS2.App.WinForms/app.config b/NAPS2.App.WinForms/app.config
deleted file mode 100644
index 1230633e9a..0000000000
--- a/NAPS2.App.WinForms/app.config
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/NAPS2.App.WinForms/runtimeconfig.template.json b/NAPS2.App.WinForms/runtimeconfig.template.json
new file mode 100644
index 0000000000..4f1f5b6f5c
--- /dev/null
+++ b/NAPS2.App.WinForms/runtimeconfig.template.json
@@ -0,0 +1,5 @@
+{
+ "configProperties": {
+ "System.Windows.Forms.ScaleTopLevelFormMinMaxSizeForDpi": false
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.App.Worker/NAPS2.App.Worker.csproj b/NAPS2.App.Worker/NAPS2.App.Worker.csproj
index 24c3e149b9..7ffd26807f 100644
--- a/NAPS2.App.Worker/NAPS2.App.Worker.csproj
+++ b/NAPS2.App.Worker/NAPS2.App.Worker.csproj
@@ -1,27 +1,42 @@
- net6-windows;net462
+ net9-windows
true
WinExe
+ enable
true
NAPS2.Worker
NAPS2.Worker
- x86
+ true
+ true
+ win-x86
+ true
+ partial
+ true
NAPS2 - Not Another PDF Scanner
NAPS2 - Not Another PDF Scanner
- Copyright 2009, 2012-2020 NAPS2 Contributors; Icons from http://www.fatcow.com/free-icons
+
-
-
+
+
+
+
+
+
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/NAPS2.App.Worker/Program.cs b/NAPS2.App.Worker/Program.cs
index 04c73fd732..88d13d82cd 100644
--- a/NAPS2.App.Worker/Program.cs
+++ b/NAPS2.App.Worker/Program.cs
@@ -1,4 +1,11 @@
-using NAPS2.EntryPoints;
+using System.Runtime;
+using NAPS2.EntryPoints;
+using NAPS2.Images.Gdi;
+using NAPS2.ImportExport.Email.Mapi;
+using NAPS2.Platform.Windows;
+using NAPS2.Remoting.Worker;
+using NAPS2.Scan;
+using NAPS2.Scan.Internal.Twain;
namespace NAPS2.Worker;
@@ -8,9 +15,31 @@ static class Program
/// The NAPS2.Worker.exe main method.
///
[STAThread]
- static void Main(string[] args)
+ static int Main(string[] args)
{
- // Use reflection to avoid antivirus false positives (yes, really)
- typeof(WindowsWorkerEntryPoint).GetMethod("Run").Invoke(null, new object[] { args });
+ var profilesPath = Path.Combine(Paths.AppData, "jit");
+ Directory.CreateDirectory(profilesPath);
+ ProfileOptimization.SetProfileRoot(profilesPath);
+ ProfileOptimization.StartProfile("naps2.worker.jit");
+
+ // This NAPS2.App.Worker project doesn't follow the conventions of the rest of NAPS2 as far as using EntryPoint
+ // classes for everything. The reason is that we want to avoid pulling in extra dependencies as NAPS2.Worker.exe
+ // is 32-bit and therefore requires a second copy of every single dependency we use.
+ //
+ // Thus the simplest solution is just to pull in a bit of code from NAPS2.Lib that has what we need
+ // (pretty much only paths, logging, and the worker setup) and avoid using Autofac.
+ var logger = NLogConfig.CreateLogger(() => NLogConfig.EnvDebugLogging);
+ var messagePump = Win32MessagePump.Create();
+ messagePump.Logger = logger;
+ var scanningContext = new ScanningContext(new GdiImageContext());
+ scanningContext.Logger = logger;
+ var serviceImpl = new WorkerServiceImpl(scanningContext, new ThumbnailRenderer(scanningContext.ImageContext),
+ new MapiWrapper(logger), new LocalTwainController(scanningContext));
+
+ Trace.Listeners.Add(new NLog.NLogTraceListener());
+ Invoker.Current = messagePump;
+ TwainHandleManager.Factory = () => new Win32TwainHandleManager(messagePump);
+
+ return CoreWorkerEntryPoint.Run(args, logger, serviceImpl, messagePump.RunMessageLoop, messagePump.Dispose);
}
}
\ No newline at end of file
diff --git a/NAPS2.Escl.Client/CompilerAttributes.cs b/NAPS2.Escl.Client/CompilerAttributes.cs
deleted file mode 100644
index 8230a289b1..0000000000
--- a/NAPS2.Escl.Client/CompilerAttributes.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-// https://sergiopedri.medium.com/enabling-and-using-c-9-features-on-older-and-unsupported-runtimes-ce384d8debb
-// ReSharper disable once CheckNamespace
-
-namespace System.Runtime.CompilerServices
-{
- internal static class IsExternalInit
- {
- }
-
- /// Specifies that a type has required members or that a member is required.
- [AttributeUsage(
- AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property,
- AllowMultiple = false, Inherited = false)]
- internal sealed class RequiredMemberAttribute : Attribute
- {
- }
-
- ///
- /// Indicates that compiler support for a particular feature is required for the location where this attribute is applied.
- ///
- [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)]
- internal sealed class CompilerFeatureRequiredAttribute : Attribute
- {
- public CompilerFeatureRequiredAttribute(string featureName)
- {
- FeatureName = featureName;
- }
-
- ///
- /// The name of the compiler feature.
- ///
- public string FeatureName { get; }
-
- ///
- /// If true, the compiler can choose to allow access to the location where this attribute is applied if it does not understand .
- ///
- public bool IsOptional { get; init; }
-
- ///
- /// The used for the ref structs C# feature.
- ///
- public const string RefStructs = nameof(RefStructs);
-
- ///
- /// The used for the required members C# feature.
- ///
- public const string RequiredMembers = nameof(RequiredMembers);
- }
-}
-
-namespace System.Diagnostics.CodeAnalysis
-{
- ///
- /// Specifies that this constructor sets all required members for the current type, and callers
- /// do not need to set any required members themselves.
- ///
- [AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)]
- internal sealed class SetsRequiredMembersAttribute : Attribute
- {
- }
-}
\ No newline at end of file
diff --git a/NAPS2.Escl.Client/EsclClient.cs b/NAPS2.Escl.Client/EsclClient.cs
deleted file mode 100644
index 47170776b7..0000000000
--- a/NAPS2.Escl.Client/EsclClient.cs
+++ /dev/null
@@ -1,135 +0,0 @@
-using System.Xml.Linq;
-
-namespace NAPS2.Escl.Client;
-
-public class EsclClient
-{
- private static readonly XNamespace ScanNs = EsclXmlHelper.ScanNs;
- private static readonly XNamespace PwgNs = EsclXmlHelper.PwgNs;
-
- private readonly EsclService _service;
-
- public EsclClient(EsclService service)
- {
- _service = service;
- }
-
- public async Task GetCapabilities()
- {
- var doc = await DoRequest("ScannerCapabilities");
- var root = doc.Root;
- if (root?.Name != ScanNs + "ScannerCapabilities")
- {
- throw new InvalidOperationException("Unexpected root element: " + doc.Root?.Name);
- }
- var settingProfilesEl = root.Element(ScanNs + "SettingProfiles");
- var settingProfiles = new Dictionary();
- if (settingProfilesEl != null)
- {
- foreach (var el in settingProfilesEl.Elements(ScanNs + "SettingProfile"))
- {
- ParseSettingProfile(el, settingProfiles);
- }
- }
- return new EsclCapabilities
- {
- Version = root.Element(PwgNs + "Version")?.Value,
- MakeAndModel = root.Element(PwgNs + "MakeAndModel")?.Value,
- SerialNumber = root.Element(PwgNs + "SerialNumber")?.Value,
- Uuid = root.Element(ScanNs + "UUID")?.Value,
- AdminUri = root.Element(ScanNs + "AdminURI")?.Value,
- IconUri = root.Element(ScanNs + "IconURI")?.Value
- };
- }
-
- public async Task GetStatus()
- {
- var text = await new HttpClient().GetStringAsync(GetUrl("ScannerStatus"));
- var doc = XDocument.Parse(text);
- return new EsclScannerStatus();
- }
-
- public async Task CreateScanJob(EsclScanSettings scanSettings)
- {
- var doc =
- EsclXmlHelper.CreateDocAsString(
- new XElement(ScanNs + "ScanSettings",
- new XElement(PwgNs + "Version", "2.6"),
- new XElement(ScanNs + "Intent", "Photo"),
- new XElement(PwgNs + "ScanRegions",
- new XElement(PwgNs + "ScanRegion",
- new XElement(PwgNs + "Height", "1200"),
- new XElement(PwgNs + "ContentRegionUnits", "escl:ThreeHundredthsOfInches"),
- new XElement(PwgNs + "Width", "1800"),
- new XElement(PwgNs + "XOffset"),
- new XElement(PwgNs + "YOffset"))),
- new XElement(PwgNs + "InputSource", "Platen"),
- new XElement(ScanNs + "ColorMode", "Grayscale8")));
- var response = await new HttpClient().PostAsync(GetUrl("ScanJobs"), new StringContent(doc));
- response.EnsureSuccessStatusCode();
- return new EsclJob
- {
- Uri = response.Headers.Location!
- };
- }
-
- public async Task NextDocument(EsclJob job)
- {
- var client = new HttpClient();
- client.DefaultRequestHeaders.TransferEncodingChunked = true;
- // TODO: Maybe check Content-Location on the response header to ensure no duplicate document?
- return await client.GetByteArrayAsync(job.Uri + "/NextDocument");
- }
-
- private EsclSettingProfile ParseSettingProfile(XElement element,
- Dictionary profilesDict)
- {
- var profileRef = element.Attribute("ref")?.Value;
- if (profileRef != null)
- {
- return profilesDict[profileRef];
- }
- var profile = new EsclSettingProfile
- {
- Name = element.Attribute("name")?.Value,
- ColorModes =
- ParseEnumValues(element.Element(ScanNs + "ColorModes")?.Elements(ScanNs + "ColorMode"))
- };
- if (profile.Name != null)
- {
- profilesDict[profile.Name] = profile;
- }
- return profile;
- }
-
- private List ParseEnumValues(IEnumerable? elements) where T : struct
- {
- var list = new List();
- if (elements == null)
- {
- return list;
- }
- foreach (var el in elements)
- {
- if (Enum.TryParse(el.Value, out var parsed))
- {
- list.Add(parsed);
- }
- }
- return list;
- }
-
- private async Task DoRequest(string endpoint)
- {
- // TODO: We're supposed to reuse HttpClient, right?
- var text = await new HttpClient().GetStringAsync(GetUrl(endpoint));
- return XDocument.Parse(text);
- }
-
- private string GetUrl(string endpoint)
- {
- var protocol = _service.Tls ? "https" : "http";
- return new UriBuilder(protocol, _service.Ip.ToString(), _service.Port, $"{_service.RootUrl}/{endpoint}")
- .Uri.ToString();
- }
-}
\ No newline at end of file
diff --git a/NAPS2.Escl.Client/EsclService.cs b/NAPS2.Escl.Client/EsclService.cs
deleted file mode 100644
index 7fea336c6b..0000000000
--- a/NAPS2.Escl.Client/EsclService.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using System.Net;
-
-namespace NAPS2.Escl.Client;
-
-public class EsclService
-{
- public required IPAddress Ip { get; init; }
- public required int Port { get; init; }
- public required bool Tls { get; init; }
- public required string RootUrl { get; init; }
-}
\ No newline at end of file
diff --git a/NAPS2.Escl.Client/EsclServiceLocator.cs b/NAPS2.Escl.Client/EsclServiceLocator.cs
deleted file mode 100644
index 006faf6aac..0000000000
--- a/NAPS2.Escl.Client/EsclServiceLocator.cs
+++ /dev/null
@@ -1,105 +0,0 @@
-using System.Net;
-using Makaretu.Dns;
-
-namespace NAPS2.Escl.Client;
-
-public class EsclServiceLocator
-{
- public async Task> Locate()
- {
- using var sd = new ServiceDiscovery();
- var locatedServices = new List();
- sd.ServiceInstanceDiscovered += (sender, args) =>
- {
- try
- {
- var service = ParseService(args);
- lock (locatedServices)
- {
- locatedServices.Add(service);
- }
- }
- catch (Exception)
- {
- // TODO: Log?
- }
- };
- sd.QueryServiceInstances("_uscan._tcp");
- sd.QueryServiceInstances("_uscans._tcp");
- await Task.Delay(2000);
- // TODO: De-duplicate http/https services?
- return locatedServices;
-
- // var txtVers = props.GetValueOrDefault("txtvers"); // txt record version
- // var adminUrl = props.GetValueOrDefault("adminurl"); // url to scanner config page
- // var esclVersion = props.GetValueOrDefault("Vers"); // escl version e.g. "2.0"
- // var thumbnail = props.GetValueOrDefault("representation"); // url to png or ico
- // var urlBasePath = props.GetValueOrDefault("rs"); // no leading (or trailing) slash
- // var scannerName = props.GetValueOrDefault("ty"); // human readable
- // var note = props.GetValueOrDefault("note"); // supposed to be "scanner location", e.g. "Copy Room"
- // // Note jpeg is better in that we can get one image at a time, but pdf does allow png quality potentially
- // // Hopefully decent scanners can support png too
- // // Also for the server we can definitely provide NAPS2-generated pdfs, which is kind of a cool idea for e.g. using from mobile
- // var pdl = props.GetValueOrDefault("pdl"); // comma separated mime types supported "application/pdf,image/jpeg" at minimum
- // var uuid = props.GetValueOrDefault("uuid"); // physical device id
- // var colorSpace = props.GetValueOrDefault("cs"); // comma separated capabilites, "color,grayscale,binary"
- // var source = props.GetValueOrDefault("is"); // "platen,adf,camera" platen = flatbed
- // var duplex = props.GetValueOrDefault("duplex"); // "T"rue or "F"alse
- //
- }
-
- private EsclService ParseService(ServiceInstanceDiscoveryEventArgs args)
- {
- string name = args.ServiceInstanceName.Labels[0];
- string protocol = args.ServiceInstanceName.Labels[1];
- IPAddress? ip = null;
- int port = -1;
- var props = new Dictionary();
- foreach (var record in args.Message.AdditionalRecords)
- {
- if (record is ARecord a)
- {
- ip ??= a.Address;
- }
- if (record is AAAARecord aaaa)
- {
- ip = aaaa.Address;
- }
- if (record is SRVRecord srv)
- {
- port = srv.Port;
- }
- if (record is TXTRecord txt)
- {
- foreach (var str in txt.Strings)
- {
- var eq = str.IndexOf("=", StringComparison.Ordinal);
- if (eq != -1)
- {
- props[str.Substring(0, eq).ToLowerInvariant()] = str.Substring(eq + 1);
- }
- }
- }
- }
- bool http = protocol == "_uscan";
- bool https = protocol == "_uscans";
- if (ip == null || port == -1 || !http && !https)
- {
- throw new ArgumentException();
- }
- return new EsclService
- {
- // Uuid = props["uuid"],
- // Name = props["ty"],
- Ip = ip,
- Port = port,
- Tls = https,
- RootUrl = props["rs"] // TODO: More props, some required, some optional maybe
- };
- }
-
- private string? Get(Dictionary props, string key)
- {
- return props.TryGetValue(key, out var value) ? value : null;
- }
-}
\ No newline at end of file
diff --git a/NAPS2.Escl.Client/NAPS2.Escl.Client.csproj b/NAPS2.Escl.Client/NAPS2.Escl.Client.csproj
deleted file mode 100644
index 7e8fb1e063..0000000000
--- a/NAPS2.Escl.Client/NAPS2.Escl.Client.csproj
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
- net6.0;net462;netstandard2.0
- enable
- enable
- 11
- USB
-
-
-
-
-
-
-
-
-
diff --git a/NAPS2.Escl.Server/CertificateHelper.cs b/NAPS2.Escl.Server/CertificateHelper.cs
new file mode 100644
index 0000000000..2a96d34e3a
--- /dev/null
+++ b/NAPS2.Escl.Server/CertificateHelper.cs
@@ -0,0 +1,57 @@
+using System.Collections.ObjectModel;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Logging;
+
+namespace NAPS2.Escl.Server;
+
+internal static class CertificateHelper
+{
+ private static readonly Type? CertificateRequestType;
+ private static readonly ConstructorInfo? CertificateRequestConstructor;
+ private static readonly PropertyInfo? CertificateExtensionsProperty;
+ private static readonly MethodInfo? CreateSelfSignedMethod;
+
+ static CertificateHelper()
+ {
+ // TODO: On net472+ we can avoid the reflection
+ CertificateRequestType = typeof(RSACertificateExtensions).Assembly.GetType(
+ "System.Security.Cryptography.X509Certificates.CertificateRequest");
+ CertificateRequestConstructor = CertificateRequestType?.GetConstructor(
+ new[] { typeof(string), typeof(RSA), typeof(HashAlgorithmName), typeof(RSASignaturePadding) });
+ CertificateExtensionsProperty = CertificateRequestType?.GetProperty("CertificateExtensions");
+ CreateSelfSignedMethod = CertificateRequestType?.GetMethod("CreateSelfSigned");
+ }
+
+ // See https://stackoverflow.com/a/65258808/2112909
+ public static X509Certificate2? GenerateSelfSignedCertificate(ILogger logger)
+ {
+ try
+ {
+ if (CertificateRequestType == null)
+ {
+ logger.LogDebug("CertificateRequest type not available");
+ return null;
+ }
+
+ var request = CertificateRequestConstructor!.Invoke(new object[]
+ { "CN=NAPS2-ESCL-Self-Signed", RSA.Create(), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1 });
+
+ var extensions = (Collection) CertificateExtensionsProperty!.GetValue(request)!;
+ extensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyEncipherment, true));
+
+ var now = DateTimeOffset.UtcNow;
+ var cert = (X509Certificate2) CreateSelfSignedMethod!.Invoke(request,
+ new object[] { now.AddDays(-1), now.AddYears(10) })!;
+ var pfxCert = new X509Certificate2(cert.Export(X509ContentType.Pfx));
+
+ return pfxCert;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error generating self-signed certificate");
+ return null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/CompilerAttributes.cs b/NAPS2.Escl.Server/CompilerAttributes.cs
deleted file mode 100644
index 8230a289b1..0000000000
--- a/NAPS2.Escl.Server/CompilerAttributes.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-// https://sergiopedri.medium.com/enabling-and-using-c-9-features-on-older-and-unsupported-runtimes-ce384d8debb
-// ReSharper disable once CheckNamespace
-
-namespace System.Runtime.CompilerServices
-{
- internal static class IsExternalInit
- {
- }
-
- /// Specifies that a type has required members or that a member is required.
- [AttributeUsage(
- AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property,
- AllowMultiple = false, Inherited = false)]
- internal sealed class RequiredMemberAttribute : Attribute
- {
- }
-
- ///
- /// Indicates that compiler support for a particular feature is required for the location where this attribute is applied.
- ///
- [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)]
- internal sealed class CompilerFeatureRequiredAttribute : Attribute
- {
- public CompilerFeatureRequiredAttribute(string featureName)
- {
- FeatureName = featureName;
- }
-
- ///
- /// The name of the compiler feature.
- ///
- public string FeatureName { get; }
-
- ///
- /// If true, the compiler can choose to allow access to the location where this attribute is applied if it does not understand .
- ///
- public bool IsOptional { get; init; }
-
- ///
- /// The used for the ref structs C# feature.
- ///
- public const string RefStructs = nameof(RefStructs);
-
- ///
- /// The used for the required members C# feature.
- ///
- public const string RequiredMembers = nameof(RequiredMembers);
- }
-}
-
-namespace System.Diagnostics.CodeAnalysis
-{
- ///
- /// Specifies that this constructor sets all required members for the current type, and callers
- /// do not need to set any required members themselves.
- ///
- [AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)]
- internal sealed class SetsRequiredMembersAttribute : Attribute
- {
- }
-}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/EsclApiController.cs b/NAPS2.Escl.Server/EsclApiController.cs
index ee4a313b54..b1286b5082 100644
--- a/NAPS2.Escl.Server/EsclApiController.cs
+++ b/NAPS2.Escl.Server/EsclApiController.cs
@@ -1,9 +1,10 @@
-using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Xml.Linq;
using EmbedIO;
using EmbedIO.Routing;
using EmbedIO.WebApi;
+using Microsoft.Extensions.Logging;
namespace NAPS2.Escl.Server;
@@ -12,89 +13,138 @@ internal class EsclApiController : WebApiController
private static readonly XNamespace ScanNs = EsclXmlHelper.ScanNs;
private static readonly XNamespace PwgNs = EsclXmlHelper.PwgNs;
- private readonly EsclServerConfig _serverConfig;
+ private readonly EsclDeviceConfig _deviceConfig;
private readonly EsclServerState _serverState;
+ private readonly EsclSecurityPolicy _securityPolicy;
+ private readonly ILogger _logger;
- internal EsclApiController(EsclServerConfig serverConfig, EsclServerState serverState)
+ internal EsclApiController(EsclDeviceConfig deviceConfig, EsclServerState serverState,
+ EsclSecurityPolicy securityPolicy, ILogger logger)
{
- _serverConfig = serverConfig;
+ _deviceConfig = deviceConfig;
_serverState = serverState;
+ _securityPolicy = securityPolicy;
+ _logger = logger;
}
[Route(HttpVerbs.Get, "/ScannerCapabilities")]
public async Task GetScannerCapabilities()
{
- var caps = _serverConfig.Capabilities;
+ var caps = _deviceConfig.Capabilities;
+ var protocol = _securityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps) ? "https" : "http";
+ var iconUri = caps.IconPng != null ? $"{protocol}://naps2-{caps.Uuid}.local.:{_deviceConfig.Port}/eSCL/icon.png" : "";
var doc =
EsclXmlHelper.CreateDocAsString(
new XElement(ScanNs + "ScannerCapabilities",
- new XElement(PwgNs + "Version", caps.Version), // TODO: Probably hard code version or something
+ new XElement(PwgNs + "Version", caps.Version),
new XElement(PwgNs + "MakeAndModel", caps.MakeAndModel),
new XElement(PwgNs + "SerialNumber", caps.SerialNumber),
- new XElement(ScanNs + "UUID", "0e468f6d-e5dc-4abe-8e9f-ad08d8546b0c"),
+ new XElement(ScanNs + "Manufacturer", caps.Manufacturer),
+ new XElement(ScanNs + "UUID", caps.Uuid),
new XElement(ScanNs + "AdminURI", ""),
- new XElement(ScanNs + "IconURI", ""),
- new XElement(ScanNs + "SettingProfiles",
- new XElement(ScanNs + "SettingProfile",
- new XAttribute("name", "p1"),
- new XElement(ScanNs + "ColorModes",
- new XElement(ScanNs + "ColorMode", "BlackAndWhite1"),
- new XElement(ScanNs + "ColorMode", "Grayscale8"),
- new XElement(ScanNs + "ColorMode", "RGB24")),
- new XElement(ScanNs + "DocumentFormats",
- new XElement(PwgNs + "DocumentFormat", "application/pdf"),
- new XElement(PwgNs + "DocumentFormat", "image/jpeg"),
- new XElement(PwgNs + "DocumentFormat", "image/png"),
- new XElement(ScanNs + "DocumentFormatExt", "application/pdf"),
- new XElement(ScanNs + "DocumentFormatExt", "image/jpeg"),
- new XElement(ScanNs + "DocumentFormatExt", "image/png")
- ),
- new XElement(ScanNs + "SupportedResolutions",
- new XElement(ScanNs + "DiscreteResolutions",
- new XElement(ScanNs + "DiscreteResolution",
- new XElement(ScanNs + "XResolution", "100"),
- new XElement(ScanNs + "YResolution", "100")))))),
+ new XElement(ScanNs + "IconURI", iconUri),
+ new XElement(ScanNs + "Naps2Extensions", "Progress;ErrorDetails;ShortTimeout;AnyDpi"),
new XElement(ScanNs + "Platen",
- new XElement(ScanNs + "PlatenInputCaps",
- new XElement(ScanNs + "MinWidth", "1"),
- new XElement(ScanNs + "MaxWidth", "3000"),
- new XElement(ScanNs + "MinHeight", "1"),
- new XElement(ScanNs + "MaxHeight", "3600"),
- new XElement(ScanNs + "MaxScanRegions", "1"),
- new XElement(ScanNs + "SettingProfiles",
- new XElement(ScanNs + "SettingProfile",
- new XAttribute("ref", "p1")))))));
+ new XElement(ScanNs + "PlatenInputCaps", GetCommonInputCaps())),
+ new XElement(ScanNs + "Adf",
+ new XElement(ScanNs + "AdfSimplexInputCaps", GetCommonInputCaps()),
+ new XElement(ScanNs + "AdfDuplexInputCaps", GetCommonInputCaps())),
+ new XElement(ScanNs + "CompressionFactorSupport",
+ new XElement(ScanNs + "Min", 0),
+ new XElement(ScanNs + "Max", 100),
+ new XElement(ScanNs + "Normal", 75),
+ new XElement(ScanNs + "Step", 1))));
Response.ContentType = "text/xml";
using var writer = new StreamWriter(HttpContext.OpenResponseStream());
await writer.WriteAsync(doc);
}
+ private object[] GetCommonInputCaps()
+ {
+ // TODO: After implementing scanner capabilities this should be scanner-specific
+ return
+ [
+ new XElement(ScanNs + "MinWidth", "1"),
+ new XElement(ScanNs + "MaxWidth", EsclInputCaps.DEFAULT_MAX_WIDTH),
+ new XElement(ScanNs + "MinHeight", "1"),
+ new XElement(ScanNs + "MaxHeight", EsclInputCaps.DEFAULT_MAX_HEIGHT),
+ new XElement(ScanNs + "MaxScanRegions", "1"),
+ new XElement(ScanNs + "SettingProfiles",
+ new XElement(ScanNs + "SettingProfile",
+ new XElement(ScanNs + "ColorModes",
+ new XElement(ScanNs + "ColorMode", "BlackAndWhite1"),
+ new XElement(ScanNs + "ColorMode", "Grayscale8"),
+ new XElement(ScanNs + "ColorMode", "RGB24")),
+ new XElement(ScanNs + "DocumentFormats",
+ new XElement(PwgNs + "DocumentFormat", "application/pdf"),
+ new XElement(PwgNs + "DocumentFormat", "image/jpeg"),
+ new XElement(PwgNs + "DocumentFormat", "image/png"),
+ new XElement(ScanNs + "DocumentFormatExt", "application/pdf"),
+ new XElement(ScanNs + "DocumentFormatExt", "image/jpeg"),
+ new XElement(ScanNs + "DocumentFormatExt", "image/png")
+ ),
+ new XElement(ScanNs + "SupportedResolutions",
+ new XElement(ScanNs + "DiscreteResolutions",
+ CreateResolution(100),
+ CreateResolution(150),
+ CreateResolution(200),
+ CreateResolution(300),
+ CreateResolution(400),
+ CreateResolution(600),
+ CreateResolution(800),
+ CreateResolution(1200),
+ CreateResolution(2400),
+ CreateResolution(4800)
+ ))))
+ ];
+ }
+
+ private XElement CreateResolution(int res) =>
+ new(ScanNs + "DiscreteResolution",
+ new XElement(ScanNs + "XResolution", res.ToString()),
+ new XElement(ScanNs + "YResolution", res.ToString()));
+
+ [Route(HttpVerbs.Get, "/icon.png")]
+ public async Task GetIcon()
+ {
+ if (_deviceConfig.Capabilities.IconPng != null)
+ {
+ Response.ContentType = "image/png";
+ using var stream = Response.OutputStream;
+ var buffer = _deviceConfig.Capabilities.IconPng;
+ await stream.WriteAsync(buffer, 0, buffer.Length);
+ }
+ else
+ {
+ Response.StatusCode = 404;
+ }
+ }
+
[Route(HttpVerbs.Get, "/ScannerStatus")]
public async Task GetScannerStatus()
{
var jobsElement = new XElement(ScanNs + "Jobs");
- foreach (var jobState in _serverState.Jobs.Values)
+ foreach (var jobInfo in _serverState.Jobs.OrderBy(x => x.LastUpdated.ElapsedMilliseconds))
{
jobsElement.Add(new XElement(ScanNs + "JobInfo",
- new XElement(PwgNs + "JobUri", $"/escl/ScanJobs/{jobState.Id}"),
- new XElement(PwgNs + "JobUuid", jobState.Id),
- new XElement(ScanNs + "Age", Math.Ceiling(jobState.LastUpdated.Elapsed.TotalSeconds)),
- new XElement(PwgNs + "ImagesCompleted",
- jobState.Status is JobStatus.Pending or JobStatus.Processing ? "0" : "1"),
- new XElement(PwgNs + "ImagesToTransfer", "1"),
- new XElement(PwgNs + "JobState", jobState.Status.ToString()),
+ new XElement(PwgNs + "JobUri", $"/eSCL/ScanJobs/{jobInfo.Id}"),
+ new XElement(PwgNs + "JobUuid", jobInfo.Id),
+ new XElement(ScanNs + "Age", Math.Ceiling(jobInfo.LastUpdated.Elapsed.TotalSeconds)),
+ new XElement(PwgNs + "ImagesCompleted", jobInfo.ImagesCompleted),
+ new XElement(PwgNs + "ImagesToTransfer", jobInfo.ImagesToTransfer),
+ new XElement(PwgNs + "JobState", jobInfo.State.ToString()),
new XElement(PwgNs + "JobStateReasons",
new XElement(PwgNs + "JobStateReason",
- jobState.Status == JobStatus.Processing ? "JobScanning" : "JobCompletedSuccessfully"))));
+ jobInfo.State == EsclJobState.Processing ? "JobScanning" : "JobCompletedSuccessfully"))));
}
+ var scannerState = _serverState.IsProcessing ? EsclScannerState.Processing : EsclScannerState.Idle;
+ var adfState = _serverState.IsProcessing ? EsclAdfState.ScannerAdfProcessing : EsclAdfState.ScannedAdfLoaded;
var doc =
EsclXmlHelper.CreateDocAsString(
new XElement(ScanNs + "ScannerStatus",
new XElement(PwgNs + "Version", "2.6"),
- new XElement(PwgNs + "State",
- _serverState.Jobs.Any(x => x.Value.Status is JobStatus.Pending or JobStatus.Processing)
- ? "Processing"
- : "Idle"),
+ new XElement(PwgNs + "State", scannerState),
+ new XElement(ScanNs + "AdfState", adfState),
jobsElement
));
Response.ContentType = "text/xml";
@@ -105,20 +155,44 @@ public async Task GetScannerStatus()
[Route(HttpVerbs.Post, "/ScanJobs")]
public void CreateScanJob()
{
- var jobState = JobState.CreateNewJob();
- _serverState.Jobs[jobState.Id] = jobState;
- Response.Headers.Add("Location", $"{Request.Url}/{jobState.Id}");
- Response.StatusCode = 201;
+ // TODO: Actually use job input for scan options
+ EsclScanSettings settings;
+ try
+ {
+ var doc = XDocument.Load(Request.InputStream);
+ settings = SettingsParser.Parse(doc);
+ }
+ catch (Exception)
+ {
+ Response.StatusCode = 400; // Bad request
+ return;
+ }
+ if (_serverState.IsProcessing)
+ {
+ Response.StatusCode = 503; // Service unavailable
+ return;
+ }
+ _serverState.IsProcessing = true;
+ var jobInfo = JobInfo.CreateNewJob(_serverState, _deviceConfig.CreateJob(settings));
+ _serverState.AddJob(jobInfo);
+ var uri = Request.Url;
+ if (Request.IsSecureConnection)
+ {
+ // Fix https://github.com/unosquare/embedio/issues/593
+ uri = new UriBuilder(uri) { Scheme = "https" }.Uri;
+ }
+ Response.Headers.Add("Access-Control-Expose-Headers", "Location");
+ Response.Headers.Add("Location", $"{uri}/{jobInfo.Id}");
+ Response.StatusCode = 201; // Created
}
[Route(HttpVerbs.Delete, "/ScanJobs/{jobId}")]
public void CancelScanJob(string jobId)
{
- if (_serverState.Jobs.TryGetValue(jobId, out var jobState) &&
- jobState.Status is JobStatus.Pending or JobStatus.Processing)
+ if (_serverState.TryGetJob(jobId, out var jobState) &&
+ jobState.State is EsclJobState.Pending or EsclJobState.Processing)
{
- jobState.Status = JobStatus.Canceled;
- jobState.LastUpdated = Stopwatch.StartNew();
+ jobState.Job.Cancel();
}
else
{
@@ -131,29 +205,153 @@ public void GetImageinfo(string jobId)
{
}
- [Route(HttpVerbs.Get, "/ScanJobs/{jobId}/NextDocument")]
- public void NextDocument(string jobId)
+ // This endpoint is a NAPS2-specific extension to the ESCL API.
+ // It gives a chunked response where each line is a double between 0 and 1 representing the current page progress.
+ // This endpoint should be called once before each call to NextDocument.
+ [Route(HttpVerbs.Get, "/ScanJobs/{jobId}/Progress")]
+ public async Task Progress(string jobId)
{
- if (_serverState.Jobs.TryGetValue(jobId, out var jobState) &&
- jobState.Status is JobStatus.Pending or JobStatus.Processing)
- {
- Response.Headers.Add("Content-Location", $"/escl/ScanJobs/{jobState.Id}/1");
- // Bypass https://github.com/unosquare/embedio/issues/510
- var field = Response.GetType().GetField("k__BackingField",
- BindingFlags.Instance | BindingFlags.NonPublic);
- field!.SetValue(Response, new Version(1, 1));
- Response.SendChunked = true;
- Response.ContentType = "image/jpeg";
- Response.ContentEncoding = null;
+ if (_serverState.TryGetJob(jobId, out var jobState) &&
+ jobState.State is EsclJobState.Pending or EsclJobState.Processing)
+ {
+ SetChunkedResponse();
using var stream = Response.OutputStream;
- var bytes = File.ReadAllBytes(@"C:\Devel\VS\NAPS2.Future\NAPS2.Sdk.Tests\Resources\dog.jpg");
- stream.Write(bytes, 0, bytes.Length);
- jobState.Status = JobStatus.Completed;
- jobState.LastUpdated = Stopwatch.StartNew();
+ await jobState.Job.WriteProgressTo(stream);
}
else
{
Response.StatusCode = 404;
}
}
+
+ // This endpoint is a NAPS2-specific extension to the ESCL API.
+ // The ESCL status-based model (where errors like "no paper in feeder" are encompassed by ScannerStatus that can be
+ // polled every few seconds) is a good match to a physical scanner but a poor match to NAPS2's model.
+ // Instead, we have a this ErrorDetails endpoint that we call when NextDocument returns a 500 error which gives us
+ // XML-based details for the exception that occurred.
+ [Route(HttpVerbs.Get, "/ScanJobs/{jobId}/ErrorDetails")]
+ public async Task ErrorDetails(string jobId)
+ {
+ if (_serverState.TryGetJob(jobId, out var jobState))
+ {
+ Response.ContentType = "text/xml";
+ using var stream = Response.OutputStream;
+ await jobState.Job.WriteErrorDetailsTo(stream);
+ }
+ else
+ {
+ Response.StatusCode = 404;
+ }
+ }
+
+ [Route(HttpVerbs.Get, "/ScanJobs/{jobId}/NextDocument")]
+ public async Task NextDocument(string jobId)
+ {
+ if (!CheckJobState(jobId, out var jobInfo))
+ {
+ return;
+ }
+
+ await jobInfo.NextDocumentLock.Take();
+ try
+ {
+ // Recheck job state in case it's been changed while we were waiting on the lock
+ if (!CheckJobState(jobId, out _))
+ {
+ return;
+ }
+ await WaitForAndWriteNextDocument(jobInfo);
+ }
+ finally
+ {
+ jobInfo.NextDocumentLock.Release();
+ }
+ }
+
+ private bool CheckJobState(string jobId, [NotNullWhen(true)] out JobInfo? jobInfo)
+ {
+ if (!_serverState.TryGetJob(jobId, out jobInfo))
+ {
+ Response.StatusCode = 404;
+ return false;
+ }
+ if (jobInfo.State == EsclJobState.Aborted)
+ {
+ Response.StatusCode = 500;
+ return false;
+ }
+ if (jobInfo.State is not (EsclJobState.Pending or EsclJobState.Processing))
+ {
+ Response.StatusCode = 404;
+ return false;
+ }
+ return true;
+ }
+
+ private async Task WaitForAndWriteNextDocument(JobInfo jobInfo)
+ {
+ try
+ {
+ // If we already have a document (i.e. if a connection error occured during the previous NextDocument
+ // request), we stay at that same document and don't advance
+ var cts = new CancellationTokenSource();
+ cts.CancelAfter(1000);
+ jobInfo.NextDocumentReady = jobInfo.NextDocumentReady || await jobInfo.Job.WaitForNextDocument(cts.Token);
+ }
+ catch (TaskCanceledException)
+ {
+ _logger.LogDebug("Waiting for document timed out, returning 503");
+ // Tell the client to retry after 2s
+ Response.Headers.Add("Retry-After", "2");
+ Response.StatusCode = 503;
+ return;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "ESCL server error waiting for document");
+ jobInfo.TransitionState(EsclJobState.Processing, EsclJobState.Aborted);
+ Response.StatusCode = 500;
+ return;
+ }
+
+ // At this point either we have a document and can respond with it, or we have no documents left and should 404
+ if (jobInfo.NextDocumentReady)
+ {
+ try
+ {
+ Response.Headers.Add("Content-Location", $"/eSCL/ScanJobs/{jobInfo.Id}/1");
+ SetChunkedResponse();
+ Response.ContentType = jobInfo.Job.ContentType;
+ Response.ContentEncoding = null;
+ using var stream = Response.OutputStream;
+ await jobInfo.Job.WriteDocumentTo(stream);
+ jobInfo.NextDocumentReady = false;
+ jobInfo.TransferredDocument();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "ESCL server error writing document");
+ // We don't transition state here, as the assumption is that the problem was network-related and the
+ // client will retry
+ Response.StatusCode = 500;
+ }
+ }
+ else
+ {
+ jobInfo.TransitionState(EsclJobState.Processing, EsclJobState.Completed);
+ Response.StatusCode = 404;
+ }
+ }
+
+ private void SetChunkedResponse()
+ {
+ // Bypass https://github.com/unosquare/embedio/issues/510
+ var field = Response.GetType().GetField("k__BackingField",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ if (field != null)
+ {
+ field.SetValue(Response, new Version(1, 1));
+ }
+ Response.SendChunked = true;
+ }
}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/EsclServer.cs b/NAPS2.Escl.Server/EsclServer.cs
index caf4e73035..dec0d9b1e4 100644
--- a/NAPS2.Escl.Server/EsclServer.cs
+++ b/NAPS2.Escl.Server/EsclServer.cs
@@ -1,45 +1,177 @@
+using System.Security.Cryptography.X509Certificates;
using EmbedIO;
using EmbedIO.WebApi;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
namespace NAPS2.Escl.Server;
-public class EsclServer : IDisposable
+public class EsclServer : IEsclServer
{
- private readonly EsclServerConfig _serverConfig;
- private readonly EsclServerState _serverState = new();
- private readonly CancellationTokenSource _cts = new();
- private WebServer? _server;
+ static EsclServer()
+ {
+ Swan.Logging.Logger.NoLogging();
+ }
+
+ private readonly Dictionary _devices = new();
+ private bool _started;
+ private CancellationTokenSource? _cts;
+
+ public EsclSecurityPolicy SecurityPolicy { get; set; }
- public EsclServer(EsclServerConfig serverConfig)
+ public X509Certificate2? Certificate { get; set; }
+
+ public ILogger Logger { get; set; } = NullLogger.Instance;
+
+ public void AddDevice(EsclDeviceConfig deviceConfig)
{
- _serverConfig = serverConfig;
+ var deviceCtx = new DeviceContext(deviceConfig);
+ _devices[deviceConfig] = deviceCtx;
+ if (_started)
+ {
+ Task.Run(() => StartServerAndAdvertise(deviceCtx));
+ }
}
- public int Port { get; set; } = 9898;
+ public void RemoveDevice(EsclDeviceConfig deviceConfig)
+ {
+ var deviceCtx = _devices[deviceConfig];
+ if (_started)
+ {
+ deviceCtx.StartTask?.ContinueWith(_ => deviceCtx.Advertiser.Dispose());
+ }
+ deviceCtx.Cts.Cancel();
+ _devices.Remove(deviceConfig);
+ }
- public void Start()
+ public async Task Start()
{
- if (_server != null)
+ if (_started)
{
- throw new InvalidOperationException();
+ return;
}
- var url = $"http://+:{Port}/";
- _server = new WebServer(o => o
+ if (SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps) &&
+ SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerDisableHttps))
+ {
+ throw new EsclSecurityPolicyViolationException(
+ $"EsclSecurityPolicy of {SecurityPolicy} is inconsistent");
+ }
+ if (SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireTrustedCertificate) && Certificate == null)
+ {
+ throw new EsclSecurityPolicyViolationException(
+ $"EsclSecurityPolicy of {SecurityPolicy} needs a certificate to be specified");
+ }
+ _started = true;
+ _cts = new CancellationTokenSource();
+
+ // Try to generate a self-signed certificate if the caller hasn't provided one
+ if (!SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerDisableHttps) && Certificate == null)
+ {
+ await Task.Run(() => Certificate = CertificateHelper.GenerateSelfSignedCertificate(Logger));
+ }
+ if (SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps) && Certificate == null)
+ {
+ throw new EsclSecurityPolicyViolationException(
+ $"EsclSecurityPolicy of {SecurityPolicy} needs a certificate to be specified");
+ }
+
+ var tasks = new List();
+ foreach (var device in _devices.Keys)
+ {
+ var deviceCtx = _devices[device];
+ deviceCtx.StartTask = Task.Run(() => StartServerAndAdvertise(deviceCtx));
+ tasks.Add(deviceCtx.StartTask);
+ }
+ await Task.WhenAll(tasks);
+ }
+
+ private async Task StartServerAndAdvertise(DeviceContext deviceCtx)
+ {
+ var cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token, deviceCtx.Cts.Token).Token;
+ // Try to run the server with the port specified in the EsclDeviceConfig first. If that fails, try random ports
+ // instead, and store the actually-used port back in EsclDeviceConfig so it can be advertised correctly.
+ bool hasHttp = !SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps);
+ bool hasHttps = !SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerDisableHttps) && Certificate != null;
+ if (hasHttp)
+ {
+ await PortFinder.RunWithSpecifiedOrRandomPort(deviceCtx.Config.Port, async port =>
+ {
+ await StartServer(deviceCtx, port, false, cancelToken);
+ deviceCtx.Config.Port = port;
+ }, cancelToken);
+ }
+ if (hasHttps)
+ {
+ await PortFinder.RunWithSpecifiedOrRandomPort(deviceCtx.Config.TlsPort, async tlsPort =>
+ {
+ await StartServer(deviceCtx, tlsPort, true, cancelToken);
+ deviceCtx.Config.TlsPort = tlsPort;
+ }, cancelToken);
+ }
+ deviceCtx.Advertiser.AdvertiseDevice(deviceCtx.Config, hasHttp, hasHttps);
+ }
+
+ private async Task StartServer(DeviceContext deviceCtx, int port, bool tls, CancellationToken cancelToken)
+ {
+ var protocol = tls ? "https" : "http";
+ var url = $"{protocol}://+:{port}/";
+ deviceCtx.ServerState = new EsclServerState(Logger);
+ var server = new WebServer(o => o
.WithMode(HttpListenerMode.EmbedIO)
- .WithUrlPrefix(url))
- .WithWebApi("/escl", m => m.WithController(() => new EsclApiController(_serverConfig, _serverState)));
- // _server.HandleHttpException(async (_, _) => { });
- _server.StateChanged += ServerOnStateChanged;
- // TODO: This might block on tasks, maybe copy impl but async
- _server.Start(_cts.Token);
+ .WithUrlPrefix(url)
+ .WithCertificate((tls ? Certificate : null)!))
+ .HandleUnhandledException(UnhandledServerException);
+ if (SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerAllowAnyOrigin))
+ {
+ server.WithCors();
+ }
+ server.WithWebApi("/eSCL",
+ m => m.WithController(() =>
+ new EsclApiController(deviceCtx.Config, deviceCtx.ServerState, SecurityPolicy, Logger)));
+ await server.StartAsync(cancelToken);
+ }
+
+ private Task UnhandledServerException(IHttpContext ctx, Exception ex)
+ {
+ Logger.LogError(ex, "Unhandled ESCL server error");
+ return Task.CompletedTask;
}
- private void ServerOnStateChanged(object sender, WebServerStateChangedEventArgs e)
+ public Task Stop()
{
+ if (!_started)
+ {
+ return Task.CompletedTask;
+ }
+ _started = false;
+
+ _cts!.Cancel();
+ var tasks = new List();
+ foreach (var device in _devices.Keys)
+ {
+ var deviceCtx = _devices[device];
+ if (deviceCtx.StartTask != null)
+ {
+ tasks.Add(deviceCtx.StartTask.ContinueWith(_ => deviceCtx.Advertiser.UnadvertiseDevice(device)));
+ }
+ }
+ _cts = null;
+ return Task.WhenAll(tasks);
}
public void Dispose()
{
- _cts.Cancel();
+ if (_started)
+ {
+ Stop();
+ }
+ }
+
+ private record DeviceContext(EsclDeviceConfig Config)
+ {
+ public MdnsAdvertiser Advertiser { get; } = new();
+ public CancellationTokenSource Cts { get; } = new();
+ public Task? StartTask { get; set; }
+ public EsclServerState? ServerState { get; set; }
}
}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/EsclServerConfig.cs b/NAPS2.Escl.Server/EsclServerConfig.cs
deleted file mode 100644
index 41592e9ba1..0000000000
--- a/NAPS2.Escl.Server/EsclServerConfig.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace NAPS2.Escl.Server;
-
-public class EsclServerConfig
-{
- public required EsclCapabilities Capabilities { get; init; }
-}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/EsclServerState.cs b/NAPS2.Escl.Server/EsclServerState.cs
index 8f7727177f..93dbc4d993 100644
--- a/NAPS2.Escl.Server/EsclServerState.cs
+++ b/NAPS2.Escl.Server/EsclServerState.cs
@@ -1,6 +1,61 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Logging;
+
namespace NAPS2.Escl.Server;
internal class EsclServerState
{
- public Dictionary Jobs { get; } = new();
+ private const int CLEANUP_INTERVAL = 10_000;
+
+ private readonly ILogger _logger;
+
+ private readonly Dictionary _jobDict = new();
+ private Timer? _cleanupTimer;
+
+ public EsclServerState(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public bool IsProcessing { get; set; }
+
+ public IEnumerable Jobs => _jobDict.Values.ToList();
+
+ public void AddJob(JobInfo jobInfo)
+ {
+ lock (this)
+ {
+ _jobDict.Add(jobInfo.Id, jobInfo);
+ _cleanupTimer ??= new Timer(Cleanup, null, CLEANUP_INTERVAL, CLEANUP_INTERVAL);
+ }
+ }
+
+ public bool TryGetJob(string id, [MaybeNullWhen(false)] out JobInfo jobInfo)
+ {
+ lock (this)
+ {
+ return _jobDict.TryGetValue(id, out jobInfo);
+ }
+ }
+
+ private void Cleanup(object? state)
+ {
+ lock (this)
+ {
+ foreach (var jobInfo in Jobs)
+ {
+ if (jobInfo.IsEligibleForCleanup)
+ {
+ _logger.LogDebug($"Cleaning up job {jobInfo.Id} (state {jobInfo.State}, last updated {jobInfo.LastUpdated.Elapsed.TotalSeconds:F1} seconds ago)");
+ _jobDict.Remove(jobInfo.Id);
+ jobInfo.Job.Dispose();
+ if (_jobDict.Count == 0)
+ {
+ _cleanupTimer?.Dispose();
+ _cleanupTimer = null;
+ }
+ }
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/FakeEsclScanJob.cs b/NAPS2.Escl.Server/FakeEsclScanJob.cs
new file mode 100644
index 0000000000..c7dbcdb018
--- /dev/null
+++ b/NAPS2.Escl.Server/FakeEsclScanJob.cs
@@ -0,0 +1,35 @@
+namespace NAPS2.Escl.Server;
+
+internal class FakeEsclScanJob : IEsclScanJob
+{
+ private Action? _callback;
+
+ public string ContentType => "image/jpeg";
+
+ public void Cancel()
+ {
+ _callback?.Invoke(StatusTransition.CancelJob);
+ }
+
+ public void RegisterStatusTransitionCallback(Action callback)
+ {
+ _callback = callback;
+ }
+
+ public Task WaitForNextDocument(CancellationToken cancelToken) => Task.FromResult(true);
+
+ public async Task WriteDocumentTo(Stream stream)
+ {
+ var bytes = File.ReadAllBytes(@"C:\Devel\VS\NAPS2\NAPS2.Sdk.Tests\Resources\dog.jpg");
+ await stream.WriteAsync(bytes, 0, bytes.Length);
+ _callback?.Invoke(StatusTransition.ScanComplete);
+ }
+
+ public Task WriteProgressTo(Stream stream) => Task.CompletedTask;
+
+ public Task WriteErrorDetailsTo(Stream stream) => Task.CompletedTask;
+
+ public void Dispose()
+ {
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/JobInfo.cs b/NAPS2.Escl.Server/JobInfo.cs
new file mode 100644
index 0000000000..6bd06cf7cb
--- /dev/null
+++ b/NAPS2.Escl.Server/JobInfo.cs
@@ -0,0 +1,105 @@
+using System.Diagnostics;
+
+namespace NAPS2.Escl.Server;
+
+internal class JobInfo
+{
+ public static JobInfo CreateNewJob(EsclServerState serverState, IEsclScanJob job)
+ {
+ var jobInfo = new JobInfo
+ {
+ Id = Guid.NewGuid().ToString("D"),
+ State = EsclJobState.Processing,
+ LastUpdated = Stopwatch.StartNew(),
+ Job = job
+ };
+ job.RegisterStatusTransitionCallback(transition =>
+ {
+ if (transition == StatusTransition.CancelJob)
+ {
+ jobInfo.TransitionState(EsclJobState.Processing, EsclJobState.Canceled);
+ }
+ if (transition == StatusTransition.AbortJob)
+ {
+ jobInfo.TransitionState(EsclJobState.Processing, EsclJobState.Aborted);
+ }
+ if (transition == StatusTransition.PageComplete)
+ {
+ jobInfo.NewImageToTransfer();
+ }
+ if (transition == StatusTransition.ScanComplete)
+ {
+ serverState.IsProcessing = false;
+ jobInfo.IsScanComplete = true;
+ }
+ });
+ return jobInfo;
+ }
+
+ public required string Id { get; init; }
+
+ public required EsclJobState State { get; set; }
+
+ public int ImagesCompleted { get; set; }
+
+ public int ImagesToTransfer { get; set; }
+
+ public required Stopwatch LastUpdated { get; set; }
+
+ public required IEsclScanJob Job { get; set; }
+
+ public SimpleAsyncLock NextDocumentLock { get; } = new();
+
+ public bool NextDocumentReady { get; set; }
+
+ // This is different than EsclJobState.Completed; the ESCL state only transitions to completed once the client has
+ // finished querying for documents. IsScanComplete is set to true immediately after the physical scan operation is
+ // done.
+ public bool IsScanComplete { get; private set; }
+
+ private bool StateIsTerminal => State is EsclJobState.Completed or EsclJobState.Aborted or EsclJobState.Canceled;
+
+ public bool IsEligibleForCleanup => IsScanComplete &&
+ (StateIsTerminal && LastUpdated.Elapsed > TimeSpan.FromMinutes(1) ||
+ LastUpdated.Elapsed > TimeSpan.FromMinutes(5));
+
+ public void TransitionState(EsclJobState precondition, EsclJobState newState)
+ {
+ lock (this)
+ {
+ if (State == precondition)
+ {
+ State = newState;
+ LastUpdated = Stopwatch.StartNew();
+ }
+ }
+ }
+
+ public void NewImageToTransfer()
+ {
+ lock (this)
+ {
+ ImagesToTransfer++;
+ LastUpdated = Stopwatch.StartNew();
+ }
+ }
+
+ public void TransferredDocument()
+ {
+ lock (this)
+ {
+ if (Job.ContentType == "application/pdf")
+ {
+ // Assume all images transferred at once
+ ImagesCompleted += ImagesToTransfer;
+ ImagesToTransfer = 0;
+ }
+ else
+ {
+ ImagesCompleted++;
+ ImagesToTransfer--;
+ }
+ LastUpdated = Stopwatch.StartNew();
+ }
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/JobState.cs b/NAPS2.Escl.Server/JobState.cs
deleted file mode 100644
index 4184d8b592..0000000000
--- a/NAPS2.Escl.Server/JobState.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using System.Diagnostics;
-
-namespace NAPS2.Escl.Server;
-
-internal class JobState
-{
- public static JobState CreateNewJob()
- {
- return new JobState
- {
- Id = Guid.NewGuid().ToString("D"),
- Status = JobStatus.Processing,
- LastUpdated = Stopwatch.StartNew()
- };
- }
-
- public required string Id { get; init; }
-
- public required JobStatus Status { get; set; }
-
- public required Stopwatch LastUpdated { get; set; }
-}
-
-internal enum JobStatus
-{
- Pending,
- Processing,
- Completed,
- Canceled,
- Aborted
-}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/LICENSE b/NAPS2.Escl.Server/LICENSE
new file mode 100644
index 0000000000..099a807a65
--- /dev/null
+++ b/NAPS2.Escl.Server/LICENSE
@@ -0,0 +1,518 @@
+NAPS2.Escl
+https://www.github.com/cyanfish/naps2/
+
+Copyright 2009-2025 NAPS2 Contributors
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+Lesser General Public License for more details.
+
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it. You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+ When we speak of free software, we are referring to freedom of use,
+not price. Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+ To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights. These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ To protect each distributor, we want to make it very clear that
+there is no warranty for the free library. Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+ Finally, software patents pose a constant threat to the existence of
+any free program. We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder. Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+ Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License. This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License. We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+ When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library. The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom. The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+ We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License. It also provides other free software developers Less
+of an advantage over competing non-free programs. These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries. However, the Lesser license provides advantages in certain
+special circumstances.
+
+ For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard. To achieve this, non-free programs must be
+allowed to use the library. A more frequent case is that a free
+library does the same job as widely used non-free libraries. In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+ In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software. For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+ Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (1) uses at run time a
+ copy of the library already present on the user's computer system,
+ rather than copying library functions into the executable, and (2)
+ will operate properly with a modified version of the library, if
+ the user installs one, as long as the modified version is
+ interface-compatible with the version that the work was made with.
+
+ c) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ d) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ e) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Libraries
+
+ If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change. You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+ To apply these terms, attach the following notices to the library. It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, write to the Free Software
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the
+ library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+ , 1 April 1990
+ Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/NAPS2.Escl.Server/MdnsAdvertiser.cs b/NAPS2.Escl.Server/MdnsAdvertiser.cs
index 8bae6fc842..17958f7767 100644
--- a/NAPS2.Escl.Server/MdnsAdvertiser.cs
+++ b/NAPS2.Escl.Server/MdnsAdvertiser.cs
@@ -1,31 +1,151 @@
using Makaretu.Dns;
+using Makaretu.Dns.Resolving;
namespace NAPS2.Escl.Server;
public class MdnsAdvertiser : IDisposable
{
- private ServiceDiscovery? _sd;
+ private readonly Dictionary _serviceProfiles = new();
+ private readonly Dictionary _serviceProfiles2 = new();
- public void Advertise()
+ // Initializing ServiceDiscovery is slow (it queries network interfaces) so use lazily
+ private readonly Lazy _sd = new(() => new ServiceDiscovery());
+
+ private ServiceDiscovery ServiceDiscovery => _sd.Value;
+
+ public void AdvertiseDevice(EsclDeviceConfig deviceConfig, bool hasHttp, bool hasHttps)
+ {
+ var caps = deviceConfig.Capabilities;
+ if (caps.Uuid == null)
+ {
+ throw new ArgumentException("UUID must be specified");
+ }
+ if (!hasHttp && !hasHttps)
+ {
+ return;
+ }
+ var name = caps.MakeAndModel;
+
+ // HTTP+HTTPS should be handled by responding with the relevant records for both _uscan and _uscans when either
+ // is queried. This isn't handled out-of-the-box by the MDNS library so we need to do some extra work.
+ var httpProfile = new ServiceProfile(name, "_uscan._tcp", (ushort) deviceConfig.Port);
+ var httpsProfile = new ServiceProfile(name, "_uscans._tcp", (ushort) deviceConfig.TlsPort);
+ // If only one of HTTP or HTTPS is enabled, then we use that as the service. If both are enabled, we use the
+ // HTTP service as a baseline and then hack in the HTTPS records later.
+ var service = hasHttp ? httpProfile : httpsProfile;
+
+ var domain = $"naps2-{caps.Uuid}";
+ var hostName = DomainName.Join(domain, service.Domain);
+
+ // Replace the default TXT record with the first TXT record (HTTP if used, HTTPS otherwise)
+ service.Resources.RemoveAll(x => x is TXTRecord);
+ service.Resources.Add(CreateTxtRecord(deviceConfig, hasHttp, service, caps, name));
+
+ // NSEC records are recommended by RFC6762 to annotate that there's no more info for this host
+ service.Resources.Add(new NSECRecord
+ { Name = hostName, NextOwnerName = hostName, Types = [DnsType.A, DnsType.AAAA] });
+
+ if (hasHttp && hasHttps)
+ {
+ // If both HTTP and HTTPS are enabled, we add the extra HTTPS records here
+ service.Resources.Add(new PTRRecord
+ {
+ Name = httpsProfile.QualifiedServiceName,
+ DomainName = httpsProfile.FullyQualifiedName
+ });
+ service.Resources.Add(new SRVRecord
+ {
+ Name = httpsProfile.FullyQualifiedName,
+ Port = (ushort) deviceConfig.TlsPort
+ });
+ service.Resources.Add(CreateTxtRecord(deviceConfig, false, httpsProfile, caps, name));
+ }
+
+ // The default HostName isn't correct, it should be "naps2-uuid.local" (the actual host) instead of
+ // "name._uscan.local" (the service name)
+ service.HostName = hostName;
+
+ // Send the full set of HTTP/HTTPS records to anyone currently listening
+ ServiceDiscovery.Announce(service);
+
+ // Set up to respond to _uscan/_uscans queries with our records.
+ ServiceDiscovery.Advertise(service);
+ if (hasHttp && hasHttps)
+ {
+ // Add _uscans to the available services (_uscan was already mapped in Advertise())
+ ServiceDiscovery.NameServer.Catalog[ServiceDiscovery.ServiceName].Resources.Add(new PTRRecord
+ { Name = ServiceDiscovery.ServiceName, DomainName = httpsProfile.QualifiedServiceName });
+ // Cross-reference _uscan to the HTTPS records
+ ServiceDiscovery.NameServer.Catalog[httpProfile.QualifiedServiceName].Resources.Add(new PTRRecord
+ { Name = httpsProfile.QualifiedServiceName, DomainName = httpsProfile.FullyQualifiedName });
+ // Add a _uscans reference with both HTTP and HTTPS records
+ ServiceDiscovery.NameServer.Catalog[httpsProfile.QualifiedServiceName] = new Node
+ {
+ Name = httpsProfile.QualifiedServiceName, Authoritative = true, Resources =
+ {
+ new PTRRecord
+ { Name = httpProfile.QualifiedServiceName, DomainName = httpProfile.FullyQualifiedName },
+ new PTRRecord
+ { Name = httpsProfile.QualifiedServiceName, DomainName = httpsProfile.FullyQualifiedName }
+ }
+ };
+ }
+
+ // Persist the profiles so they can be unadvertised later
+ _serviceProfiles.Add(caps.Uuid, service);
+ if (hasHttp && hasHttps)
+ {
+ _serviceProfiles2.Add(caps.Uuid, httpsProfile);
+ }
+ }
+
+ private static TXTRecord CreateTxtRecord(EsclDeviceConfig deviceConfig, bool http, ServiceProfile service,
+ EsclCapabilities caps, string? name)
+ {
+ var record = new TXTRecord();
+ record.Name = service.FullyQualifiedName;
+ record.Strings.Add("txtvers=1");
+ record.Strings.Add("Vers=2.0"); // TODO: verify
+ if (deviceConfig.Capabilities.IconPng != null)
+ {
+ record.Strings.Add(
+ http
+ ? $"representation=http://naps2-{caps.Uuid}.local.:{deviceConfig.Port}/eSCL/icon.png"
+ : $"representation=https://naps2-{caps.Uuid}.local.:{deviceConfig.TlsPort}/eSCL/icon.png");
+ }
+ record.Strings.Add("rs=eSCL");
+ record.Strings.Add($"ty={name}");
+ record.Strings.Add("pdl=application/pdf,image/jpeg,image/png");
+ // TODO: Actual adf/duplex, etc.
+ record.Strings.Add($"uuid={caps.Uuid}");
+ record.Strings.Add("cs=color,grayscale,binary");
+ record.Strings.Add("is=platen"); // and ,adf
+ record.Strings.Add("duplex=F");
+ return record;
+ }
+
+ public void UnadvertiseDevice(EsclDeviceConfig deviceConfig)
{
- var service = new ServiceProfile("NAPS2-Canon MP495", "_uscan._tcp", 9898);
- service.AddProperty("txtvers", "1");
- service.AddProperty("Vers", "2.0"); // TODO: verify
- service.AddProperty("rs", "escl");
- service.AddProperty("ty", "NAPS2-Canon MP495");
- service.AddProperty("pdl", "application/pdf,image/jpeg,image/png");
- service.AddProperty("uuid", "0e468f6d-e5dc-4abe-8e9f-ad08d8546b0c");
- service.AddProperty("cs", "color,grayscale,binary");
- service.AddProperty("is", "platen"); // and ,adf
- service.AddProperty("duplex", "F");
- _sd = new ServiceDiscovery();
- _sd.Announce(service);
- _sd.Advertise(service);
+ var uuid = deviceConfig.Capabilities.Uuid;
+ if (uuid == null)
+ {
+ throw new ArgumentException("UUID must be specified");
+ }
+ if (_serviceProfiles.ContainsKey(uuid))
+ {
+ ServiceDiscovery.Unadvertise(_serviceProfiles[uuid]);
+ _serviceProfiles.Remove(uuid);
+ }
+ if (_serviceProfiles2.ContainsKey(uuid))
+ {
+ ServiceDiscovery.Unadvertise(_serviceProfiles2[uuid]);
+ _serviceProfiles2.Remove(uuid);
+ }
}
public void Dispose()
{
- _sd?.Unadvertise();
- _sd?.Dispose();
+ ServiceDiscovery.Unadvertise();
+ ServiceDiscovery.Dispose();
}
}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/NAPS2.Escl.Server.csproj b/NAPS2.Escl.Server/NAPS2.Escl.Server.csproj
index 2bbedf87cb..fb0483f059 100644
--- a/NAPS2.Escl.Server/NAPS2.Escl.Server.csproj
+++ b/NAPS2.Escl.Server/NAPS2.Escl.Server.csproj
@@ -1,14 +1,21 @@
- net6.0;net462;netstandard2.0
+ net6;net8;net462;netstandard2.0
enable
enable
- 11
+ 12
+
+ NAPS2.Escl.Server
+ NAPS2.Escl.Server
+ ESCL server for NAPS2.Sdk.
+ naps2 escl
+
+
-
+
diff --git a/NAPS2.Escl.Server/PortFinder.cs b/NAPS2.Escl.Server/PortFinder.cs
new file mode 100644
index 0000000000..59256936e6
--- /dev/null
+++ b/NAPS2.Escl.Server/PortFinder.cs
@@ -0,0 +1,43 @@
+namespace NAPS2.Escl.Server;
+
+internal static class PortFinder
+{
+ private const int MAX_PORT_TRIES = 5;
+ private const int RANDOM_PORT_MIN = 10001;
+ private const int RANDOM_PORT_MAX = 19999;
+
+ public static async Task RunWithSpecifiedOrRandomPort(int defaultPort, Func portTaskFunc,
+ CancellationToken cancelToken)
+ {
+ int port = defaultPort;
+ int retries = 0;
+ var random = new Random();
+ if (port == 0)
+ {
+ port = RandomPort(random);
+ }
+ while (true)
+ {
+ try
+ {
+ await portTaskFunc(port);
+ break;
+ }
+ catch (Exception)
+ {
+ if (cancelToken.IsCancellationRequested)
+ {
+ break;
+ }
+ retries++;
+ port = RandomPort(random);
+ if (retries > MAX_PORT_TRIES)
+ {
+ throw;
+ }
+ }
+ }
+ }
+
+ private static int RandomPort(Random random) => random.Next(RANDOM_PORT_MIN, RANDOM_PORT_MAX + 1);
+}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/SettingsParser.cs b/NAPS2.Escl.Server/SettingsParser.cs
new file mode 100644
index 0000000000..8d2a8a249b
--- /dev/null
+++ b/NAPS2.Escl.Server/SettingsParser.cs
@@ -0,0 +1,35 @@
+using System.Xml.Linq;
+
+namespace NAPS2.Escl.Server;
+
+internal static class SettingsParser
+{
+ private static readonly XNamespace ScanNs = EsclXmlHelper.ScanNs;
+ private static readonly XNamespace PwgNs = EsclXmlHelper.PwgNs;
+
+ public static EsclScanSettings Parse(XDocument doc)
+ {
+ var root = doc.Root;
+ if (root?.Name != ScanNs + "ScanSettings")
+ {
+ throw new InvalidOperationException("Unexpected root element: " + doc.Root?.Name);
+ }
+ var scanRegion = root!.Element(PwgNs + "ScanRegions")?.Elements(PwgNs + "ScanRegion").FirstOrDefault();
+ return new EsclScanSettings
+ {
+ // TODO: Handle intents?
+ InputSource = ParseHelper.MaybeParseEnum(root.Element(PwgNs + "InputSource"), EsclInputSource.Platen),
+ ColorMode = ParseHelper.MaybeParseEnum(root.Element(ScanNs + "ColorMode"), EsclColorMode.RGB24),
+ DocumentFormat = root.Element(ScanNs + "DocumentFormatExt")?.Value ??
+ root.Element(PwgNs + "DocumentFormat")?.Value,
+ Duplex = root.Element(ScanNs + "Duplex")?.Value == "true",
+ XResolution = ParseHelper.MaybeParseInt(root.Element(ScanNs + "XResolution")) ?? 0,
+ YResolution = ParseHelper.MaybeParseInt(root.Element(ScanNs + "YResolution")) ?? 0,
+ Width = ParseHelper.MaybeParseInt(scanRegion?.Element(PwgNs + "Width")) ?? 0,
+ Height = ParseHelper.MaybeParseInt(scanRegion?.Element(PwgNs + "Height")) ?? 0,
+ XOffset = ParseHelper.MaybeParseInt(scanRegion?.Element(PwgNs + "XOffset")) ?? 0,
+ YOffset = ParseHelper.MaybeParseInt(scanRegion?.Element(PwgNs + "YOffset")) ?? 0,
+ CompressionFactor = ParseHelper.MaybeParseInt(root.Element(ScanNs + "CompressionFactor"))
+ };
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/SimpleAsyncLock.cs b/NAPS2.Escl.Server/SimpleAsyncLock.cs
new file mode 100644
index 0000000000..0e54712652
--- /dev/null
+++ b/NAPS2.Escl.Server/SimpleAsyncLock.cs
@@ -0,0 +1,37 @@
+namespace NAPS2.Escl.Server;
+
+internal class SimpleAsyncLock
+{
+ private readonly Queue> _listeners = new();
+ private bool _isTaken;
+
+ public Task Take()
+ {
+ lock (this)
+ {
+ if (!_isTaken)
+ {
+ _isTaken = true;
+ return Task.CompletedTask;
+ }
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _listeners.Enqueue(tcs);
+ return tcs.Task;
+ }
+ }
+
+ public void Release()
+ {
+ lock (this)
+ {
+ if (_listeners.Count > 0)
+ {
+ _listeners.Dequeue().SetResult(true);
+ }
+ else
+ {
+ _isTaken = false;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl.Server/WebServerExtensions.cs b/NAPS2.Escl.Server/WebServerExtensions.cs
new file mode 100644
index 0000000000..fdb300b3e7
--- /dev/null
+++ b/NAPS2.Escl.Server/WebServerExtensions.cs
@@ -0,0 +1,30 @@
+using EmbedIO;
+
+namespace NAPS2.Escl.Server;
+
+internal static class WebServerExtensions
+{
+ public static async Task StartAsync(this WebServer server, CancellationToken cancelToken = default)
+ {
+ var startedTcs = new TaskCompletionSource();
+ server.StateChanged += (_, args) =>
+ {
+ if (args.NewState == WebServerState.Listening)
+ {
+ startedTcs.TrySetResult(true);
+ }
+ };
+ _ = server.RunAsync(cancelToken).ContinueWith(t =>
+ {
+ if (t.IsFaulted)
+ {
+ startedTcs.TrySetException(t.Exception!);
+ }
+ else
+ {
+ startedTcs.TrySetCanceled();
+ }
+ });
+ await startedTcs.Task;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl.Tests/AdvertiseTests.cs b/NAPS2.Escl.Tests/AdvertiseTests.cs
index 8e78582656..5883d6e3f1 100644
--- a/NAPS2.Escl.Tests/AdvertiseTests.cs
+++ b/NAPS2.Escl.Tests/AdvertiseTests.cs
@@ -1,5 +1,6 @@
using System.Diagnostics;
using NAPS2.Escl.Server;
+using NSubstitute;
using Xunit;
namespace NAPS2.Escl.Tests;
@@ -9,21 +10,23 @@ public class AdvertiseTests
[Fact]
public async Task Advertise()
{
- using var server = new EsclServer(new EsclServerConfig
+ var job = Substitute.For();
+ using var server = new EsclServer();
+ server.AddDevice(new EsclDeviceConfig
{
- Capabilities = new EsclCapabilities()
+ Capabilities = new EsclCapabilities
{
Version = "2.6",
MakeAndModel = "HP Blah",
- SerialNumber = "123abc"
- }
+ SerialNumber = "123abc",
+ Uuid = Guid.NewGuid().ToString("D")
+ },
+ CreateJob = _ => job
});
- server.Start();
- using var advertiser = new MdnsAdvertiser();
- advertiser.Advertise();
+ await server.Start();
if (Debugger.IsAttached)
{
- for (int i = 0; i < 10; i++)
+ for (int i = 0; i < 100; i++)
{
await Task.Delay(5000);
}
diff --git a/NAPS2.Escl.Tests/CapabilitiesParserTests.cs b/NAPS2.Escl.Tests/CapabilitiesParserTests.cs
new file mode 100644
index 0000000000..cb867b0262
--- /dev/null
+++ b/NAPS2.Escl.Tests/CapabilitiesParserTests.cs
@@ -0,0 +1,132 @@
+using System.Xml.Linq;
+using NAPS2.Escl.Client;
+using Xunit;
+
+namespace NAPS2.Escl.Tests;
+
+public class CapabilitiesParserTests
+{
+ [Fact]
+ public void TestBasicCaps()
+ {
+ string input =
+ """
+
+
+ 2.6
+ Hewlett Packard Photosmart C4760
+ CN01 7971874378PJ
+ 96a4b400 2a9e 012f 6165 0025559efbc6f
+ http://192.168.1.2/index.html
+ http://192.168.1.2/scanner.png
+
+
+
+ BlackAndWhite1
+ Grayscale8
+
+
+ application/pdf
+ image/jpeg
+ application/pdf
+ image/jpeg
+
+
+
+
+ 100
+ 100
+
+
+ 200
+ 200
+
+
+ 300
+ 300
+
+
+
+
+
+
+
+ 1
+ 3000
+ 1
+ 3600
+ 2
+
+
+
+ BlackAndWhite1
+ Grayscale8
+
+
+ application/pdf
+ image/jpeg
+ application/pdf
+ image/jpeg
+
+
+
+
+ 75
+ 1200
+ 300
+ 10
+
+
+ 75
+ 1200
+ 300
+ 10
+
+
+
+
+
+
+
+
+
+ 1
+ 2600
+ 1
+ 3400
+
+
+
+
+
+ DetectPaperLoaded
+ SelectSinglePage
+
+
+
+ """;
+ var caps = CapabilitiesParser.Parse(XDocument.Parse(input));
+ Assert.Equal("2.6", caps.Version);
+ Assert.Equal("Hewlett Packard Photosmart C4760", caps.MakeAndModel);
+ Assert.Equal("CN01 7971874378PJ", caps.SerialNumber);
+ Assert.Equal("96a4b400 2a9e 012f 6165 0025559efbc6f", caps.Uuid);
+ Assert.Equal("http://192.168.1.2/index.html", caps.AdminUri);
+ Assert.Equal("http://192.168.1.2/scanner.png", caps.IconUri);
+ }
+
+ [Fact]
+ public void TestWithDifferentSchemaVersion()
+ {
+ string input =
+ """
+
+
+ Hewlett Packard Photosmart C4760
+
+ """;
+ var caps = CapabilitiesParser.Parse(XDocument.Parse(input));
+ Assert.Equal("Hewlett Packard Photosmart C4760", caps.MakeAndModel);
+ }
+}
diff --git a/NAPS2.Escl.Tests/ClientServerTests.cs b/NAPS2.Escl.Tests/ClientServerTests.cs
index b2464bb9f3..58731a31eb 100644
--- a/NAPS2.Escl.Tests/ClientServerTests.cs
+++ b/NAPS2.Escl.Tests/ClientServerTests.cs
@@ -1,35 +1,63 @@
using System.Net;
using NAPS2.Escl.Client;
using NAPS2.Escl.Server;
+using NSubstitute;
using Xunit;
namespace NAPS2.Escl.Tests;
public class ClientServerTests
{
- [Fact]
+ [Fact(Timeout = 60_000)]
public async Task ClientServer()
{
- using var server = new EsclServer(new EsclServerConfig
+ var job = Substitute.For();
+ using var server = new EsclServer();
+ var uuid = Guid.NewGuid().ToString("D");
+ var deviceConfig = new EsclDeviceConfig
{
Capabilities = new EsclCapabilities
{
Version = "2.0",
MakeAndModel = "HP Blah",
- SerialNumber = "123abc"
- }
- }) { Port = 9801 };
- server.Start();
+ SerialNumber = "123abc",
+ Uuid = uuid
+ },
+ CreateJob = _ => job
+ };
+ server.AddDevice(deviceConfig);
+ await server.Start();
var client = new EsclClient(new EsclService
{
- Ip = IPAddress.IPv6Loopback,
- Port = 9801,
- RootUrl = "escl",
- Tls = false
+ IpV4 = IPAddress.Loopback,
+ IpV6 = IPAddress.IPv6Loopback,
+ Host = $"[{IPAddress.IPv6Loopback}]",
+ RemoteEndpoint = IPAddress.IPv6Loopback,
+ Port = deviceConfig.Port,
+ TlsPort = deviceConfig.TlsPort,
+ RootUrl = "eSCL",
+ Tls = false,
+ Uuid = uuid
});
var caps = await client.GetCapabilities();
Assert.Equal("2.0", caps.Version);
Assert.Equal("HP Blah", caps.MakeAndModel);
Assert.Equal("123abc", caps.SerialNumber);
}
+
+ [Fact]
+ public async Task StartTlsServerWithoutTrustedCertificate()
+ {
+ using var server = new EsclServer();
+ server.SecurityPolicy = EsclSecurityPolicy.RequireTrustedCertificate;
+ await Assert.ThrowsAsync(() => server.Start());
+ }
+
+ [Fact]
+ public async Task StartTlsServerWithInconsistentFlags()
+ {
+ using var server = new EsclServer();
+ server.SecurityPolicy = EsclSecurityPolicy.RequireHttps | EsclSecurityPolicy.ServerDisableHttps;
+ await Assert.ThrowsAsync(() => server.Start());
+ }
}
\ No newline at end of file
diff --git a/NAPS2.Escl.Tests/DeviceServiceLocatorTests.cs b/NAPS2.Escl.Tests/DeviceServiceLocatorTests.cs
index bf5d7792e0..7e0e6d3fbf 100644
--- a/NAPS2.Escl.Tests/DeviceServiceLocatorTests.cs
+++ b/NAPS2.Escl.Tests/DeviceServiceLocatorTests.cs
@@ -1,13 +1,13 @@
-using NAPS2.Escl.Client;
-using Xunit;
-
-namespace NAPS2.Escl.Tests;
-
-public class DeviceServiceLocatorTests
-{
- [Fact]
- public async Task Locate()
- {
- await new EsclServiceLocator().Locate();
- }
-}
\ No newline at end of file
+// using NAPS2.Escl.Client;
+// using Xunit;
+//
+// namespace NAPS2.Escl.Tests;
+//
+// public class DeviceServiceLocatorTests
+// {
+// [Fact]
+// public async Task Locate()
+// {
+// await new EsclServiceLocator().Locate();
+// }
+// }
\ No newline at end of file
diff --git a/NAPS2.Escl.Tests/NAPS2.Escl.Tests.csproj b/NAPS2.Escl.Tests/NAPS2.Escl.Tests.csproj
index 1061c10ddf..88fcf081b4 100644
--- a/NAPS2.Escl.Tests/NAPS2.Escl.Tests.csproj
+++ b/NAPS2.Escl.Tests/NAPS2.Escl.Tests.csproj
@@ -1,25 +1,23 @@
- net6.0;net462
+ net8;net462
enable
enable
- 10
- USB
+ 12
-
-
-
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
+
+
+
-
-
+
+
+
-
+
\ No newline at end of file
diff --git a/NAPS2.Escl.Tests/UsbTests.cs b/NAPS2.Escl.Tests/UsbTests.cs
index 0ed892aad5..a719d3e75b 100644
--- a/NAPS2.Escl.Tests/UsbTests.cs
+++ b/NAPS2.Escl.Tests/UsbTests.cs
@@ -1,5 +1,4 @@
-#if USB
-using NAPS2.Escl.Client;
+using NAPS2.Escl.Usb;
using Xunit;
namespace NAPS2.Escl.Tests;
@@ -20,4 +19,3 @@ public async Task Usb()
Assert.NotNull(caps);
}
}
-#endif
\ No newline at end of file
diff --git a/NAPS2.Escl.Client/.gitignore b/NAPS2.Escl.Usb/.gitignore
similarity index 100%
rename from NAPS2.Escl.Client/.gitignore
rename to NAPS2.Escl.Usb/.gitignore
diff --git a/NAPS2.Escl.Client/EsclUsbContext.cs b/NAPS2.Escl.Usb/EsclUsbContext.cs
similarity index 94%
rename from NAPS2.Escl.Client/EsclUsbContext.cs
rename to NAPS2.Escl.Usb/EsclUsbContext.cs
index 23e35318ce..6067c1e0fb 100644
--- a/NAPS2.Escl.Client/EsclUsbContext.cs
+++ b/NAPS2.Escl.Usb/EsclUsbContext.cs
@@ -1,4 +1,3 @@
-#if USB
using System.Net;
using System.Net.Sockets;
using System.Text;
@@ -6,8 +5,9 @@
using LibUsbDotNet.Info;
using LibUsbDotNet.LibUsb;
using LibUsbDotNet.Main;
+using NAPS2.Escl.Client;
-namespace NAPS2.Escl.Client;
+namespace NAPS2.Escl.Usb;
public class EsclUsbContext : IDisposable
{
@@ -47,10 +47,15 @@ public void ConnectToDevice()
var port = ((IPEndPoint) _proxyListener.LocalEndpoint).Port;
_client = new EsclClient(new EsclService
{
- Ip = IPAddress.Loopback,
+ IpV4 = IPAddress.Loopback,
+ IpV6 = null,
+ Host = IPAddress.Loopback.ToString(),
+ RemoteEndpoint = IPAddress.Loopback,
Port = port,
+ TlsPort = 0,
RootUrl = "eSCL",
- Tls = false
+ Tls = false,
+ Uuid = Guid.Empty.ToString("D")
});
Task.Run(ProxyLoop);
}
@@ -153,4 +158,3 @@ public void Dispose()
_usbContext.Dispose();
}
}
-#endif
\ No newline at end of file
diff --git a/NAPS2.Escl.Client/EsclUsbDescriptor.cs b/NAPS2.Escl.Usb/EsclUsbDescriptor.cs
similarity index 73%
rename from NAPS2.Escl.Client/EsclUsbDescriptor.cs
rename to NAPS2.Escl.Usb/EsclUsbDescriptor.cs
index d21383a474..3931bdd7c1 100644
--- a/NAPS2.Escl.Client/EsclUsbDescriptor.cs
+++ b/NAPS2.Escl.Usb/EsclUsbDescriptor.cs
@@ -1,5 +1,3 @@
-#if USB
-namespace NAPS2.Escl.Client;
+namespace NAPS2.Escl.Usb;
public record EsclUsbDescriptor(int VendorId, int ProductId, string SerialNumber, string Manufacturer, string Product);
-#endif
\ No newline at end of file
diff --git a/NAPS2.Escl.Client/EsclUsbPoller.cs b/NAPS2.Escl.Usb/EsclUsbPoller.cs
similarity index 96%
rename from NAPS2.Escl.Client/EsclUsbPoller.cs
rename to NAPS2.Escl.Usb/EsclUsbPoller.cs
index 1ad9eff73a..db9ecf7933 100644
--- a/NAPS2.Escl.Client/EsclUsbPoller.cs
+++ b/NAPS2.Escl.Usb/EsclUsbPoller.cs
@@ -1,9 +1,8 @@
-#if USB
-using LibUsbDotNet;
+using LibUsbDotNet;
using LibUsbDotNet.Info;
using LibUsbDotNet.LibUsb;
-namespace NAPS2.Escl.Client;
+namespace NAPS2.Escl.Usb;
public class EsclUsbPoller
{
@@ -50,4 +49,3 @@ public Task> Poll()
});
}
}
-#endif
\ No newline at end of file
diff --git a/NAPS2.Escl.Usb/LICENSE b/NAPS2.Escl.Usb/LICENSE
new file mode 100644
index 0000000000..099a807a65
--- /dev/null
+++ b/NAPS2.Escl.Usb/LICENSE
@@ -0,0 +1,518 @@
+NAPS2.Escl
+https://www.github.com/cyanfish/naps2/
+
+Copyright 2009-2025 NAPS2 Contributors
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+Lesser General Public License for more details.
+
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it. You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+ When we speak of free software, we are referring to freedom of use,
+not price. Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+ To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights. These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ To protect each distributor, we want to make it very clear that
+there is no warranty for the free library. Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+ Finally, software patents pose a constant threat to the existence of
+any free program. We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder. Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+ Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License. This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License. We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+ When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library. The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom. The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+ We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License. It also provides other free software developers Less
+of an advantage over competing non-free programs. These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries. However, the Lesser license provides advantages in certain
+special circumstances.
+
+ For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard. To achieve this, non-free programs must be
+allowed to use the library. A more frequent case is that a free
+library does the same job as widely used non-free libraries. In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+ In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software. For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+ Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (1) uses at run time a
+ copy of the library already present on the user's computer system,
+ rather than copying library functions into the executable, and (2)
+ will operate properly with a modified version of the library, if
+ the user installs one, as long as the modified version is
+ interface-compatible with the version that the work was made with.
+
+ c) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ d) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ e) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Libraries
+
+ If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change. You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+ To apply these terms, attach the following notices to the library. It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, write to the Free Software
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the
+ library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+ , 1 April 1990
+ Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/NAPS2.Escl.Usb/NAPS2.Escl.Usb.csproj b/NAPS2.Escl.Usb/NAPS2.Escl.Usb.csproj
new file mode 100644
index 0000000000..6ccce2ee65
--- /dev/null
+++ b/NAPS2.Escl.Usb/NAPS2.Escl.Usb.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net6;net8;net462
+ enable
+ enable
+ 12
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/NAPS2.Escl/Client/CapabilitiesParser.cs b/NAPS2.Escl/Client/CapabilitiesParser.cs
new file mode 100644
index 0000000000..1d734d91ee
--- /dev/null
+++ b/NAPS2.Escl/Client/CapabilitiesParser.cs
@@ -0,0 +1,154 @@
+using System.Xml.Linq;
+
+namespace NAPS2.Escl.Client;
+
+internal static class CapabilitiesParser
+{
+ private static readonly XNamespace ScanNs = EsclXmlHelper.ScanNs;
+ private static readonly XNamespace PwgNs = EsclXmlHelper.PwgNs;
+
+ public static EsclCapabilities Parse(XDocument doc)
+ {
+ var root = doc.Root;
+ if (root?.Name.LocalName != "ScannerCapabilities" ||
+ !root.Name.NamespaceName.StartsWith("http://schemas.hp.com/imaging/escl/"))
+ {
+ throw new InvalidOperationException("Unexpected root element: " + doc.Root?.Name);
+ }
+ var settingProfilesEl = root!.Element(ScanNs + "SettingProfiles");
+ var settingProfiles = new Dictionary();
+ if (settingProfilesEl != null)
+ {
+ foreach (var el in settingProfilesEl.Elements(ScanNs + "SettingProfile"))
+ {
+ ParseSettingProfile(el, settingProfiles);
+ }
+ }
+ var platenCapsEl = root.Element(ScanNs + "Platen")?.Element(ScanNs + "PlatenInputCaps");
+ var adfSimplexCapsEl = root.Element(ScanNs + "Adf")?.Element(ScanNs + "AdfSimplexInputCaps");
+ var adfDuplexCapsEl = root.Element(ScanNs + "Adf")?.Element(ScanNs + "AdfDuplexInputCaps");
+ return new EsclCapabilities
+ {
+ Version = root.Element(PwgNs + "Version")?.Value ?? EsclCapabilities.DEFAULT_VERSION,
+ MakeAndModel = root.Element(PwgNs + "MakeAndModel")?.Value,
+ SerialNumber = root.Element(PwgNs + "SerialNumber")?.Value,
+ Manufacturer = root.Element(ScanNs + "Manufacturer")?.Value,
+ Uuid = root.Element(ScanNs + "UUID")?.Value,
+ AdminUri = root.Element(ScanNs + "AdminURI")?.Value,
+ IconUri = root.Element(ScanNs + "IconURI")?.Value,
+ Naps2Extensions = root.Element(ScanNs + "Naps2Extensions")?.Value,
+ PlatenCaps = ParseInputCaps(platenCapsEl, settingProfiles),
+ AdfSimplexCaps = ParseInputCaps(adfSimplexCapsEl, settingProfiles),
+ AdfDuplexCaps = ParseInputCaps(adfDuplexCapsEl, settingProfiles),
+ CompressionFactorSupport = ParseRange(root.Element(ScanNs + "CompressionFactorSupport"))
+ };
+ }
+
+ private static EsclSettingProfile ParseSettingProfile(XElement element,
+ Dictionary profilesDict)
+ {
+ var profileRef = element.Attribute("ref")?.Value;
+ if (profileRef != null)
+ {
+ return profilesDict[profileRef];
+ }
+ var profile = new EsclSettingProfile
+ {
+ Name = element.Attribute("name")?.Value,
+ ColorModes =
+ ParseEnumValues(element.Element(ScanNs + "ColorModes")?.Elements(ScanNs + "ColorMode")),
+ DocumentFormats = element.Element(ScanNs + "DocumentFormats")?.Elements(PwgNs + "DocumentFormat")
+ .Select(x => x.Value).ToList() ?? new List(),
+ DocumentFormatsExt = element.Element(ScanNs + "DocumentFormats")?.Elements(PwgNs + "DocumentFormatExt")
+ .Select(x => x.Value).ToList() ?? new List(),
+ DiscreteResolutions = ParseDiscreteResolutions(element.Element(ScanNs + "SupportedResolutions")
+ ?.Element(ScanNs + "DiscreteResolutions")?.Elements(ScanNs + "DiscreteResolution")),
+ XResolutionRange = ParseRange(element.Element(ScanNs + "SupportedResolutions")
+ ?.Element(ScanNs + "XResolutionRange")),
+ YResolutionRange = ParseRange(element.Element(ScanNs + "SupportedResolutions")
+ ?.Element(ScanNs + "YResolutionRange")),
+ };
+ if (profile.Name != null)
+ {
+ profilesDict[profile.Name] = profile;
+ }
+ return profile;
+ }
+
+ private static EsclRange? ParseRange(XElement? element)
+ {
+ if (element == null) return null;
+
+ var min = ParseHelper.MaybeParseInt(element.Element(ScanNs + "Min"));
+ var max = ParseHelper.MaybeParseInt(element.Element(ScanNs + "Max"));
+ var normal = ParseHelper.MaybeParseInt(element.Element(ScanNs + "Normal"));
+ var step = ParseHelper.MaybeParseInt(element.Element(ScanNs + "Step"));
+ if (min != null && max != null && normal != null)
+ {
+ return new EsclRange(min.Value, max.Value, normal.Value, step ?? 1);
+ }
+ return null;
+ }
+
+ private static List ParseDiscreteResolutions(IEnumerable? elements)
+ {
+ var list = new List();
+ if (elements == null)
+ {
+ return list;
+ }
+ foreach (var el in elements)
+ {
+ var xRes = el.Element(ScanNs + "XResolution");
+ var yRes = el.Element(ScanNs + "YResolution");
+ if (xRes != null && yRes != null)
+ {
+ list.Add(new DiscreteResolution(int.Parse(xRes.Value), int.Parse(yRes.Value)));
+ }
+ }
+ return list;
+ }
+
+ private static List ParseEnumValues(IEnumerable? elements) where T : struct
+ {
+ var list = new List();
+ if (elements == null)
+ {
+ return list;
+ }
+ foreach (var el in elements)
+ {
+ if (Enum.TryParse(el.Value, out var parsed))
+ {
+ list.Add(parsed);
+ }
+ }
+ return list;
+ }
+
+ private static EsclInputCaps? ParseInputCaps(XElement? element,
+ Dictionary settingProfilesMap)
+ {
+ if (element == null)
+ {
+ return null;
+ }
+ var settingProfiles = new List();
+ var settingProfilesEl = element.Element(ScanNs + "SettingProfiles");
+ if (settingProfilesEl != null)
+ {
+ foreach (var el in settingProfilesEl.Elements(ScanNs + "SettingProfile"))
+ {
+ settingProfiles.Add(ParseSettingProfile(el, settingProfilesMap));
+ }
+ }
+ return new EsclInputCaps
+ {
+ SettingProfiles = settingProfiles,
+ MinWidth = ParseHelper.MaybeParseInt(element.Element(ScanNs + "MinWidth")),
+ MaxWidth = ParseHelper.MaybeParseInt(element.Element(ScanNs + "MaxWidth")),
+ MinHeight = ParseHelper.MaybeParseInt(element.Element(ScanNs + "MinHeight")),
+ MaxHeight = ParseHelper.MaybeParseInt(element.Element(ScanNs + "MaxHeight")),
+ };
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/Client/EsclClient.cs b/NAPS2.Escl/Client/EsclClient.cs
new file mode 100644
index 0000000000..6a374aec6c
--- /dev/null
+++ b/NAPS2.Escl/Client/EsclClient.cs
@@ -0,0 +1,398 @@
+using System.Globalization;
+using System.Net;
+using System.Net.Http;
+using System.Security.Authentication;
+using System.Text;
+using System.Xml.Linq;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace NAPS2.Escl.Client;
+
+public class EsclClient
+{
+ private static readonly XNamespace ScanNs = EsclXmlHelper.ScanNs;
+ private static readonly XNamespace PwgNs = EsclXmlHelper.PwgNs;
+
+ // Client that verifies HTTPS certificates
+ private static readonly HttpMessageHandler VerifiedHttpClientHandler = new StandardSocketsHttpHandler
+ {
+ MaxConnectionsPerServer = 256,
+ ConnectTimeout = TimeSpan.FromSeconds(5)
+ };
+ private static readonly HttpClient VerifiedHttpClient = new(VerifiedHttpClientHandler)
+ {
+ Timeout = TimeSpan.FromSeconds(10)
+ };
+ private static readonly HttpClient LongTimeoutVerifiedHttpClient = new(VerifiedHttpClientHandler)
+ {
+ Timeout = TimeSpan.FromSeconds(120)
+ };
+
+ // Client that doesn't verify HTTPS certificates
+ private static readonly HttpMessageHandler UnverifiedHttpClientHandler = new StandardSocketsHttpHandler
+ {
+ MaxConnectionsPerServer = 256,
+ ConnectTimeout = TimeSpan.FromSeconds(5),
+ SslOptions =
+ {
+ // ESCL certificates are generally self-signed - we aren't trying to verify server authenticity, just ensure
+ // that the connection is encrypted and protect against passive interception.
+ RemoteCertificateValidationCallback = (_, _, _, _) => true
+ }
+ };
+ private static readonly HttpClient UnverifiedHttpClient = new(UnverifiedHttpClientHandler)
+ {
+ Timeout = TimeSpan.FromSeconds(10)
+ };
+ private static readonly HttpClient LongTimeoutUnverifiedHttpClient = new(UnverifiedHttpClientHandler)
+ {
+ Timeout = TimeSpan.FromSeconds(120)
+ };
+
+ public static HttpClient GetHttpClient(EsclSecurityPolicy securityPolicy, bool longTimeout = false)
+ {
+ if (longTimeout)
+ {
+ return securityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireTrustedCertificate)
+ ? LongTimeoutVerifiedHttpClient
+ : LongTimeoutUnverifiedHttpClient;
+ }
+ return securityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireTrustedCertificate)
+ ? VerifiedHttpClient
+ : UnverifiedHttpClient;
+ }
+
+ private readonly EsclService? _service;
+ private readonly Uri? _uriBase;
+ private readonly string _rootUrl;
+ private bool _httpFallback;
+
+ public EsclClient(EsclService service)
+ {
+ _service = service;
+ _rootUrl = _service.RootUrl;
+ }
+
+ public EsclClient(Uri uriBase)
+ {
+ _uriBase = uriBase;
+ _rootUrl = _uriBase.AbsolutePath;
+ if (_rootUrl.StartsWith("/")) _rootUrl = _rootUrl.Substring(1);
+ }
+
+ public EsclSecurityPolicy SecurityPolicy { get; set; }
+
+ public ILogger Logger { get; set; } = NullLogger.Instance;
+
+ public CancellationToken CancelToken { get; set; }
+
+ private HttpClient HttpClient => GetHttpClient(SecurityPolicy, false);
+
+ private HttpClient LongTimeoutHttpClient => GetHttpClient(SecurityPolicy, true);
+
+ public async Task GetCapabilities()
+ {
+ var doc = await DoRequest("ScannerCapabilities");
+ return CapabilitiesParser.Parse(doc);
+ }
+
+ public async Task GetStatus()
+ {
+ var doc = await DoRequest("ScannerStatus");
+ var root = doc.Root;
+ if (root?.Name != ScanNs + "ScannerStatus")
+ {
+ throw new InvalidOperationException("Unexpected root element: " + doc.Root?.Name);
+ }
+ var jobStates = new Dictionary();
+ foreach (var jobInfoEl in root.Element(ScanNs + "Jobs")?.Elements(ScanNs + "JobInfo") ?? [])
+ {
+ var jobUri = jobInfoEl.Element(PwgNs + "JobUri")?.Value;
+ var jobState = ParseHelper.MaybeParseEnum(jobInfoEl.Element(PwgNs + "JobState"), EsclJobState.Unknown);
+ if (jobUri != null && jobState != EsclJobState.Unknown)
+ {
+ jobStates.Add(jobUri, jobState);
+ }
+ }
+ return new EsclScannerStatus
+ {
+ State = ParseHelper.MaybeParseEnum(root.Element(PwgNs + "State"), EsclScannerState.Unknown),
+ AdfState = ParseHelper.MaybeParseEnum(root.Element(ScanNs + "AdfState"), EsclAdfState.Unknown),
+ JobStates = jobStates
+ };
+ }
+
+ public async Task CreateScanJob(EsclScanSettings settings)
+ {
+ var doc =
+ EsclXmlHelper.CreateDocAsString(
+ new XElement(ScanNs + "ScanSettings",
+ new XElement(PwgNs + "Version", "2.0"),
+ new XElement(ScanNs + "Intent", "TextAndGraphic"),
+ new XElement(PwgNs + "ScanRegions",
+ new XAttribute(PwgNs + "MustHonor", "true"),
+ new XElement(PwgNs + "ScanRegion",
+ new XElement(PwgNs + "Height", settings.Height),
+ new XElement(PwgNs + "ContentRegionUnits", "escl:ThreeHundredthsOfInches"),
+ new XElement(PwgNs + "Width", settings.Width),
+ new XElement(PwgNs + "XOffset", settings.XOffset),
+ new XElement(PwgNs + "YOffset", settings.YOffset))),
+ new XElement(PwgNs + "InputSource", settings.InputSource),
+ new XElement(ScanNs + "Duplex", settings.Duplex),
+ new XElement(ScanNs + "ColorMode", settings.ColorMode),
+ new XElement(ScanNs + "XResolution", settings.XResolution),
+ new XElement(ScanNs + "YResolution", settings.YResolution),
+ // TODO: Brightness/contrast/threshold
+ // new XElement(ScanNs + "Brightness", settings.Brightness),
+ // new XElement(ScanNs + "Contrast", settings.Contrast),
+ // new XElement(ScanNs + "Threshold", settings.Threshold),
+ OptionalElement(ScanNs + "CompressionFactor", settings.CompressionFactor),
+ new XElement(PwgNs + "DocumentFormat", settings.DocumentFormat)));
+ var content = new StringContent(doc, Encoding.UTF8, "text/xml");
+ var response = await WithHttpFallback(
+ () => GetUrl($"/{_rootUrl}/ScanJobs"),
+ url =>
+ {
+ Logger.LogDebug("ESCL POST {Url}", url);
+ return HttpClient.PostAsync(url, content);
+ });
+ response.EnsureSuccessStatusCode();
+ Logger.LogDebug("POST OK");
+
+ var uri = response.Headers.Location!;
+
+ return new EsclJob
+ {
+ UriPath = uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString
+ };
+ }
+
+ private XElement? OptionalElement(XName elementName, int? value)
+ {
+ if (value == null) return null;
+ return new XElement(elementName, value);
+ }
+
+ public async Task NextDocument(EsclJob job, Action? pageProgress = null,
+ bool shortTimeout = false)
+ {
+ var progressCts = new CancellationTokenSource();
+ if (pageProgress != null)
+ {
+ var progressUrl = GetUrl($"{job.UriPath}/Progress");
+ var progressResponse = await LongTimeoutHttpClient.GetStreamAsync(progressUrl);
+ var streamReader = new StreamReader(progressResponse);
+ _ = Task.Run(async () =>
+ {
+ using var streamReaderForDisposal = streamReader;
+ while (await streamReader.ReadLineAsync() is { } line)
+ {
+ if (progressCts.IsCancellationRequested)
+ {
+ return;
+ }
+ if (double.TryParse(line, NumberStyles.Any, CultureInfo.InvariantCulture, out var progress))
+ {
+ pageProgress(progress);
+ }
+ }
+ });
+ }
+ try
+ {
+ // TODO: Maybe check Content-Location on the response header to ensure no duplicate document?
+ HttpResponseMessage response;
+ while (true)
+ {
+ response = await WithHttpFallback(
+ () => GetUrl($"{job.UriPath}/NextDocument"),
+ url =>
+ {
+ Logger.LogDebug("ESCL GET {Url}", url);
+ var client = shortTimeout ? HttpClient : LongTimeoutHttpClient;
+ return client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
+ });
+ if (response.StatusCode == HttpStatusCode.ServiceUnavailable)
+ {
+ // ServiceUnavailable = retry after a delay
+ Logger.LogDebug("GET returned 503, waiting to retry");
+ await Task.Delay(2000);
+ continue;
+ }
+ if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
+ {
+ // NotFound = end of scan, Gone = canceled
+ Logger.LogDebug("GET failed: {Status}", response.StatusCode);
+ return null;
+ }
+ response.EnsureSuccessStatusCode();
+ break;
+ }
+ // TODO: Define a NAPS2 protocol extension to shorten this timeout to 10s (once we do the rollout of server-side 503s)
+ var data = await ReadStreamWithPerReadTimeout(await response.Content.ReadAsStreamAsync(), 60_000);
+ var doc = new RawDocument
+ {
+ Data = data,
+ ContentType = response.Content.Headers.ContentType?.MediaType,
+ ContentLocation = response.Content.Headers.ContentLocation?.ToString()
+ };
+ if (doc.Data.Length == 0)
+ {
+ throw new Exception("ESCL response had no data, the connection may have been interrupted");
+ }
+ Logger.LogDebug("GET OK: {Type} ({Bytes} bytes) {Location}", doc.ContentType, doc.Data.Length,
+ doc.ContentLocation);
+ return doc;
+ }
+ finally
+ {
+ progressCts.Cancel();
+ }
+ }
+
+ private async Task ReadStreamWithPerReadTimeout(Stream stream, int timeout)
+ {
+ // We expect the server to be continuously sending some kind of data - if reads take longer than the timeout,
+ // we assume that the connection has been disrupted.
+ MemoryStream tempStream = new MemoryStream();
+ byte[] buffer = new byte[65536];
+ while (true)
+ {
+ var cts = new CancellationTokenSource();
+ cts.CancelAfter(timeout);
+ int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cts.Token);
+ if (bytesRead == 0) break;
+ tempStream.Write(buffer, 0, bytesRead);
+ }
+ return tempStream.ToArray();
+ }
+
+ public async Task ErrorDetails(EsclJob job)
+ {
+ var response = await WithHttpFallback(
+ () => GetUrl($"{job.UriPath}/ErrorDetails"),
+ url =>
+ {
+ Logger.LogDebug("ESCL GET {Url}", url);
+ return HttpClient.GetAsync(url);
+ });
+ response.EnsureSuccessStatusCode();
+ Logger.LogDebug("GET OK");
+ return await response.Content.ReadAsStringAsync();
+ }
+
+ public async Task CancelJob(EsclJob job)
+ {
+ var response = await WithHttpFallback(
+ () => GetUrl(job.UriPath),
+ url =>
+ {
+ Logger.LogDebug("ESCL DELETE {Url}", url);
+ return HttpClient.DeleteAsync(url);
+ });
+ if (!response.IsSuccessStatusCode)
+ {
+ Logger.LogDebug("DELETE failed: {Status}", response.StatusCode);
+ return;
+ }
+ response.EnsureSuccessStatusCode();
+ Logger.LogDebug("DELETE OK");
+ }
+
+ public string? IconUri
+ {
+ get
+ {
+ // TODO: Consider replacing the hostname with the remote endpoint?
+ var iconUri = _service?.Thumbnail;
+ if (string.IsNullOrWhiteSpace(iconUri))
+ {
+ return null;
+ }
+ if (iconUri!.StartsWith("http://") || iconUri.StartsWith("https://"))
+ {
+ return iconUri;
+ }
+ if (!iconUri.StartsWith("/"))
+ {
+ iconUri = "/" + iconUri;
+ }
+ return GetUrl(iconUri);
+ }
+ }
+
+ public string ConnectionUri => GetUrl($"/{_rootUrl}");
+
+ private async Task DoRequest(string endpoint)
+ {
+ // TODO: Retry logic
+ var response = await WithHttpFallback(
+ () => GetUrl($"/{_rootUrl}/{endpoint}"),
+ url =>
+ {
+ Logger.LogDebug("ESCL GET {Url}", url);
+ return HttpClient.GetAsync(url, CancelToken);
+ });
+ response.EnsureSuccessStatusCode();
+ Logger.LogDebug("GET OK");
+ var text = await response.Content.ReadAsStringAsync();
+ // Fix for invalid doctype declarations
+ text = text.Replace(" WithHttpFallback(Func urlFunc, Func> func)
+ {
+ string url = urlFunc();
+ try
+ {
+ return await func(url);
+ }
+ catch (HttpRequestException ex) when (!SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireHttps) &&
+ !_httpFallback &&
+ url.StartsWith("https://") && (
+ ex.InnerException is AuthenticationException ||
+ ex.InnerException?.InnerException is AuthenticationException))
+ {
+ Logger.LogDebug(ex, "TLS authentication error; falling back to HTTP");
+ _httpFallback = true;
+ url = urlFunc();
+ return await func(url);
+ }
+ }
+
+ private string GetUrl(string endpoint)
+ {
+ bool tls = _service == null
+ ? _uriBase!.Scheme == "https"
+ : (_service.Tls || _service.Port == 443) && !_httpFallback &&
+ !SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientDisableHttps);
+ if (SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireHttps) && !tls)
+ {
+ throw new EsclSecurityPolicyViolationException(
+ $"EsclSecurityPolicy of {SecurityPolicy} doesn't allow HTTP connections");
+ }
+ if (_service == null)
+ {
+ return $"{_uriBase!.Scheme}://{_uriBase.Host}:{_uriBase.Port}{endpoint}";
+ }
+ var protocol = tls ? "https" : "http";
+ return $"{protocol}://{GetHostAndPort(_service.Tls && !_httpFallback)}{endpoint}";
+ }
+
+ private string GetHostAndPort(bool tls)
+ {
+ var port = tls ? _service!.TlsPort : _service!.Port;
+ var host = new IPEndPoint(_service.RemoteEndpoint, port).ToString();
+#if NET6_0_OR_GREATER
+ if (OperatingSystem.IsMacOS())
+ {
+ // Using the mDNS hostname is more reliable on Mac (but doesn't work at all on Windows)
+ host = $"{_service.Host}:{port}";
+ }
+#endif
+ return host;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/Client/EsclService.cs b/NAPS2.Escl/Client/EsclService.cs
new file mode 100644
index 0000000000..6f277fd907
--- /dev/null
+++ b/NAPS2.Escl/Client/EsclService.cs
@@ -0,0 +1,102 @@
+using System.Net;
+
+namespace NAPS2.Escl.Client;
+
+public class EsclService
+{
+ ///
+ /// The IP (v4) address of the scanner. At least one of IpV4 and IpV6 will be non-null.
+ ///
+ public required IPAddress? IpV4 { get; init; }
+
+ ///
+ /// The IP (v6) address of the scanner. At least one of IpV4 and IpV6 will be non-null.
+ ///
+ public required IPAddress? IpV6 { get; init; }
+
+ ///
+ /// The mDNS host name of the scanner.
+ ///
+ public required string Host { get; init; }
+
+ ///
+ /// The IP address of the DNS response.
+ ///
+ public required IPAddress RemoteEndpoint { get; init; }
+
+ ///
+ /// The HTTP port of the ESCL service.
+ ///
+ public required int Port { get; init; }
+
+ ///
+ /// The HTTPS port of the ESCL service.
+ ///
+ public required int TlsPort { get; init; }
+
+ ///
+ /// Whether to use HTTPS for the connection.
+ ///
+ public required bool Tls { get; init; }
+
+ ///
+ /// The root path of the ESCL URLs with no leading or trailing slash. For example, "eSCL" means we would use a URL
+ /// like "http://192.168.1.111:80/eSCL/ScannerCapabilities".
+ ///
+ public required string RootUrl { get; init; }
+
+ ///
+ /// A unique identifier for the physical scanner device.
+ ///
+ public required string Uuid { get; init; }
+
+ ///
+ /// The make and model of the scanner.
+ ///
+ public string? ScannerName { get; init; }
+
+ ///
+ /// The version of the TXT record the information in this class came from.
+ ///
+ public string? TxtVersion { get; init; }
+
+ ///
+ /// The configuration URL for the scanner.
+ ///
+ public string? AdminUrl { get; init; }
+
+ ///
+ /// The ESCL protocol version. Should be 2.0.
+ ///
+ public string? EsclVersion { get; init; }
+
+ ///
+ /// A URL to an image representing the scanner.
+ ///
+ public string? Thumbnail { get; init; }
+
+ ///
+ /// Extra information about the scanner's location (e.g. "3rd Floor Copy Room").
+ ///
+ public string? Note { get; init; }
+
+ ///
+ /// The MIME types supported by the scanner (e.g. "application/pdf", "image/jpeg").
+ ///
+ public string[]? MimeTypes { get; init; }
+
+ ///
+ /// Supported color options (e.g. "color", "grayscale", "binary").
+ ///
+ public string[]? ColorOptions { get; init; }
+
+ ///
+ /// Supported input source options (e.g. "platen", "adf", "camera").
+ ///
+ public string? SourceOptions { get; init; }
+
+ ///
+ /// Whether duplex is supported with the "adf" source.
+ ///
+ public bool? DuplexSupported { get; init; }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/Client/EsclServiceLocator.cs b/NAPS2.Escl/Client/EsclServiceLocator.cs
new file mode 100644
index 0000000000..6b52c57d86
--- /dev/null
+++ b/NAPS2.Escl/Client/EsclServiceLocator.cs
@@ -0,0 +1,176 @@
+using System.Net;
+using Makaretu.Dns;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace NAPS2.Escl.Client;
+
+public class EsclServiceLocator : IDisposable
+{
+ private readonly ServiceDiscovery _discovery;
+ private readonly HashSet _locatedServices = new();
+ private bool _started;
+ private int _nextQueryInterval = 1000;
+
+ public EsclServiceLocator(Action serviceCallback)
+ {
+ _discovery = new ServiceDiscovery();
+ _discovery.ServiceInstanceDiscovered += (_, args) =>
+ {
+ try
+ {
+ if (args.ServiceInstanceName.Labels[1] is not ("_uscan" or "_uscans"))
+ {
+ return;
+ }
+ var service = ParseService(args);
+ // TODO: Does the IP really make the device distinct? Not that it should matter in practice, but still.
+ // TODO: We definitely want to de-duplicate HTTP/HTTPS, but I'm not sure how to do that. Remind me how
+ var serviceKey = new ServiceKey(service.ScannerName, service.Uuid, service.Port, service.IpV4, service.IpV6);
+ lock (_locatedServices)
+ {
+ if (!_locatedServices.Add(serviceKey))
+ {
+ // Don't callback for duplicates
+ return;
+ }
+ }
+ Logger.LogDebug("Discovered ESCL Service: {Name}, instance {Instance}, endpoint {Endpoint}, ipv4 {Ipv4}, ipv6 {IpV6}, host {Host}, port {Port}, tlsPort {Port}, uuid {Uuid}",
+ service.ScannerName, args.ServiceInstanceName, args.RemoteEndPoint, service.IpV4, service.IpV6, service.Host, service.Port, service.TlsPort, service.Uuid);
+ serviceCallback(service);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error parsing ESCL service");
+ }
+ };
+ }
+
+ public ILogger Logger { get; set; } = NullLogger.Instance;
+
+ public void Start()
+ {
+ if (_started) throw new InvalidOperationException("Already started");
+ _started = true;
+
+ Query();
+ }
+
+ private void Query()
+ {
+ if (_discovery.Mdns == null)
+ {
+ return;
+ }
+ // TODO: De-duplicate http/https services?
+ _discovery.QueryServiceInstances("_uscan._tcp");
+ _discovery.QueryServiceInstances("_uscans._tcp");
+
+ // We query once when we start, then again after 1s, 2s, etc. to account for race conditions where there was a
+ // previous query/answer on the network just before we started listening, which would prevent us from receiving
+ // a response. See the following:
+ //
+ // "When retransmitting Multicast DNS queries to implement continuous monitoring, the interval between the first
+ // two queries MUST be at least one second, and the intervals between successive queries MUST increase by at
+ // least a factor of two."
+ // https://datatracker.ietf.org/doc/html/rfc6762#section-5.2
+ //
+ // "A Multicast DNS responder MUST NOT multicast a record on a given interface until at least one second has
+ // elapsed since the last time that record was multicast on that particular interface."
+ // https://datatracker.ietf.org/doc/html/rfc6762#section-6
+ Task.Delay(_nextQueryInterval).ContinueWith(_ => Query());
+ _nextQueryInterval *= 2;
+ }
+
+ private EsclService ParseService(ServiceInstanceDiscoveryEventArgs args)
+ {
+ string name = args.ServiceInstanceName.Labels[0];
+ bool isTls = false;
+ IPAddress? ipv4 = null, ipv6 = null;
+ int port = -1;
+ int tlsPort = -1;
+ string? host = null;
+ var props = new Dictionary();
+ foreach (var record in args.Message.Answers.Concat(args.Message.AdditionalRecords))
+ {
+ Logger.LogTrace("{Type} {Record}", record.GetType().Name, record);
+ if (record is ARecord a)
+ {
+ ipv4 = a.Address;
+ }
+ if (record is AAAARecord aaaa)
+ {
+ ipv6 = aaaa.Address;
+ }
+ if (record is SRVRecord srv)
+ {
+ bool recordIsTls = srv.Name.IsSubdomainOf(DomainName.Join("_uscans", "_tcp", "local"));
+ if (recordIsTls)
+ {
+ tlsPort = srv.Port;
+ }
+ else
+ {
+ port = srv.Port;
+ }
+ if (host == null || recordIsTls)
+ {
+ // HTTPS overrides HTTP but not the other way around
+ host = srv.Target.ToString();
+ isTls = recordIsTls;
+ }
+ }
+ if (record is TXTRecord txt)
+ {
+ foreach (var str in txt.Strings)
+ {
+ var eq = str.IndexOf("=", StringComparison.Ordinal);
+ if (eq != -1)
+ {
+ props[str.Substring(0, eq).ToLowerInvariant()] = str.Substring(eq + 1);
+ }
+ }
+ }
+ }
+ string? uuid = Get(props, "uuid");
+ if ((ipv4 == null && ipv6 == null) || (port == -1 && tlsPort == -1) || host == null || uuid == null)
+ {
+ throw new ArgumentException("Missing host/IP/port/uuid");
+ }
+
+ return new EsclService
+ {
+ IpV4 = ipv4,
+ IpV6 = ipv6,
+ Host = host,
+ RemoteEndpoint = args.RemoteEndPoint.Address,
+ Port = port,
+ TlsPort = tlsPort,
+ Tls = isTls,
+ Uuid = uuid,
+ ScannerName = props["ty"],
+ RootUrl = props["rs"],
+ TxtVersion = Get(props, "txtvers"),
+ AdminUrl = Get(props, "adminurl"),
+ EsclVersion = Get(props, "Vers"),
+ Thumbnail = Get(props, "representation"),
+ Note = Get(props, "note"),
+ MimeTypes = Get(props, "pdl")?.Split(','),
+ ColorOptions = Get(props, "cs")?.Split(','),
+ SourceOptions = Get(props, "is"),
+ DuplexSupported = Get(props, "duplex")?.ToUpperInvariant() == "T"
+ };
+ }
+
+ private string? Get(Dictionary props, string key)
+ {
+ return props.TryGetValue(key, out var value) ? value : null;
+ }
+
+ public void Dispose()
+ {
+ _discovery.Dispose();
+ }
+
+ private record ServiceKey(string? ScannerName, string? Uuid, int Port, IPAddress? IpV4, IPAddress? IpV6);
+}
diff --git a/NAPS2.Escl/Client/RawDocument.cs b/NAPS2.Escl/Client/RawDocument.cs
new file mode 100644
index 0000000000..3336c2b7e6
--- /dev/null
+++ b/NAPS2.Escl/Client/RawDocument.cs
@@ -0,0 +1,10 @@
+namespace NAPS2.Escl.Client;
+
+public class RawDocument
+{
+ public required byte[] Data { get; init; }
+
+ public required string? ContentType { get; init; }
+
+ public string? ContentLocation { get; init; }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/CompilerAttributes.cs b/NAPS2.Escl/CompilerAttributes.cs
deleted file mode 100644
index 8230a289b1..0000000000
--- a/NAPS2.Escl/CompilerAttributes.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-// https://sergiopedri.medium.com/enabling-and-using-c-9-features-on-older-and-unsupported-runtimes-ce384d8debb
-// ReSharper disable once CheckNamespace
-
-namespace System.Runtime.CompilerServices
-{
- internal static class IsExternalInit
- {
- }
-
- /// Specifies that a type has required members or that a member is required.
- [AttributeUsage(
- AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property,
- AllowMultiple = false, Inherited = false)]
- internal sealed class RequiredMemberAttribute : Attribute
- {
- }
-
- ///
- /// Indicates that compiler support for a particular feature is required for the location where this attribute is applied.
- ///
- [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)]
- internal sealed class CompilerFeatureRequiredAttribute : Attribute
- {
- public CompilerFeatureRequiredAttribute(string featureName)
- {
- FeatureName = featureName;
- }
-
- ///
- /// The name of the compiler feature.
- ///
- public string FeatureName { get; }
-
- ///
- /// If true, the compiler can choose to allow access to the location where this attribute is applied if it does not understand .
- ///
- public bool IsOptional { get; init; }
-
- ///
- /// The used for the ref structs C# feature.
- ///
- public const string RefStructs = nameof(RefStructs);
-
- ///
- /// The used for the required members C# feature.
- ///
- public const string RequiredMembers = nameof(RequiredMembers);
- }
-}
-
-namespace System.Diagnostics.CodeAnalysis
-{
- ///
- /// Specifies that this constructor sets all required members for the current type, and callers
- /// do not need to set any required members themselves.
- ///
- [AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)]
- internal sealed class SetsRequiredMembersAttribute : Attribute
- {
- }
-}
\ No newline at end of file
diff --git a/NAPS2.Escl/DiscreteResolution.cs b/NAPS2.Escl/DiscreteResolution.cs
new file mode 100644
index 0000000000..65b7fdf1b6
--- /dev/null
+++ b/NAPS2.Escl/DiscreteResolution.cs
@@ -0,0 +1,3 @@
+namespace NAPS2.Escl;
+
+public record DiscreteResolution(int XResolution, int YResolution);
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclAdfState.cs b/NAPS2.Escl/EsclAdfState.cs
new file mode 100644
index 0000000000..7acc38421b
--- /dev/null
+++ b/NAPS2.Escl/EsclAdfState.cs
@@ -0,0 +1,17 @@
+namespace NAPS2.Escl;
+
+public enum EsclAdfState
+{
+ Unknown,
+ ScannerAdfProcessing,
+ ScannerAdfEmpty,
+ ScannerAdfJam,
+ ScannedAdfLoaded,
+ ScannerAdfMispick,
+ ScannerAdfHatchOpen,
+ ScannerAdfDuplexPageTooShort,
+ ScannerAdfDuplexPageTooLong,
+ ScannerAdfMultipickDetected,
+ ScannerAdfInputTrayFailed,
+ ScannerAdfInputTrayOverloaded
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclCapabilities.cs b/NAPS2.Escl/EsclCapabilities.cs
index 770c9aae4b..b2e9255f24 100644
--- a/NAPS2.Escl/EsclCapabilities.cs
+++ b/NAPS2.Escl/EsclCapabilities.cs
@@ -2,10 +2,19 @@ namespace NAPS2.Escl;
public class EsclCapabilities
{
- public string? Version { get; init; }
+ public const string DEFAULT_VERSION = "2.6";
+
+ public string Version { get; init; } = DEFAULT_VERSION;
public string? MakeAndModel { get; init; }
public string? SerialNumber { get; init; }
+ public string? Manufacturer { get; init; }
public string? Uuid { get; init; }
public string? AdminUri { get; init; }
public string? IconUri { get; init; }
+ public byte[]? IconPng { get; init; }
+ public string? Naps2Extensions { get; init; }
+ public EsclInputCaps? PlatenCaps { get; init; }
+ public EsclInputCaps? AdfSimplexCaps { get; init; }
+ public EsclInputCaps? AdfDuplexCaps { get; init; }
+ public EsclRange? CompressionFactorSupport { get; init; }
}
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclInputCaps.cs b/NAPS2.Escl/EsclInputCaps.cs
new file mode 100644
index 0000000000..5ba7d869d3
--- /dev/null
+++ b/NAPS2.Escl/EsclInputCaps.cs
@@ -0,0 +1,14 @@
+namespace NAPS2.Escl;
+
+public class EsclInputCaps
+{
+ // Units of 1/300 inch (per ESCL spec); supports A3 in both orientations
+ public const int DEFAULT_MAX_WIDTH = 5000;
+ public const int DEFAULT_MAX_HEIGHT = 5000;
+
+ public List SettingProfiles { get; init; } = new();
+ public int? MinWidth { get; set; }
+ public int? MaxWidth { get; set; }
+ public int? MinHeight { get; set; }
+ public int? MaxHeight { get; set; }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclInputSource.cs b/NAPS2.Escl/EsclInputSource.cs
new file mode 100644
index 0000000000..f839043f28
--- /dev/null
+++ b/NAPS2.Escl/EsclInputSource.cs
@@ -0,0 +1,7 @@
+namespace NAPS2.Escl;
+
+public enum EsclInputSource
+{
+ Platen,
+ Feeder
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclJob.cs b/NAPS2.Escl/EsclJob.cs
index cecc3c5cfc..13743300a6 100644
--- a/NAPS2.Escl/EsclJob.cs
+++ b/NAPS2.Escl/EsclJob.cs
@@ -2,5 +2,5 @@ namespace NAPS2.Escl;
public class EsclJob
{
- public required Uri Uri { get; init; }
+ public required string UriPath { get; init; }
}
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclJobState.cs b/NAPS2.Escl/EsclJobState.cs
new file mode 100644
index 0000000000..c3afd0d816
--- /dev/null
+++ b/NAPS2.Escl/EsclJobState.cs
@@ -0,0 +1,11 @@
+namespace NAPS2.Escl;
+
+public enum EsclJobState
+{
+ Unknown,
+ Pending,
+ Processing,
+ Completed,
+ Canceled,
+ Aborted
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclRange.cs b/NAPS2.Escl/EsclRange.cs
new file mode 100644
index 0000000000..f07988dd3e
--- /dev/null
+++ b/NAPS2.Escl/EsclRange.cs
@@ -0,0 +1,3 @@
+namespace NAPS2.Escl;
+
+public record EsclRange(int Min, int Max, int Normal, int Step);
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclScanSettings.cs b/NAPS2.Escl/EsclScanSettings.cs
index e725012080..937bacf540 100644
--- a/NAPS2.Escl/EsclScanSettings.cs
+++ b/NAPS2.Escl/EsclScanSettings.cs
@@ -1,5 +1,32 @@
namespace NAPS2.Escl;
-public class EsclScanSettings
+public record EsclScanSettings
{
+ public int Width { get; init; }
+
+ public int Height { get; init; }
+
+ public int XOffset { get; init; }
+
+ public int YOffset { get; init; }
+
+ public string? DocumentFormat { get; init; }
+
+ public EsclInputSource InputSource { get; init; }
+
+ public int XResolution { get; init; }
+
+ public int YResolution { get; init; }
+
+ public EsclColorMode ColorMode { get; init; }
+
+ public bool Duplex { get; init; }
+
+ public int Brightness { get; init; }
+
+ public int Contrast { get; init; }
+
+ public int Threshold { get; init; }
+
+ public int? CompressionFactor { get; set; }
}
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclScannerState.cs b/NAPS2.Escl/EsclScannerState.cs
new file mode 100644
index 0000000000..0b483cc352
--- /dev/null
+++ b/NAPS2.Escl/EsclScannerState.cs
@@ -0,0 +1,11 @@
+namespace NAPS2.Escl;
+
+public enum EsclScannerState
+{
+ Unknown,
+ Idle,
+ Processing,
+ Testing,
+ Stopped,
+ Down
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclScannerStatus.cs b/NAPS2.Escl/EsclScannerStatus.cs
index d3f6b35255..27bcf2a3dc 100644
--- a/NAPS2.Escl/EsclScannerStatus.cs
+++ b/NAPS2.Escl/EsclScannerStatus.cs
@@ -2,4 +2,7 @@ namespace NAPS2.Escl;
public class EsclScannerStatus
{
+ public EsclScannerState State { get; init; }
+ public EsclAdfState AdfState { get; init; }
+ public Dictionary JobStates { get; set; } = new();
}
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclSecurityPolicy.cs b/NAPS2.Escl/EsclSecurityPolicy.cs
new file mode 100644
index 0000000000..fdb532355c
--- /dev/null
+++ b/NAPS2.Escl/EsclSecurityPolicy.cs
@@ -0,0 +1,32 @@
+namespace NAPS2.Escl;
+
+[Flags]
+public enum EsclSecurityPolicy
+{
+ ///
+ /// Allow both HTTP and HTTPS connections.
+ ///
+ None = 0,
+
+ ServerDisableHttps = 1,
+ ServerRequireHttps = 2,
+ ServerRequireTrustedCertificate = 4,
+ ClientDisableHttps = 8,
+ ClientRequireHttps = 16,
+ ClientRequireTrustedCertificate = 32,
+
+ ///
+ /// Only allow HTTPS connections, but clients will accept self-signed certificates.
+ ///
+ RequireHttps = ServerRequireHttps | ClientRequireHttps,
+
+ ///
+ /// Only allow HTTPS connections, and clients will only accept trusted certificates.
+ ///
+ RequireTrustedCertificate = RequireHttps | ServerRequireTrustedCertificate | ClientRequireTrustedCertificate,
+
+ ///
+ /// Set the header "Access-Control-Allow-Origin: *" on all server responses.
+ ///
+ ServerAllowAnyOrigin = 64
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclSecurityPolicyViolationException.cs b/NAPS2.Escl/EsclSecurityPolicyViolationException.cs
new file mode 100644
index 0000000000..43bdc58af1
--- /dev/null
+++ b/NAPS2.Escl/EsclSecurityPolicyViolationException.cs
@@ -0,0 +1,3 @@
+namespace NAPS2.Escl;
+
+public class EsclSecurityPolicyViolationException(string message) : Exception(message);
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclSettingProfile.cs b/NAPS2.Escl/EsclSettingProfile.cs
index d6ebf2dec3..7247d19ffa 100644
--- a/NAPS2.Escl/EsclSettingProfile.cs
+++ b/NAPS2.Escl/EsclSettingProfile.cs
@@ -1,7 +1,14 @@
+using NAPS2.Escl.Client;
+
namespace NAPS2.Escl;
public class EsclSettingProfile
{
public string? Name { get; init; }
public List ColorModes { get; init; } = new();
+ public List DocumentFormats { get; init; } = new();
+ public List DocumentFormatsExt { get; init; } = new();
+ public List DiscreteResolutions { get; init; } = new();
+ public EsclRange? XResolutionRange { get; init; }
+ public EsclRange? YResolutionRange { get; init; }
}
\ No newline at end of file
diff --git a/NAPS2.Escl/EsclXmlHelper.cs b/NAPS2.Escl/EsclXmlHelper.cs
index 570e415e21..1b262e9c1f 100644
--- a/NAPS2.Escl/EsclXmlHelper.cs
+++ b/NAPS2.Escl/EsclXmlHelper.cs
@@ -16,6 +16,6 @@ public static string CreateDocAsString(XElement root)
root.Add(ScanNsAttr);
root.Add(PwgNsAttr);
var content = new XDocument(root);
- return $"{Decl}{Environment.NewLine}{content}";
+ return $"{Decl}{content}".Replace("\n", "");
}
}
\ No newline at end of file
diff --git a/NAPS2.Escl/LICENSE b/NAPS2.Escl/LICENSE
new file mode 100644
index 0000000000..099a807a65
--- /dev/null
+++ b/NAPS2.Escl/LICENSE
@@ -0,0 +1,518 @@
+NAPS2.Escl
+https://www.github.com/cyanfish/naps2/
+
+Copyright 2009-2025 NAPS2 Contributors
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+Lesser General Public License for more details.
+
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it. You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+ When we speak of free software, we are referring to freedom of use,
+not price. Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+ To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights. These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ To protect each distributor, we want to make it very clear that
+there is no warranty for the free library. Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+ Finally, software patents pose a constant threat to the existence of
+any free program. We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder. Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+ Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License. This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License. We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+ When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library. The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom. The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+ We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License. It also provides other free software developers Less
+of an advantage over competing non-free programs. These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries. However, the Lesser license provides advantages in certain
+special circumstances.
+
+ For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard. To achieve this, non-free programs must be
+allowed to use the library. A more frequent case is that a free
+library does the same job as widely used non-free libraries. In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+ In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software. For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+ Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (1) uses at run time a
+ copy of the library already present on the user's computer system,
+ rather than copying library functions into the executable, and (2)
+ will operate properly with a modified version of the library, if
+ the user installs one, as long as the modified version is
+ interface-compatible with the version that the work was made with.
+
+ c) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ d) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ e) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Libraries
+
+ If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change. You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+ To apply these terms, attach the following notices to the library. It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, write to the Free Software
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the
+ library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+ , 1 April 1990
+ Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/NAPS2.Escl/NAPS2.Escl.csproj b/NAPS2.Escl/NAPS2.Escl.csproj
index ba8827e4ce..3201eeedca 100644
--- a/NAPS2.Escl/NAPS2.Escl.csproj
+++ b/NAPS2.Escl/NAPS2.Escl.csproj
@@ -1,15 +1,36 @@
- net6.0;net462;netstandard2.0
+ net6;net8;net462;netstandard2.0
enable
enable
- 11
+ 12
+
+ NAPS2.Escl
+ NAPS2.Escl
+ ESCL client for NAPS2.Sdk.
+ naps2 escl
+
+
-
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>NAPS2.Escl.Server
+
+
+ <_Parameter1>NAPS2.Escl.Tests
+
diff --git a/NAPS2.Escl/ParseHelper.cs b/NAPS2.Escl/ParseHelper.cs
new file mode 100644
index 0000000000..eac775b98c
--- /dev/null
+++ b/NAPS2.Escl/ParseHelper.cs
@@ -0,0 +1,13 @@
+using System.Globalization;
+using System.Xml.Linq;
+
+namespace NAPS2.Escl;
+
+internal class ParseHelper
+{
+ public static T MaybeParseEnum(XElement? element, T defaultValue) where T : struct =>
+ Enum.TryParse(element?.Value, out var value) ? value : defaultValue;
+
+ public static int? MaybeParseInt(XElement? element) =>
+ int.TryParse(element?.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null;
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/Server/EsclDeviceConfig.cs b/NAPS2.Escl/Server/EsclDeviceConfig.cs
new file mode 100644
index 0000000000..87a54ed7b7
--- /dev/null
+++ b/NAPS2.Escl/Server/EsclDeviceConfig.cs
@@ -0,0 +1,12 @@
+namespace NAPS2.Escl.Server;
+
+public class EsclDeviceConfig
+{
+ public required EsclCapabilities Capabilities { get; init; }
+
+ public required Func CreateJob { get; init; }
+
+ public int Port { get; set; }
+
+ public int TlsPort { get; set; }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/Server/IEsclScanJob.cs b/NAPS2.Escl/Server/IEsclScanJob.cs
new file mode 100644
index 0000000000..1b7cf80398
--- /dev/null
+++ b/NAPS2.Escl/Server/IEsclScanJob.cs
@@ -0,0 +1,12 @@
+namespace NAPS2.Escl.Server;
+
+public interface IEsclScanJob : IDisposable
+{
+ string ContentType { get; }
+ void Cancel();
+ void RegisterStatusTransitionCallback(Action callback);
+ Task WaitForNextDocument(CancellationToken cancelToken);
+ Task WriteDocumentTo(Stream stream);
+ Task WriteProgressTo(Stream stream);
+ Task WriteErrorDetailsTo(Stream stream);
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/Server/IEsclServer.cs b/NAPS2.Escl/Server/IEsclServer.cs
new file mode 100644
index 0000000000..9d9c915a57
--- /dev/null
+++ b/NAPS2.Escl/Server/IEsclServer.cs
@@ -0,0 +1,15 @@
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Logging;
+
+namespace NAPS2.Escl.Server;
+
+public interface IEsclServer : IDisposable
+{
+ void AddDevice(EsclDeviceConfig deviceConfig);
+ void RemoveDevice(EsclDeviceConfig deviceConfig);
+ Task Start();
+ Task Stop();
+ public EsclSecurityPolicy SecurityPolicy { get; set; }
+ public X509Certificate2? Certificate { get; set; }
+ ILogger Logger { get; set; }
+}
\ No newline at end of file
diff --git a/NAPS2.Escl/Server/StatusTransition.cs b/NAPS2.Escl/Server/StatusTransition.cs
new file mode 100644
index 0000000000..c069b8a97b
--- /dev/null
+++ b/NAPS2.Escl/Server/StatusTransition.cs
@@ -0,0 +1,9 @@
+namespace NAPS2.Escl.Server;
+
+public enum StatusTransition
+{
+ CancelJob,
+ AbortJob,
+ ScanComplete,
+ PageComplete
+}
\ No newline at end of file
diff --git a/NAPS2.Images.Gdi/GdiConverters.cs b/NAPS2.Images.Gdi/GdiConverters.cs
deleted file mode 100644
index 5952ce28e7..0000000000
--- a/NAPS2.Images.Gdi/GdiConverters.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-// using System.Drawing;
-// using System.Drawing.Imaging;
-//
-// namespace NAPS2.Images.Gdi;
-//
-// public class GdiConverters
-// {
-// [StorageConverter]
-// public FileStorage ConvertToFile(GdiImage input, StorageConvertParams convertParams)
-// {
-// if (convertParams.Temporary)
-// {
-// var path = Path.Combine(Paths.Temp, Path.GetRandomFileName());
-// input.Bitmap.Save(path);
-// return new FileStorage(path);
-// }
-// else
-// {
-// var tempPath = ScannedImageHelper.SaveSmallestBitmap(input.Bitmap, convertParams.BitDepth, convertParams.Lossless, convertParams.LossyQuality, out ImageFormat fileFormat);
-// string ext = Equals(fileFormat, ImageFormat.Png) ? ".png" : ".jpg";
-// var path = _imageContext.FileStorageManager.NextFilePath() + ext;
-// File.Move(tempPath, path);
-// return new FileStorage(path);
-// }
-// }
-//
-// [StorageConverter]
-// public GdiImage ConvertToGdi(FileStorage input, StorageConvertParams convertParams)
-// {
-// // TODO: Allow multiple converters (with priority?) and fall back to the next if it returns null
-// // Then we can have a PDF->Image converter that returns null if it's not a pdf file.
-// if (IsPdfFile(input))
-// {
-// return (GdiImage)_imageContext.PdfRenderer.Render(input.FullPath, 300).Single();
-// }
-// else
-// {
-// return new GdiImage(new Bitmap(input.FullPath));
-// }
-// }
-//
-// private static bool IsPdfFile(FileStorage fileStorage) => Path.GetExtension(fileStorage.FullPath)?.Equals(".pdf", StringComparison.InvariantCultureIgnoreCase) ?? false;
-//
-// }
\ No newline at end of file
diff --git a/NAPS2.Images.Gdi/GdiExtensions.cs b/NAPS2.Images.Gdi/GdiExtensions.cs
index a7cd8a6925..7049817c55 100644
--- a/NAPS2.Images.Gdi/GdiExtensions.cs
+++ b/NAPS2.Images.Gdi/GdiExtensions.cs
@@ -3,9 +3,7 @@
namespace NAPS2.Images.Gdi;
-#if NET6_0_OR_GREATER
[System.Runtime.Versioning.SupportedOSPlatform("windows7.0")]
-#endif
public static class GdiExtensions
{
public static Bitmap RenderToBitmap(this IRenderableImage image)
@@ -63,7 +61,7 @@ public static ImageFileFormat AsImageFileFormat(this ImageFormat imageFormat)
{
return ImageFileFormat.Tiff;
}
- return ImageFileFormat.Unspecified;
+ return ImageFileFormat.Unknown;
}
public static PixelFormat AsPixelFormat(this ImagePixelFormat pixelFormat)
@@ -95,7 +93,7 @@ public static ImagePixelFormat AsImagePixelFormat(this PixelFormat pixelFormat)
case PixelFormat.Format1bppIndexed:
return ImagePixelFormat.BW1;
default:
- return ImagePixelFormat.Unsupported;
+ return ImagePixelFormat.Unknown;
}
}
diff --git a/NAPS2.Images.Gdi/GdiImage.cs b/NAPS2.Images.Gdi/GdiImage.cs
index 5005166694..b0d500c1d4 100644
--- a/NAPS2.Images.Gdi/GdiImage.cs
+++ b/NAPS2.Images.Gdi/GdiImage.cs
@@ -7,15 +7,11 @@ namespace NAPS2.Images.Gdi;
///
/// An implementation of IMemoryImage that wraps a GDI+ image (System.Drawing.Bitmap).
///
-#if NET6_0_OR_GREATER
[System.Runtime.Versioning.SupportedOSPlatform("windows7.0")]
-#endif
public class GdiImage : IMemoryImage
{
- public GdiImage(ImageContext imageContext, Bitmap bitmap)
+ public GdiImage(Bitmap bitmap)
{
- if (imageContext is not GdiImageContext) throw new ArgumentException("Expected GdiImageContext");
- ImageContext = imageContext;
if (bitmap == null)
{
throw new ArgumentNullException(nameof(bitmap));
@@ -23,10 +19,9 @@ public GdiImage(ImageContext imageContext, Bitmap bitmap)
FixedPixelFormat = GdiPixelFormatFixer.MaybeFixPixelFormat(ref bitmap);
Bitmap = bitmap;
OriginalFileFormat = bitmap.RawFormat.AsImageFileFormat();
- LogicalPixelFormat = PixelFormat;
}
- public ImageContext ImageContext { get; }
+ public ImageContext ImageContext { get; } = new GdiImageContext();
///
/// Gets the underlying System.Drawing.Bitmap object for this image.
@@ -49,6 +44,10 @@ public GdiImage(ImageContext imageContext, Bitmap bitmap)
public ImageLockState Lock(LockMode lockMode, out BitwiseImageData imageData)
{
+ if (lockMode != LockMode.ReadOnly)
+ {
+ LogicalPixelFormat = ImagePixelFormat.Unknown;
+ }
return GdiImageLockState.Create(Bitmap, lockMode, out imageData);
}
@@ -57,39 +56,43 @@ public ImageLockState Lock(LockMode lockMode, out BitwiseImageData imageData)
public ImagePixelFormat LogicalPixelFormat { get; set; }
- public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified, int quality = -1)
+ public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unknown, ImageSaveOptions? options = null)
{
- if (imageFormat == ImageFileFormat.Unspecified)
+ if (imageFormat == ImageFileFormat.Unknown)
{
imageFormat = ImageContext.GetFileFormatFromExtension(path);
}
ImageContext.CheckSupportsFormat(imageFormat);
- if (imageFormat == ImageFileFormat.Jpeg && quality != -1)
+ options ??= new ImageSaveOptions();
+ using var helper = PixelFormatHelper.Create(this, options.PixelFormatHint);
+ if (imageFormat == ImageFileFormat.Jpeg && options.Quality != -1)
{
- var (encoder, encoderParams) = GetJpegSaveArgs(quality);
- Bitmap.Save(path, encoder, encoderParams);
+ var (encoder, encoderParams) = GetJpegSaveArgs(options.Quality);
+ helper.Image.Bitmap.Save(path, encoder, encoderParams);
}
else
{
- Bitmap.Save(path, imageFormat.AsImageFormat());
+ helper.Image.Bitmap.Save(path, imageFormat.AsImageFormat());
}
}
- public void Save(Stream stream, ImageFileFormat imageFormat, int quality = -1)
+ public void Save(Stream stream, ImageFileFormat imageFormat, ImageSaveOptions? options = null)
{
- if (imageFormat == ImageFileFormat.Unspecified)
+ if (imageFormat == ImageFileFormat.Unknown)
{
throw new ArgumentException("Format required to save to a stream", nameof(imageFormat));
}
ImageContext.CheckSupportsFormat(imageFormat);
- if (imageFormat == ImageFileFormat.Jpeg && quality != -1)
+ options ??= new ImageSaveOptions();
+ using var helper = PixelFormatHelper.Create(this, options.PixelFormatHint);
+ if (imageFormat == ImageFileFormat.Jpeg && options.Quality != -1)
{
- var (encoder, encoderParams) = GetJpegSaveArgs(quality);
- Bitmap.Save(stream, encoder, encoderParams);
+ var (encoder, encoderParams) = GetJpegSaveArgs(options.Quality);
+ helper.Image.Bitmap.Save(stream, encoder, encoderParams);
}
else
{
- Bitmap.Save(stream, imageFormat.AsImageFormat());
+ helper.Image.Bitmap.Save(stream, imageFormat.AsImageFormat());
}
}
@@ -104,11 +107,9 @@ private static (ImageCodecInfo, EncoderParameters) GetJpegSaveArgs(int quality)
public IMemoryImage Clone()
{
- var newImage = new GdiImage(ImageContext, (Bitmap) Bitmap.Clone());
- // TODO: We want to make these more consistent when copying around and transforming images
- newImage.OriginalFileFormat = OriginalFileFormat;
- newImage.LogicalPixelFormat = LogicalPixelFormat;
- return newImage;
+ // TODO: Ideally we'd like to make use of copy-on-write. But GDI copy-on-write (Clone) is not thread safe.
+ // Maybe we can implement something like CopyOnWrite to use instead of just Bitmap.
+ return this.Copy();
}
public void Dispose()
diff --git a/NAPS2.Images.Gdi/GdiImageContext.cs b/NAPS2.Images.Gdi/GdiImageContext.cs
index 066b0d8557..d2d25ce922 100644
--- a/NAPS2.Images.Gdi/GdiImageContext.cs
+++ b/NAPS2.Images.Gdi/GdiImageContext.cs
@@ -4,18 +4,12 @@
namespace NAPS2.Images.Gdi;
-#if NET6_0_OR_GREATER
[System.Runtime.Versioning.SupportedOSPlatform("windows7.0")]
-#endif
public class GdiImageContext : ImageContext
{
private readonly GdiImageTransformer _imageTransformer;
- public GdiImageContext() : this(null)
- {
- }
-
- public GdiImageContext(IPdfRenderer? pdfRenderer) : base(typeof(GdiImage), pdfRenderer)
+ public GdiImageContext() : base(typeof(GdiImage))
{
_imageTransformer = new GdiImageTransformer(this);
}
@@ -30,8 +24,9 @@ public override IMemoryImage PerformTransform(IMemoryImage image, Transform tran
protected override IMemoryImage LoadCore(Stream stream, ImageFileFormat format)
{
- stream = EnsureMemoryStream(stream);
- return new GdiImage(this, new Bitmap(stream));
+ var memoryStream = EnsureMemoryStream(stream);
+ using var bitmap = new Bitmap(memoryStream);
+ return new GdiImage(bitmap).Copy();
}
protected override void LoadFramesCore(Action produceImage, Stream stream,
@@ -45,7 +40,7 @@ protected override void LoadFramesCore(Action produceImage, Stream
progress.Report(i, count);
if (progress.IsCancellationRequested) break;
bitmap.SelectActiveFrame(FrameDimension.Page, i);
- produceImage(new GdiImage(this, bitmap).Copy());
+ produceImage(new GdiImage(bitmap).Copy());
}
progress.Report(count, count);
}
@@ -88,6 +83,6 @@ public override IMemoryImage Create(int width, int height, ImagePixelFormat pixe
}
bitmap.Palette = p;
}
- return new GdiImage(this, bitmap);
+ return new GdiImage(bitmap);
}
}
\ No newline at end of file
diff --git a/NAPS2.Images.Gdi/GdiImageLockState.cs b/NAPS2.Images.Gdi/GdiImageLockState.cs
index 3ee9f78b03..b948ade638 100644
--- a/NAPS2.Images.Gdi/GdiImageLockState.cs
+++ b/NAPS2.Images.Gdi/GdiImageLockState.cs
@@ -4,10 +4,8 @@
namespace NAPS2.Images.Gdi;
-#if NET6_0_OR_GREATER
[System.Runtime.Versioning.SupportedOSPlatform("windows7.0")]
-#endif
-public class GdiImageLockState : ImageLockState
+internal class GdiImageLockState : ImageLockState
{
public static GdiImageLockState Create(Bitmap bitmap, LockMode lockMode, out BitwiseImageData imageData)
{
diff --git a/NAPS2.Images.Gdi/GdiImageTransformer.cs b/NAPS2.Images.Gdi/GdiImageTransformer.cs
index 9987b5231a..5f71b7489c 100644
--- a/NAPS2.Images.Gdi/GdiImageTransformer.cs
+++ b/NAPS2.Images.Gdi/GdiImageTransformer.cs
@@ -4,10 +4,8 @@
namespace NAPS2.Images.Gdi;
-#if NET6_0_OR_GREATER
[System.Runtime.Versioning.SupportedOSPlatform("windows7.0")]
-#endif
-public class GdiImageTransformer : AbstractImageTransformer
+internal class GdiImageTransformer : AbstractImageTransformer
{
public GdiImageTransformer(ImageContext imageContext) : base(imageContext)
{
@@ -53,7 +51,7 @@ protected override GdiImage PerformTransform(GdiImage image, RotationTransform t
g.TranslateTransform(-image.Width / 2.0f, -image.Height / 2.0f);
g.DrawImage(image.Bitmap, new Rectangle(0, 0, image.Width, image.Height));
}
- var resultImage = new GdiImage(ImageContext, result);
+ var resultImage = new GdiImage(result);
OptimizePixelFormat(image, ref resultImage);
image.Dispose();
return resultImage;
@@ -61,7 +59,10 @@ protected override GdiImage PerformTransform(GdiImage image, RotationTransform t
protected override GdiImage PerformTransform(GdiImage image, ResizeTransform transform)
{
- var result = new Bitmap(transform.Width, transform.Height, PixelFormat.Format24bppRgb);
+ var result = new Bitmap(transform.Width, transform.Height,
+ image.PixelFormat == ImagePixelFormat.ARGB32
+ ? PixelFormat.Format32bppArgb
+ : PixelFormat.Format24bppRgb);
using Graphics g = Graphics.FromImage(result);
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
// We set WrapMode to avoid artifacts
@@ -82,6 +83,6 @@ protected override GdiImage PerformTransform(GdiImage image, ResizeTransform tra
image.HorizontalResolution * image.Width / transform.Width,
image.VerticalResolution * image.Height / transform.Height);
image.Dispose();
- return new GdiImage(ImageContext, result);
+ return new GdiImage(result);
}
}
\ No newline at end of file
diff --git a/NAPS2.Images.Gdi/GdiPixelFormatFixer.cs b/NAPS2.Images.Gdi/GdiPixelFormatFixer.cs
index 75bbd84a47..190f9e3e9d 100644
--- a/NAPS2.Images.Gdi/GdiPixelFormatFixer.cs
+++ b/NAPS2.Images.Gdi/GdiPixelFormatFixer.cs
@@ -7,9 +7,7 @@ namespace NAPS2.Images.Gdi;
///
/// Ensures that bitmaps use a standard pixel format/palette.
///
-#if NET6_0_OR_GREATER
[System.Runtime.Versioning.SupportedOSPlatform("windows7.0")]
-#endif
internal static class GdiPixelFormatFixer
{
public static bool MaybeFixPixelFormat(ref Bitmap bitmap)
diff --git a/NAPS2.Images.Gdi/GdiTiffWriter.cs b/NAPS2.Images.Gdi/GdiTiffWriter.cs
index 96bc1be0d6..34846a5c67 100644
--- a/NAPS2.Images.Gdi/GdiTiffWriter.cs
+++ b/NAPS2.Images.Gdi/GdiTiffWriter.cs
@@ -4,9 +4,7 @@
namespace NAPS2.Images.Gdi;
-#if NET6_0_OR_GREATER
[System.Runtime.Versioning.SupportedOSPlatform("windows7.0")]
-#endif
internal class GdiTiffWriter : ITiffWriter
{
public bool SaveTiff(IList images, string path,
diff --git a/NAPS2.Images.Gdi/IsExternalInit.cs b/NAPS2.Images.Gdi/IsExternalInit.cs
deleted file mode 100644
index ba0435b8b5..0000000000
--- a/NAPS2.Images.Gdi/IsExternalInit.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-// https://sergiopedri.medium.com/enabling-and-using-c-9-features-on-older-and-unsupported-runtimes-ce384d8debb
-// ReSharper disable once CheckNamespace
-namespace System.Runtime.CompilerServices;
-
-public static class IsExternalInit {}
\ No newline at end of file
diff --git a/NAPS2.Images.Gdi/LICENSE b/NAPS2.Images.Gdi/LICENSE
index 3f89270f9d..f62d4e9cab 100644
--- a/NAPS2.Images.Gdi/LICENSE
+++ b/NAPS2.Images.Gdi/LICENSE
@@ -1,7 +1,7 @@
NAPS2.Images
https://www.github.com/cyanfish/naps2/
-Copyright 2009-2022 NAPS2 Contributors
+Copyright 2009-2025 NAPS2 Contributors
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
diff --git a/NAPS2.Images.Gdi/NAPS2.Images.Gdi.csproj b/NAPS2.Images.Gdi/NAPS2.Images.Gdi.csproj
index 50eaff4b02..591dbfd1f6 100644
--- a/NAPS2.Images.Gdi/NAPS2.Images.Gdi.csproj
+++ b/NAPS2.Images.Gdi/NAPS2.Images.Gdi.csproj
@@ -1,19 +1,24 @@
- net6;net462;netstandard2.0
+ net6;net8;net462;netstandard2.0
enable
true
false
NAPS2.Images.Gdi
NAPS2.Images.Gdi
- Copyright 2022 Ben Olden-Cooligan
+ NAPS2.Images.Gdi
+ Images based on System.Drawing.Bitmap for NAPS2.Sdk.
+ naps2
+
+
-
+
+
diff --git a/NAPS2.Images.Gtk/GtkExtensions.cs b/NAPS2.Images.Gtk/GtkExtensions.cs
new file mode 100644
index 0000000000..ec978c01c1
--- /dev/null
+++ b/NAPS2.Images.Gtk/GtkExtensions.cs
@@ -0,0 +1,19 @@
+using Gdk;
+
+namespace NAPS2.Images.Gtk;
+
+public static class GtkExtensions
+{
+ public static Pixbuf RenderToPixbuf(this IRenderableImage image)
+ {
+ var gtkImageContext = image.ImageContext as GtkImageContext ??
+ throw new ArgumentException("The provided image does not have a GtkImageContext");
+ return gtkImageContext.RenderToPixbuf(image);
+ }
+
+ public static Pixbuf AsPixbuf(this IMemoryImage image)
+ {
+ var gtkImage = image as GtkImage ?? throw new ArgumentException("Expected a GtkImage", nameof(image));
+ return gtkImage.Pixbuf;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images.Gtk/GtkImage.cs b/NAPS2.Images.Gtk/GtkImage.cs
index ab6e57989e..0fa29df0c7 100644
--- a/NAPS2.Images.Gtk/GtkImage.cs
+++ b/NAPS2.Images.Gtk/GtkImage.cs
@@ -7,18 +7,15 @@ namespace NAPS2.Images.Gtk;
public class GtkImage : IMemoryImage
{
- public GtkImage(ImageContext imageContext, Pixbuf pixbuf)
+ public GtkImage(Pixbuf pixbuf)
{
- if (imageContext is not GtkImageContext) throw new ArgumentException("Expected GtkImageContext");
LeakTracer.StartTracking(this);
- ImageContext = imageContext;
Pixbuf = pixbuf;
- LogicalPixelFormat = PixelFormat;
HorizontalResolution = float.TryParse(pixbuf.GetOption("x-dpi"), out var xDpi) ? xDpi : 0;
VerticalResolution = float.TryParse(pixbuf.GetOption("y-dpi"), out var yDpi) ? yDpi : 0;
}
- public ImageContext ImageContext { get; }
+ public ImageContext ImageContext { get; } = new GtkImageContext();
public Pixbuf Pixbuf { get; }
@@ -45,6 +42,10 @@ public void SetResolution(float xDpi, float yDpi)
public ImageLockState Lock(LockMode lockMode, out BitwiseImageData imageData)
{
+ if (lockMode != LockMode.ReadOnly)
+ {
+ LogicalPixelFormat = ImagePixelFormat.Unknown;
+ }
var ptr = Pixbuf.Pixels;
var stride = Pixbuf.Rowstride;
var subPixelType = PixelFormat switch
@@ -58,7 +59,7 @@ public ImageLockState Lock(LockMode lockMode, out BitwiseImageData imageData)
}
// TODO: Should we implement some kind of actual locking?
- public class GtkImageLockState : ImageLockState
+ internal class GtkImageLockState : ImageLockState
{
public override void Dispose()
{
@@ -69,39 +70,46 @@ public override void Dispose()
public ImagePixelFormat LogicalPixelFormat { get; set; }
- public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified, int quality = -1)
+ public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unknown,
+ ImageSaveOptions? options = null)
{
- if (imageFormat == ImageFileFormat.Unspecified)
+ if (imageFormat == ImageFileFormat.Unknown)
{
imageFormat = ImageContext.GetFileFormatFromExtension(path);
}
if (imageFormat == ImageFileFormat.Tiff)
{
- ((GtkImageContext) ImageContext).TiffIo.SaveTiff(new List { this }, path);
+ ((GtkImageContext) ImageContext).TiffIo.SaveTiff([this], path);
return;
}
ImageContext.CheckSupportsFormat(imageFormat);
+ options ??= new ImageSaveOptions();
var type = GetType(imageFormat);
- var (keys, values) = GetSaveOptions(imageFormat, quality);
- Pixbuf.Savev(path, type, keys, values);
+ var (keys, values) = GetSaveOptions(imageFormat, options.Quality);
+ using var helper = PixelFormatHelper.Create(this, options.PixelFormatHint, minFormat: ImagePixelFormat.RGB24);
+ helper.Image.Pixbuf.Savev(path, type, keys, values);
}
- public void Save(Stream stream, ImageFileFormat imageFormat, int quality = -1)
+ public void Save(Stream stream, ImageFileFormat imageFormat, ImageSaveOptions? options = null)
{
- if (imageFormat == ImageFileFormat.Unspecified)
+ if (imageFormat == ImageFileFormat.Unknown)
{
throw new ArgumentException("Format required to save to a stream", nameof(imageFormat));
}
if (imageFormat == ImageFileFormat.Tiff)
{
- ((GtkImageContext) ImageContext).TiffIo.SaveTiff(new List { this }, stream);
+ ((GtkImageContext) ImageContext).TiffIo.SaveTiff([this], stream);
return;
}
ImageContext.CheckSupportsFormat(imageFormat);
+ options ??= new ImageSaveOptions();
var type = GetType(imageFormat);
- var (keys, values) = GetSaveOptions(imageFormat, quality);
+ var (keys, values) = GetSaveOptions(imageFormat, options.Quality);
+ // TODO: GDK doesn't support optimizing bit depth (e.g. 1bit/8bit instead of 24bit/32bit) for BMP/PNG/JPEG.
+ // We'd probably need to use libpng/libjpeg etc. directly to fix that.
+ using var helper = PixelFormatHelper.Create(this, options.PixelFormatHint, minFormat: ImagePixelFormat.RGB24);
// TODO: Map to OutputStream directly?
- stream.Write(Pixbuf.SaveToBuffer(type, keys, values));
+ stream.Write(helper.Image.Pixbuf.SaveToBuffer(type, keys, values));
}
private string GetType(ImageFileFormat fileFormat) => fileFormat switch
@@ -132,7 +140,7 @@ public void Save(Stream stream, ImageFileFormat imageFormat, int quality = -1)
return (keys.ToArray(), values.ToArray());
}
- public IMemoryImage Clone() => new GtkImage(ImageContext, (Pixbuf) Pixbuf.Clone())
+ public IMemoryImage Clone() => new GtkImage((Pixbuf) Pixbuf.Clone())
{
OriginalFileFormat = OriginalFileFormat,
LogicalPixelFormat = LogicalPixelFormat,
diff --git a/NAPS2.Images.Gtk/GtkImageContext.cs b/NAPS2.Images.Gtk/GtkImageContext.cs
index d30edb19f8..102a54aee7 100644
--- a/NAPS2.Images.Gtk/GtkImageContext.cs
+++ b/NAPS2.Images.Gtk/GtkImageContext.cs
@@ -9,7 +9,7 @@ public class GtkImageContext : ImageContext
private readonly GtkImageTransformer _imageTransformer;
private readonly LibTiffIo _tiffIo;
- public GtkImageContext(IPdfRenderer? pdfRenderer = null) : base(typeof(GtkImage), pdfRenderer)
+ public GtkImageContext() : base(typeof(GtkImage))
{
_imageTransformer = new GtkImageTransformer(this);
_tiffIo = new LibTiffIo(this);
@@ -32,7 +32,7 @@ protected override IMemoryImage LoadCore(Stream stream, ImageFileFormat format)
_tiffIo.LoadTiff(img => { image = img; cts.Cancel(); }, stream, cts.Token);
return image;
}
- return new GtkImage(this, new Pixbuf(stream));
+ return new GtkImage(new Pixbuf(stream));
}
protected override void LoadFramesCore(Action produceImage, Stream stream,
@@ -53,13 +53,18 @@ protected override void LoadFramesCore(Action produceImage, Stream
internal LibTiffIo TiffIo => _tiffIo;
+ public Pixbuf RenderToPixbuf(IRenderableImage image)
+ {
+ return ((GtkImage) Render(image)).Pixbuf;
+ }
+
public override IMemoryImage Create(int width, int height, ImagePixelFormat pixelFormat)
{
- if (pixelFormat == ImagePixelFormat.Unsupported)
+ if (pixelFormat == ImagePixelFormat.Unknown)
{
throw new ArgumentException("Unsupported pixel format");
}
var pixbuf = new Pixbuf(Colorspace.Rgb, pixelFormat == ImagePixelFormat.ARGB32, 8, width, height);
- return new GtkImage(this, pixbuf);
+ return new GtkImage(pixbuf);
}
}
\ No newline at end of file
diff --git a/NAPS2.Images.Gtk/GtkImageTransformer.cs b/NAPS2.Images.Gtk/GtkImageTransformer.cs
index 7c786c9a45..417f5dcfae 100644
--- a/NAPS2.Images.Gtk/GtkImageTransformer.cs
+++ b/NAPS2.Images.Gtk/GtkImageTransformer.cs
@@ -4,7 +4,7 @@
namespace NAPS2.Images.Gtk;
-public class GtkImageTransformer : AbstractImageTransformer
+internal class GtkImageTransformer : AbstractImageTransformer
{
public GtkImageTransformer(ImageContext imageContext) : base(imageContext)
{
@@ -31,11 +31,9 @@ protected override GtkImage PerformTransform(GtkImage image, RotationTransform t
context.Translate(-image.Width / 2.0, -image.Height / 2.0);
CairoHelper.SetSourcePixbuf(context, image.Pixbuf, 0, 0);
context.Paint();
- var newImage = new GtkImage(ImageContext, new Pixbuf(surface, 0, 0, width, height));
- // TODO: In Gdi, we convert this back to BW1. Should we do the same?
- newImage.LogicalPixelFormat = image.LogicalPixelFormat == ImagePixelFormat.BW1
- ? ImagePixelFormat.Gray8
- : image.LogicalPixelFormat;
+ var newImage = new GtkImage(new Pixbuf(surface, 0, 0, width, height));
+ OptimizePixelFormat(image, ref newImage);
+ newImage.LogicalPixelFormat = image.LogicalPixelFormat;
newImage.SetResolution(xres, yres);
image.Dispose();
return newImage;
@@ -43,13 +41,15 @@ protected override GtkImage PerformTransform(GtkImage image, RotationTransform t
protected override GtkImage PerformTransform(GtkImage image, ResizeTransform transform)
{
+ // TODO: Can we improve interpolation? Somehow integrate Cairo.Filter.Bilinear or Cairo.Filter.Best, though
+ // it's not clear how to reconcile that with SetSourcePixbuf.
var format = image.PixelFormat == ImagePixelFormat.ARGB32 ? Format.Argb32 : Format.Rgb24;
using var surface = new ImageSurface(format, transform.Width, transform.Height);
using var context = new Context(surface);
context.Scale(transform.Width / (double) image.Width, transform.Height / (double) image.Height);
CairoHelper.SetSourcePixbuf(context, image.Pixbuf, 0, 0);
context.Paint();
- var newImage = new GtkImage(ImageContext, new Pixbuf(surface, 0, 0, transform.Width, transform.Height));
+ var newImage = new GtkImage(new Pixbuf(surface, 0, 0, transform.Width, transform.Height));
newImage.LogicalPixelFormat = image.LogicalPixelFormat == ImagePixelFormat.BW1
? ImagePixelFormat.Gray8
: image.LogicalPixelFormat;
@@ -69,4 +69,14 @@ protected override GtkImage PerformTransform(GtkImage image, BlackWhiteTransform
image.LogicalPixelFormat = ImagePixelFormat.BW1;
return image;
}
+
+ protected override GtkImage PerformTransform(GtkImage image, GrayscaleTransform transform)
+ {
+ new DecolorBitwiseImageOp(false).Perform(image);
+ if (image.LogicalPixelFormat != ImagePixelFormat.Unknown)
+ {
+ image.LogicalPixelFormat = ImagePixelFormat.Gray8;
+ }
+ return image;
+ }
}
\ No newline at end of file
diff --git a/NAPS2.Images.Gtk/LICENSE b/NAPS2.Images.Gtk/LICENSE
index 3f89270f9d..f62d4e9cab 100644
--- a/NAPS2.Images.Gtk/LICENSE
+++ b/NAPS2.Images.Gtk/LICENSE
@@ -1,7 +1,7 @@
NAPS2.Images
https://www.github.com/cyanfish/naps2/
-Copyright 2009-2022 NAPS2 Contributors
+Copyright 2009-2025 NAPS2 Contributors
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
diff --git a/NAPS2.Images.Gtk/LibTiff.cs b/NAPS2.Images.Gtk/LibTiff.cs
index 700e63b5ba..5919af8375 100644
--- a/NAPS2.Images.Gtk/LibTiff.cs
+++ b/NAPS2.Images.Gtk/LibTiff.cs
@@ -1,4 +1,5 @@
using System.Runtime.InteropServices;
+using NAPS2.Util;
using toff_t = System.IntPtr;
using tsize_t = System.IntPtr;
using thandle_t = System.IntPtr;
@@ -8,24 +9,64 @@ namespace NAPS2.Images.Gtk;
internal static class LibTiff
{
- // TODO: String marshalling?
- [DllImport("libtiff.so.5")]
- public static extern IntPtr TIFFOpen(string filename, string mode);
-
- [DllImport("libtiff.so.5")]
- public static extern IntPtr TIFFSetErrorHandler(TIFFErrorHandler handler);
-
- [DllImport("libtiff.so.5")]
- public static extern IntPtr TIFFSetWarningHandler(TIFFErrorHandler handler);
+ private const int RTLD_LAZY = 1;
+ private const int RTLD_GLOBAL = 8;
+
+ private static readonly Dictionary FuncCache = new();
+
+ private static readonly Lazy LibraryHandle = new(() =>
+ {
+ var handle = dlopen("libtiff.so.5", RTLD_LAZY | RTLD_GLOBAL);
+ if (handle == IntPtr.Zero)
+ {
+ handle = dlopen("libtiff.so.6", RTLD_LAZY | RTLD_GLOBAL);
+ }
+ if (handle == IntPtr.Zero)
+ {
+ var error = dlerror();
+ throw new InvalidOperationException($"Could not load library: \"libtiff\". Error: {error}");
+ }
+ return handle;
+ });
+
+ public static T Load()
+ {
+ return (T) FuncCache.Get(typeof(T), () => Marshal.GetDelegateForFunctionPointer(LoadFunc())!);
+ }
+
+ private static IntPtr LoadFunc()
+ {
+ var symbol = typeof(T).Name.Split("_")[0];
+ var ptr = dlsym(LibraryHandle.Value, symbol);
+ if (ptr == IntPtr.Zero)
+ {
+ var error = dlerror();
+ throw new InvalidOperationException($"Could not load symbol: \"{symbol}\". Error: {error}");
+ }
+ return ptr;
+ }
+
+ public delegate IntPtr TIFFOpen_d(string filename, string mode);
+
+ public static TIFFOpen_d TIFFOpen => Load();
+
+ public delegate IntPtr TIFFSetErrorHandler_d(TIFFErrorHandler handler);
+
+ public static TIFFSetErrorHandler_d TIFFSetErrorHandler => Load();
+
+ public delegate IntPtr TIFFSetWarningHandler_d(TIFFErrorHandler handler);
+
+ public static TIFFSetWarningHandler_d TIFFSetWarningHandler => Load();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void TIFFErrorHandler(string x, string y, IntPtr va_args);
- [DllImport("libtiff.so.5")]
- public static extern IntPtr TIFFClientOpen(string filename, string mode, IntPtr clientdata,
+ public delegate IntPtr TIFFClientOpen_d(string filename, string mode, IntPtr clientdata,
TIFFReadWriteProc readproc, TIFFReadWriteProc writeproc, TIFFSeekProc seekproc, TIFFCloseProc closeproc,
TIFFSizeProc sizeproc, TIFFMapFileProc mapproc, TIFFUnmapFileProc unmapproc);
+ public static TIFFClientOpen_d TIFFClientOpen => Load();
+
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate tsize_t TIFFReadWriteProc(thandle_t clientdata, tdata_t data, tsize_t size);
@@ -44,52 +85,71 @@ public static extern IntPtr TIFFClientOpen(string filename, string mode, IntPtr
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void TIFFUnmapFileProc(thandle_t clientdata, tdata_t a, toff_t b);
- [DllImport("libtiff.so.5")]
- public static extern IntPtr TIFFClose(IntPtr tiff);
+ public delegate IntPtr TIFFClose_d(IntPtr tiff);
+
+ public static TIFFClose_d TIFFClose => Load();
+
+ public delegate short TIFFNumberOfDirectories_d(IntPtr tiff);
+
+ public static TIFFNumberOfDirectories_d TIFFNumberOfDirectories => Load();
+
+ public delegate int TIFFReadDirectory_d(IntPtr tiff);
+
+ public static TIFFReadDirectory_d TIFFReadDirectory => Load();
+
+ public delegate int TIFFWriteDirectory_d(IntPtr tiff);
+
+ public static TIFFWriteDirectory_d TIFFWriteDirectory => Load();
- [DllImport("libtiff.so.5")]
- public static extern short TIFFNumberOfDirectories(IntPtr tiff);
+ public delegate int TIFFGetField_d1(IntPtr tiff, TiffTag tag, out int field);
- [DllImport("libtiff.so.5")]
- public static extern int TIFFReadDirectory(IntPtr tiff);
+ public static TIFFGetField_d1 TIFFGetFieldInt => Load();
- [DllImport("libtiff.so.5")]
- public static extern int TIFFWriteDirectory(IntPtr tiff);
+ public delegate int TIFFGetField_d2(IntPtr tiff, TiffTag tag, out float field);
- // TODO: Clean these overloads up
- [DllImport("libtiff.so.5")]
- public static extern int TIFFGetField(IntPtr tiff, TiffTag tag, out int field);
+ public static TIFFGetField_d2 TIFFGetFieldFloat => Load();
- [DllImport("libtiff.so.5")]
- public static extern int TIFFGetField(IntPtr tiff, TiffTag tag, out float field);
+ public delegate int TIFFGetField_d3(IntPtr tiff, TiffTag tag, out double field);
- [DllImport("libtiff.so.5")]
- public static extern int TIFFGetField(IntPtr tiff, TiffTag tag, out double field);
+ public static TIFFGetField_d3 TIFFGetFieldDouble => Load();
- [DllImport("libtiff.so.5")]
- public static extern int TIFFSetField(IntPtr tiff, TiffTag tag, int field);
+ public delegate int TIFFSetField_d1(IntPtr tiff, TiffTag tag, int field);
- [DllImport("libtiff.so.5")]
- public static extern int TIFFSetField(IntPtr tiff, TiffTag tag, float field);
+ public static TIFFSetField_d1 TIFFSetFieldInt => Load();
- [DllImport("libtiff.so.5")]
- public static extern int TIFFSetField(IntPtr tiff, TiffTag tag, double field);
+ public delegate int TIFFSetField_d2(IntPtr tiff, TiffTag tag, float field);
- [DllImport("libtiff.so.5")]
- public static extern int TIFFSetField(IntPtr tiff, TiffTag tag, short field, short[] array);
+ public static TIFFSetField_d2 TIFFSetFieldFloat => Load();
- [DllImport("libtiff.so.5")]
- public static extern int TIFFWriteScanline(
+ public delegate int TIFFSetField_d3(IntPtr tiff, TiffTag tag, double field);
+
+ public static TIFFSetField_d3 TIFFSetFieldDouble => Load();
+
+ public delegate int TIFFSetField_d4(IntPtr tiff, TiffTag tag, short field, short[] array);
+
+ public static TIFFSetField_d4 TIFFSetFieldShortArray => Load();
+
+ public delegate int TIFFWriteScanline_d(
IntPtr tiff, tdata_t buf, int row, short sample);
- [DllImport("libtiff.so.5")]
- public static extern int TIFFReadRGBAImage(
+ public static TIFFWriteScanline_d TIFFWriteScanline => Load();
+
+ public delegate int TIFFReadRGBAImage_d(
IntPtr tiff, int w, int h, IntPtr raster, int stopOnError);
- [DllImport("libtiff.so.5")]
- public static extern int TIFFReadRGBAImageOriented(
+ public static TIFFReadRGBAImage_d TIFFReadRGBAImage => Load();
+
+ public delegate int TIFFReadRGBAImageOriented_d(
IntPtr tiff, int w, int h, IntPtr raster, int orientation, int stopOnError);
- // TODO: For streams
- // https://linux.die.net/man/3/tiffclientopen
+ public static TIFFReadRGBAImageOriented_d TIFFReadRGBAImageOriented => Load();
+
+ [DllImport("libdl.so.2")]
+ public static extern IntPtr dlopen(string filename, int flags);
+
+ [DllImport("libdl.so.2")]
+ public static extern string dlerror();
+
+ [DllImport("libdl.so.2")]
+ public static extern IntPtr dlsym(IntPtr handle, string symbol);
}
\ No newline at end of file
diff --git a/NAPS2.Images.Gtk/LibTiffIo.cs b/NAPS2.Images.Gtk/LibTiffIo.cs
index abd98bdb1f..346027ab5c 100644
--- a/NAPS2.Images.Gtk/LibTiffIo.cs
+++ b/NAPS2.Images.Gtk/LibTiffIo.cs
@@ -1,3 +1,4 @@
+using System.Runtime.InteropServices;
using NAPS2.Images.Bitwise;
using NAPS2.Util;
@@ -37,6 +38,7 @@ private bool WriteTiff(IntPtr tiff, LibTiffStreamClient? client, IList TiffCompression.G4,
TiffCompressionType.Lzw => TiffCompression.Lzw,
@@ -96,29 +97,29 @@ private static void WriteTiffMetadata(IntPtr tiff, ImagePixelFormat pixelFormat,
? TiffCompression.G4
: TiffCompression.Lzw
}));
- LibTiff.TIFFSetField(tiff, TiffTag.Orientation, 1);
- LibTiff.TIFFSetField(tiff, TiffTag.BitsPerSample, pixelFormat == ImagePixelFormat.BW1 ? 1 : 8);
- LibTiff.TIFFSetField(tiff, TiffTag.SamplesPerPixel, pixelFormat switch
+ LibTiff.TIFFSetFieldInt(tiff, TiffTag.Orientation, 1);
+ LibTiff.TIFFSetFieldInt(tiff, TiffTag.BitsPerSample, pixelFormat == ImagePixelFormat.BW1 ? 1 : 8);
+ LibTiff.TIFFSetFieldInt(tiff, TiffTag.SamplesPerPixel, pixelFormat switch
{
ImagePixelFormat.RGB24 => 3,
ImagePixelFormat.ARGB32 => 4,
_ => 1
});
- LibTiff.TIFFSetField(tiff, TiffTag.Photometric, (int) (pixelFormat switch
+ LibTiff.TIFFSetFieldInt(tiff, TiffTag.Photometric, (int) (pixelFormat switch
{
ImagePixelFormat.RGB24 or ImagePixelFormat.ARGB32 => TiffPhotometric.Rgb,
_ => TiffPhotometric.MinIsBlack
}));
if (pixelFormat == ImagePixelFormat.ARGB32)
{
- LibTiff.TIFFSetField(tiff, TiffTag.ExtraSamples, 1, new short[] { 2 });
+ LibTiff.TIFFSetFieldShortArray(tiff, TiffTag.ExtraSamples, 1, new short[] { 2 });
}
if (image.HorizontalResolution != 0 && image.VerticalResolution != 0)
{
- LibTiff.TIFFSetField(tiff, TiffTag.ResolutionUnit, 2);
+ LibTiff.TIFFSetFieldInt(tiff, TiffTag.ResolutionUnit, 2);
// TODO: Why do we need to write as a double? It's supposed to be a float.
- LibTiff.TIFFSetField(tiff, TiffTag.XResolution, (double) image.HorizontalResolution);
- LibTiff.TIFFSetField(tiff, TiffTag.YResolution, (double) image.VerticalResolution);
+ LibTiff.TIFFSetFieldDouble(tiff, TiffTag.XResolution, image.HorizontalResolution);
+ LibTiff.TIFFSetFieldDouble(tiff, TiffTag.YResolution, image.VerticalResolution);
}
}
@@ -129,16 +130,11 @@ public void LoadTiff(Action produceImage, Stream stream, ProgressH
EnumerateTiffFrames(produceImage, tiff, progress, client);
}
- public void LoadTiff(Action produceImage, string path, ProgressHandler progress)
- {
- var tiff = LibTiff.TIFFOpen(path, "r");
- EnumerateTiffFrames(produceImage, tiff, progress);
- }
-
private void EnumerateTiffFrames(Action produceImage, IntPtr tiff,
- ProgressHandler progress, LibTiffStreamClient? client = null)
+ ProgressHandler progress, LibTiffStreamClient client)
{
// We keep a reference to the client to avoid garbage collection
+ var handle = GCHandle.Alloc(client);
try
{
var count = LibTiff.TIFFNumberOfDirectories(tiff);
@@ -147,11 +143,11 @@ private void EnumerateTiffFrames(Action produceImage, IntPtr tiff,
do
{
if (progress.IsCancellationRequested) break;
- LibTiff.TIFFGetField(tiff, TiffTag.ImageWidth, out int w);
- LibTiff.TIFFGetField(tiff, TiffTag.ImageHeight, out int h);
+ LibTiff.TIFFGetFieldInt(tiff, TiffTag.ImageWidth, out int w);
+ LibTiff.TIFFGetFieldInt(tiff, TiffTag.ImageHeight, out int h);
// TODO: Check return values
- LibTiff.TIFFGetField(tiff, TiffTag.XResolution, out float xres);
- LibTiff.TIFFGetField(tiff, TiffTag.YResolution, out float yres);
+ LibTiff.TIFFGetFieldFloat(tiff, TiffTag.XResolution, out float xres);
+ LibTiff.TIFFGetFieldFloat(tiff, TiffTag.YResolution, out float yres);
var img = _imageContext.Create(w, h, ImagePixelFormat.ARGB32);
img.SetResolution(xres, yres);
img.OriginalFileFormat = ImageFileFormat.Tiff;
@@ -160,13 +156,14 @@ private void EnumerateTiffFrames(Action produceImage, IntPtr tiff,
imageLock.Dispose();
// LibTiff always produces pre-multiplied alpha, which we don't want
new UnmultiplyAlphaOp().Perform(img);
- produceImage(img);
progress.Report(++i, count);
+ produceImage(img);
} while (LibTiff.TIFFReadDirectory(tiff) == 1);
}
finally
{
LibTiff.TIFFClose(tiff);
+ handle.Free();
}
}
diff --git a/NAPS2.Images.Gtk/NAPS2.Images.Gtk.csproj b/NAPS2.Images.Gtk/NAPS2.Images.Gtk.csproj
index 85500f4a4b..e88050f7bf 100644
--- a/NAPS2.Images.Gtk/NAPS2.Images.Gtk.csproj
+++ b/NAPS2.Images.Gtk/NAPS2.Images.Gtk.csproj
@@ -1,18 +1,22 @@
- net6
+ net6;net8
enable
true
NAPS2.Images.Gtk
NAPS2.Images.Gtk
- Copyright 2022 Ben Olden-Cooligan
+ NAPS2.Images.Gtk
+ Images based on Gdk.Pixbuf for NAPS2.Sdk.
+ naps2
+
+
-
+
diff --git a/NAPS2.Images.ImageSharp/.gitignore b/NAPS2.Images.ImageSharp/.gitignore
new file mode 100644
index 0000000000..866b3a4299
--- /dev/null
+++ b/NAPS2.Images.ImageSharp/.gitignore
@@ -0,0 +1,34 @@
+Thumbs.db
+*.obj
+*.exe
+*.pdb
+*.user
+*.aps
+*.pch
+*.vspscc
+*_i.c
+*_p.c
+*.ncb
+*.suo
+*.sln.docstates
+*.tlb
+*.tlh
+*.bak
+*.cache
+*.ilk
+*.log
+[Bb]in
+[Dd]ebug*/
+*.lib
+*.sbr
+obj/
+[Rr]elease*/
+_ReSharper*/
+[Tt]est[Rr]esult*
+*.vssscc
+$tf*/
+publish/
+bin/
+temp/
+google.credentials.json
+microsoft.credentials.json
\ No newline at end of file
diff --git a/NAPS2.Images.ImageSharp/ImageSharpImage.cs b/NAPS2.Images.ImageSharp/ImageSharpImage.cs
new file mode 100644
index 0000000000..9e9438c24f
--- /dev/null
+++ b/NAPS2.Images.ImageSharp/ImageSharpImage.cs
@@ -0,0 +1,168 @@
+using System.Buffers;
+using NAPS2.Images.Bitwise;
+using NAPS2.Util;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Formats;
+using SixLabors.ImageSharp.Formats.Bmp;
+using SixLabors.ImageSharp.Formats.Jpeg;
+using SixLabors.ImageSharp.Formats.Png;
+using SixLabors.ImageSharp.Formats.Tiff;
+using SixLabors.ImageSharp.Metadata;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+
+namespace NAPS2.Images.ImageSharp;
+
+public class ImageSharpImage : IMemoryImage
+{
+ public ImageSharpImage(Image image)
+ {
+ LeakTracer.StartTracking(this);
+ // TODO: Something similar to MacImage where if it's not a supported pixel type we convert
+ // TODO: Though we might also want to add support where reasonable, e.g. we can probably support argb or bgr pretty easily?
+ Image = image;
+ }
+
+ public ImageContext ImageContext { get; } = new ImageSharpImageContext();
+
+ public Image Image { get; }
+
+ public int Width => Image.Width;
+
+ public int Height => Image.Height;
+
+ public float HorizontalResolution => (float) (Image.Metadata.HorizontalResolution * ResolutionMultiplier);
+
+ public float VerticalResolution => (float) (Image.Metadata.HorizontalResolution * ResolutionMultiplier);
+
+ private double ResolutionMultiplier => Image.Metadata.ResolutionUnits switch
+ {
+ PixelResolutionUnit.PixelsPerCentimeter => 2.54,
+ PixelResolutionUnit.PixelsPerMeter => 2.54 / 100,
+ _ => 1
+ };
+
+ public void SetResolution(float xDpi, float yDpi)
+ {
+ Image.Metadata.ResolutionUnits = PixelResolutionUnit.PixelsPerInch;
+ Image.Metadata.HorizontalResolution = xDpi;
+ Image.Metadata.VerticalResolution = yDpi;
+ }
+
+ public ImagePixelFormat PixelFormat => Image.PixelType switch
+ {
+ { BitsPerPixel: 8 } => ImagePixelFormat.Gray8,
+ { BitsPerPixel: 24 } => ImagePixelFormat.RGB24,
+ { BitsPerPixel: 32 } => ImagePixelFormat.ARGB32,
+ _ => throw new InvalidOperationException("Unsupported pixel format")
+ };
+
+ public unsafe ImageLockState Lock(LockMode lockMode, out BitwiseImageData imageData)
+ {
+ if (lockMode != LockMode.ReadOnly)
+ {
+ LogicalPixelFormat = ImagePixelFormat.Unknown;
+ }
+ var memoryHandle = PixelFormat switch
+ {
+ ImagePixelFormat.RGB24 => ((Image) Image).DangerousTryGetSinglePixelMemory(out var mem)
+ ? mem.Pin()
+ : throw new InvalidOperationException("Could not get contiguous memory for ImageSharp image"),
+ ImagePixelFormat.ARGB32 => ((Image) Image).DangerousTryGetSinglePixelMemory(out var mem)
+ ? mem.Pin()
+ : throw new InvalidOperationException("Could not get contiguous memory for ImageSharp image"),
+ ImagePixelFormat.Gray8 => ((Image) Image).DangerousTryGetSinglePixelMemory(out var mem)
+ ? mem.Pin()
+ : throw new InvalidOperationException("Could not get contiguous memory for ImageSharp image"),
+ _ => throw new InvalidOperationException("Unsupported pixel format")
+ };
+ var subPixelType = PixelFormat switch
+ {
+ ImagePixelFormat.RGB24 => SubPixelType.Rgb,
+ ImagePixelFormat.ARGB32 => SubPixelType.Rgba,
+ ImagePixelFormat.Gray8 => SubPixelType.Gray,
+ _ => throw new InvalidOperationException("Unsupported pixel format")
+ };
+ imageData = new BitwiseImageData((byte*) memoryHandle.Pointer, new PixelInfo(Width, Height, subPixelType));
+ return new ImageSharpImageLockState(memoryHandle);
+ }
+
+ internal class ImageSharpImageLockState : ImageLockState
+ {
+ private readonly MemoryHandle _memoryHandle;
+
+ public ImageSharpImageLockState(MemoryHandle memoryHandle)
+ {
+ _memoryHandle = memoryHandle;
+ }
+
+ public override void Dispose()
+ {
+ _memoryHandle.Dispose();
+ }
+ }
+
+ public ImageFileFormat OriginalFileFormat { get; set; }
+
+ public ImagePixelFormat LogicalPixelFormat { get; set; }
+
+ public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unknown,
+ ImageSaveOptions? options = null)
+ {
+ if (imageFormat == ImageFileFormat.Unknown)
+ {
+ imageFormat = ImageContext.GetFileFormatFromExtension(path);
+ }
+ ImageContext.CheckSupportsFormat(imageFormat);
+
+ options ??= new ImageSaveOptions();
+ using var helper = PixelFormatHelper.Create(this, options.PixelFormatHint, minFormat: ImagePixelFormat.Gray8);
+ var encoder = GetImageEncoder(imageFormat, options);
+ helper.Image.Image.Save(path, encoder);
+ }
+
+ public void Save(Stream stream, ImageFileFormat imageFormat, ImageSaveOptions? options = null)
+ {
+ if (imageFormat == ImageFileFormat.Unknown)
+ {
+ throw new ArgumentException("Format required to save to a stream", nameof(imageFormat));
+ }
+ ImageContext.CheckSupportsFormat(imageFormat);
+
+ options ??= new ImageSaveOptions();
+ using var helper = PixelFormatHelper.Create(this, options.PixelFormatHint, minFormat: ImagePixelFormat.Gray8);
+ var encoder = GetImageEncoder(imageFormat, options);
+ helper.Image.Image.Save(stream, encoder);
+ }
+
+ private static ImageEncoder GetImageEncoder(ImageFileFormat imageFormat, ImageSaveOptions options)
+ {
+ var encoder = imageFormat switch
+ {
+ ImageFileFormat.Bmp => (ImageEncoder) new BmpEncoder(),
+ ImageFileFormat.Png => new PngEncoder(),
+ ImageFileFormat.Jpeg => new JpegEncoder
+ {
+ Quality = options.Quality == -1 ? 75 : options.Quality,
+ // ImageSharp will automatically save an RGB24 image as Grayscale if the actual image colors are gray.
+ // We prevent that here if the caller specified an RGB PixelFormatHint.
+ ColorType = options.PixelFormatHint >= ImagePixelFormat.RGB24 ? JpegEncodingColor.Rgb : null
+ },
+ ImageFileFormat.Tiff => new TiffEncoder(),
+ _ => throw new InvalidOperationException()
+ };
+ return encoder;
+ }
+
+ public IMemoryImage Clone() => new ImageSharpImage(Image.Clone(_ => { }))
+ {
+ OriginalFileFormat = OriginalFileFormat,
+ LogicalPixelFormat = LogicalPixelFormat
+ };
+
+ public void Dispose()
+ {
+ Image.Dispose();
+ LeakTracer.StopTracking(this);
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images.ImageSharp/ImageSharpImageContext.cs b/NAPS2.Images.ImageSharp/ImageSharpImageContext.cs
new file mode 100644
index 0000000000..31d6d213f8
--- /dev/null
+++ b/NAPS2.Images.ImageSharp/ImageSharpImageContext.cs
@@ -0,0 +1,71 @@
+using NAPS2.Util;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Formats;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace NAPS2.Images.ImageSharp;
+
+public class ImageSharpImageContext : ImageContext
+{
+ private readonly ImageSharpImageTransformer _imageTransformer;
+
+ public static Configuration GetConfiguration()
+ {
+ var config = Configuration.Default.Clone();
+ config.PreferContiguousImageBuffers = true;
+ return config;
+ }
+
+ public static DecoderOptions GetDecoderOptions() => new()
+ {
+ Configuration = GetConfiguration()
+ };
+
+ public ImageSharpImageContext() : base(typeof(ImageSharpImage))
+ {
+ _imageTransformer = new ImageSharpImageTransformer(this);
+ }
+
+ protected override bool SupportsTiff => true;
+
+ public override IMemoryImage PerformTransform(IMemoryImage image, Transform transform)
+ {
+ var imageSharpImage = image as ImageSharpImage ?? throw new ArgumentException("Expected ImageSharpImage object");
+ return _imageTransformer.Apply(imageSharpImage, transform);
+ }
+
+ protected override IMemoryImage LoadCore(Stream stream, ImageFileFormat format)
+ {
+ return new ImageSharpImage(Image.Load(GetDecoderOptions(), stream));
+ }
+
+ protected override void LoadFramesCore(Action produceImage, Stream stream,
+ ImageFileFormat format, ProgressHandler progress)
+ {
+ progress.Report(0, 1);
+ if (progress.IsCancellationRequested) return;
+ produceImage(LoadCore(stream, format));
+ progress.Report(1, 1);
+ }
+
+ public Image RenderToImage(IRenderableImage image)
+ {
+ return ((ImageSharpImage) Render(image)).Image;
+ }
+
+ public override IMemoryImage Create(int width, int height, ImagePixelFormat pixelFormat)
+ {
+ if (pixelFormat == ImagePixelFormat.Unknown)
+ {
+ throw new ArgumentException("Unsupported pixel format");
+ }
+ var image = pixelFormat switch
+ {
+ ImagePixelFormat.ARGB32 => (Image) new Image(GetConfiguration(), width, height),
+ ImagePixelFormat.RGB24 => new Image(GetConfiguration(), width, height),
+ ImagePixelFormat.Gray8 or ImagePixelFormat.BW1 => new Image(GetConfiguration(), width, height),
+ _ => throw new InvalidOperationException("Unsupported pixel format")
+ };
+ return new ImageSharpImage(image);
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images.ImageSharp/ImageSharpImageTransformer.cs b/NAPS2.Images.ImageSharp/ImageSharpImageTransformer.cs
new file mode 100644
index 0000000000..ecd4580c95
--- /dev/null
+++ b/NAPS2.Images.ImageSharp/ImageSharpImageTransformer.cs
@@ -0,0 +1,69 @@
+using NAPS2.Images.Bitwise;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+
+namespace NAPS2.Images.ImageSharp;
+
+internal class ImageSharpImageTransformer : AbstractImageTransformer
+{
+ public ImageSharpImageTransformer(ImageContext imageContext) : base(imageContext)
+ {
+ }
+
+ protected override ImageSharpImage PerformTransform(ImageSharpImage image, RotationTransform transform)
+ {
+ // TODO: We can maybe optimize this in some ways, e.g. skip a clone if we're already Rgba32, or convert the
+ // final pixel format back to whatever the original was.
+
+ var width = image.Width;
+ var height = image.Height;
+ float xres = image.HorizontalResolution, yres = image.VerticalResolution;
+ if (transform.Angle is > 45.0 and < 135.0 or > 225.0 and < 315.0)
+ {
+ (width, height) = (height, width);
+ (xres, yres) = (yres, xres);
+ }
+
+ // Get an image with an alpha channel so we can set the background color to white
+ var copy = image.Image.CloneAs();
+
+ copy.Mutate(x => x.Rotate((float) transform.Angle).BackgroundColor(Color.White));
+
+ var cropRect = new Rectangle((copy.Width - width) / 2, (copy.Height - height) / 2, width, height);
+ copy.Mutate(x => x.Crop(cropRect));
+
+ var newImage = new ImageSharpImage(copy);
+ // TODO: In Gdi, we convert this back to BW1. Should we do the same?
+ newImage.LogicalPixelFormat = image.LogicalPixelFormat == ImagePixelFormat.BW1
+ ? ImagePixelFormat.Gray8
+ : image.LogicalPixelFormat;
+ newImage.SetResolution(xres, yres);
+ image.Dispose();
+ return newImage;
+ }
+
+ protected override ImageSharpImage PerformTransform(ImageSharpImage image, ResizeTransform transform)
+ {
+ image.Image.Mutate(x => x.Resize(transform.Width, transform.Height));
+ image.LogicalPixelFormat = image.LogicalPixelFormat == ImagePixelFormat.BW1
+ ? ImagePixelFormat.Gray8
+ : image.LogicalPixelFormat;
+ image.SetResolution(
+ image.HorizontalResolution * image.Width / transform.Width,
+ image.VerticalResolution * image.Height / transform.Height);
+ return image;
+ }
+
+ protected override ImageSharpImage PerformTransform(ImageSharpImage image, BlackWhiteTransform transform)
+ {
+ new DecolorBitwiseImageOp(true)
+ {
+ BlackWhiteThreshold = (transform.Threshold + 1000) / 2000f
+ }.Perform(image);
+ var newImage = (ImageSharpImage) image.CopyWithPixelFormat(ImagePixelFormat.Gray8);
+ newImage.LogicalPixelFormat = ImagePixelFormat.BW1;
+ image.Dispose();
+ return newImage;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images.ImageSharp/LICENSE b/NAPS2.Images.ImageSharp/LICENSE
new file mode 100644
index 0000000000..f62d4e9cab
--- /dev/null
+++ b/NAPS2.Images.ImageSharp/LICENSE
@@ -0,0 +1,518 @@
+NAPS2.Images
+https://www.github.com/cyanfish/naps2/
+
+Copyright 2009-2025 NAPS2 Contributors
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+Lesser General Public License for more details.
+
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it. You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+ When we speak of free software, we are referring to freedom of use,
+not price. Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+ To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights. These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ To protect each distributor, we want to make it very clear that
+there is no warranty for the free library. Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+ Finally, software patents pose a constant threat to the existence of
+any free program. We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder. Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+ Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License. This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License. We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+ When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library. The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom. The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+ We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License. It also provides other free software developers Less
+of an advantage over competing non-free programs. These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries. However, the Lesser license provides advantages in certain
+special circumstances.
+
+ For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard. To achieve this, non-free programs must be
+allowed to use the library. A more frequent case is that a free
+library does the same job as widely used non-free libraries. In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+ In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software. For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+ Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (1) uses at run time a
+ copy of the library already present on the user's computer system,
+ rather than copying library functions into the executable, and (2)
+ will operate properly with a modified version of the library, if
+ the user installs one, as long as the modified version is
+ interface-compatible with the version that the work was made with.
+
+ c) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ d) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ e) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Libraries
+
+ If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change. You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+ To apply these terms, attach the following notices to the library. It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, write to the Free Software
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the
+ library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+ , 1 April 1990
+ Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/NAPS2.Images.ImageSharp/NAPS2.Images.ImageSharp.csproj b/NAPS2.Images.ImageSharp/NAPS2.Images.ImageSharp.csproj
new file mode 100644
index 0000000000..c73669f263
--- /dev/null
+++ b/NAPS2.Images.ImageSharp/NAPS2.Images.ImageSharp.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net6;net8
+ enable
+ true
+ NAPS2.Images.ImageSharp
+
+ NAPS2.Images.ImageSharp
+ NAPS2.Images.ImageSharp
+ Images based on ImageSharp for NAPS2.Sdk.
+ naps2
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>NAPS2.Sdk.Tests
+
+
+
+
+
+
+
+
+
+
+
diff --git a/NAPS2.Images.Mac/FloatHelper.cs b/NAPS2.Images.Mac/FloatHelper.cs
index d15c05588e..df3ed9dc2c 100644
--- a/NAPS2.Images.Mac/FloatHelper.cs
+++ b/NAPS2.Images.Mac/FloatHelper.cs
@@ -4,7 +4,7 @@ namespace NAPS2.Images.Mac;
/// Building xamarin-mac and monomac on different platforms can mean dealing with different floating point types.
/// This class allows minimizing conditional compilation at the target site.
///
-public static class FloatHelper
+internal static class FloatHelper
{
#if MONOMAC
public static float ToFloat(this float value)
diff --git a/NAPS2.Images.Mac/LICENSE b/NAPS2.Images.Mac/LICENSE
index 3f89270f9d..f62d4e9cab 100644
--- a/NAPS2.Images.Mac/LICENSE
+++ b/NAPS2.Images.Mac/LICENSE
@@ -1,7 +1,7 @@
NAPS2.Images
https://www.github.com/cyanfish/naps2/
-Copyright 2009-2022 NAPS2 Contributors
+Copyright 2009-2025 NAPS2 Contributors
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
diff --git a/NAPS2.Images.Mac/MacBitmapHelper.cs b/NAPS2.Images.Mac/MacBitmapHelper.cs
index bf7cefa9a8..408bab8b26 100644
--- a/NAPS2.Images.Mac/MacBitmapHelper.cs
+++ b/NAPS2.Images.Mac/MacBitmapHelper.cs
@@ -11,7 +11,8 @@ public static NSBitmapImageRep CopyRep(NSBitmapImageRep original)
var copy = CreateRepForDrawing(w, h);
using var c = CreateContext(copy, false, true);
CGRect rect = new CGRect(0, 0, w, h);
- c.DrawImage(rect, original.AsCGImage(ref rect, null, null));
+ using var cgImage = original.AsCGImage(ref rect, null, null);
+ c.DrawImage(rect, cgImage);
return copy;
}
diff --git a/NAPS2.Images.Mac/MacImage.cs b/NAPS2.Images.Mac/MacImage.cs
index 9897102d0f..3d164585bd 100644
--- a/NAPS2.Images.Mac/MacImage.cs
+++ b/NAPS2.Images.Mac/MacImage.cs
@@ -2,17 +2,18 @@
namespace NAPS2.Images.Mac;
-// TODO: We might need to dispose things more aggressively
public class MacImage : IMemoryImage
{
- public MacImage(ImageContext imageContext, NSImage image)
+ public MacImage(NSImage image)
{
- if (imageContext is not MacImageContext) throw new ArgumentException("Expected MacImageContext");
- ImageContext = imageContext;
NsImage = image ?? throw new ArgumentNullException(nameof(image));
var reps = NsImage.Representations();
if (reps.Length != 1)
{
+ foreach (var rep in reps)
+ {
+ rep.Dispose();
+ }
throw new ArgumentException("Expected NSImage with exactly one representation");
}
lock (MacImageContext.ConstructorLock)
@@ -24,10 +25,12 @@ public MacImage(ImageContext imageContext, NSImage image)
#endif
}
PixelFormat = GetPixelFormat(Rep);
- // TODO: Why do we get an unsupported warning for ColorSpaceName?
+ // TODO: Any replacement for deprecated ColorSpaceName?
+#pragma warning disable CA1416,CA1422
bool isDeviceColorSpace = Rep.ColorSpaceName == NSColorSpace.DeviceRGB ||
Rep.ColorSpaceName == NSColorSpace.DeviceWhite;
- if (PixelFormat == ImagePixelFormat.Unsupported)
+#pragma warning restore CA1416,CA1422
+ if (PixelFormat == ImagePixelFormat.Unknown)
{
var rep = MacBitmapHelper.CopyRep(Rep);
ReplaceRep(rep);
@@ -38,9 +41,9 @@ public MacImage(ImageContext imageContext, NSImage image)
? NSColorSpace.DeviceGrayColorSpace
: NSColorSpace.DeviceRGBColorSpace;
var rep = Rep.ConvertingToColorSpace(newColorSpace, NSColorRenderingIntent.Default);
+ rep.Size = Rep.Size;
ReplaceRep(rep);
}
- LogicalPixelFormat = PixelFormat;
}
private void ReplaceRep(NSBitmapImageRep rep)
@@ -60,33 +63,26 @@ private static ImagePixelFormat GetPixelFormat(NSBitmapImageRep rep)
{ BitsPerPixel: 32, BitsPerSample: 8, SamplesPerPixel: 3 } => ImagePixelFormat.RGB24,
{ BitsPerPixel: 8, BitsPerSample: 8, SamplesPerPixel: 1 } => ImagePixelFormat.Gray8,
{ BitsPerPixel: 1, BitsPerSample: 1, SamplesPerPixel: 1 } => ImagePixelFormat.BW1,
- _ => ImagePixelFormat.Unsupported
+ _ => ImagePixelFormat.Unknown
};
}
- public ImageContext ImageContext { get; }
+ public ImageContext ImageContext { get; } = new MacImageContext();
public NSImage NsImage { get; }
internal NSBitmapImageRep Rep { get; private set; }
- public void Dispose()
- {
- NsImage.Dispose();
- // TODO: Does this need to dispose the imageRep?
- }
-
public int Width => (int) Rep.PixelsWide;
public int Height => (int) Rep.PixelsHigh;
- public float HorizontalResolution => (float) NsImage.Size.Width.ToDouble() / Width * 72;
- public float VerticalResolution => (float) NsImage.Size.Height.ToDouble() / Height * 72;
+ public float HorizontalResolution => (float) (Width / NsImage.Size.Width * 72).ToDouble();
+ public float VerticalResolution => (float) (Height / NsImage.Size.Height * 72).ToDouble();
public void SetResolution(float xDpi, float yDpi)
{
- // TODO: Image size or imagerep size?
if (xDpi > 0 && yDpi > 0)
{
- NsImage.Size = new CGSize(xDpi / 72 * Width, yDpi / 72 * Height);
+ NsImage.Size = Rep.Size = new CGSize(Width / xDpi * 72, Height / yDpi * 72);
}
}
@@ -94,6 +90,10 @@ public void SetResolution(float xDpi, float yDpi)
public ImageLockState Lock(LockMode lockMode, out BitwiseImageData imageData)
{
+ if (lockMode != LockMode.ReadOnly)
+ {
+ LogicalPixelFormat = ImagePixelFormat.Unknown;
+ }
var ptr = Rep.BitmapData;
var stride = (int) Rep.BytesPerRow;
var subPixelType = PixelFormat switch
@@ -109,7 +109,7 @@ public ImageLockState Lock(LockMode lockMode, out BitwiseImageData imageData)
}
// TODO: Should we implement some kind of actual locking?
- public class MacImageLockState : ImageLockState
+ internal class MacImageLockState : ImageLockState
{
public override void Dispose()
{
@@ -120,61 +120,74 @@ public override void Dispose()
public ImagePixelFormat LogicalPixelFormat { get; set; }
- public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified, int quality = -1)
+ public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unknown,
+ ImageSaveOptions? options = null)
{
- if (imageFormat == ImageFileFormat.Unspecified)
+ if (imageFormat == ImageFileFormat.Unknown)
{
imageFormat = ImageContext.GetFileFormatFromExtension(path);
}
ImageContext.CheckSupportsFormat(imageFormat);
- var rep = GetRepForSaving(imageFormat, quality);
+ var rep = GetRepForSaving(imageFormat, options);
if (!rep.Save(path, false, out var error))
{
throw new IOException(error!.Description);
}
}
- public void Save(Stream stream, ImageFileFormat imageFormat, int quality = -1)
+ public void Save(Stream stream, ImageFileFormat imageFormat, ImageSaveOptions? options = null)
{
- if (imageFormat == ImageFileFormat.Unspecified)
+ if (imageFormat == ImageFileFormat.Unknown)
{
throw new ArgumentException("Format required to save to a stream", nameof(imageFormat));
}
ImageContext.CheckSupportsFormat(imageFormat);
- var rep = GetRepForSaving(imageFormat, quality);
+ var rep = GetRepForSaving(imageFormat, options);
rep.AsStream().CopyTo(stream);
}
- private NSData GetRepForSaving(ImageFileFormat imageFormat, int quality)
+ private NSData GetRepForSaving(ImageFileFormat imageFormat, ImageSaveOptions? options)
{
+ options ??= new ImageSaveOptions();
lock (MacImageContext.ConstructorLock)
{
- var props = quality != -1 && imageFormat is ImageFileFormat.Jpeg or ImageFileFormat.Jpeg2000
- ? NSDictionary.FromObjectAndKey(NSNumber.FromDouble(quality / 100.0),
- NSBitmapImageRep.CompressionFactor)
- : null;
var fileType = imageFormat switch
{
- ImageFileFormat.Jpeg => NSBitmapImageFileType.Jpeg,
- ImageFileFormat.Png => NSBitmapImageFileType.Png,
- ImageFileFormat.Bmp => NSBitmapImageFileType.Bmp,
- ImageFileFormat.Tiff => NSBitmapImageFileType.Tiff,
- ImageFileFormat.Jpeg2000 => NSBitmapImageFileType.Jpeg2000,
+ // TODO: Any replacement for deprecated UTType?
+#pragma warning disable CA1416,CA1422
+ ImageFileFormat.Jpeg => UTType.JPEG,
+ ImageFileFormat.Png => UTType.PNG,
+ ImageFileFormat.Bmp => UTType.BMP,
+ ImageFileFormat.Tiff => UTType.TIFF,
+ ImageFileFormat.Jpeg2000 => UTType.JPEG2000,
+#pragma warning restore CA1416,CA1422
_ => throw new InvalidOperationException("Unsupported image format")
};
- var targetFormat = LogicalPixelFormat;
- if (imageFormat == ImageFileFormat.Bmp && targetFormat == ImagePixelFormat.Gray8)
+ var targetFormat = options.PixelFormatHint;
+ if (imageFormat == ImageFileFormat.Bmp && targetFormat == ImagePixelFormat.Unknown &&
+ PixelFormat == ImagePixelFormat.Gray8)
{
- // Workaround for NSImage issue saving 8bit BMPs
+ // Workaround for issue in some macOS versions with 8bit BMPs
targetFormat = ImagePixelFormat.RGB24;
}
- if (targetFormat != PixelFormat)
+ using var helper = PixelFormatHelper.Create(this, targetFormat);
+ var cgImage = helper.Image.Rep.CGImage; //RepresentationUsingTypeProperties(fileType, props);
+ var data = new NSMutableData();
+ var props = new NSMutableDictionary();
+ props.Add((NSString) "DPIWidth", NSObject.FromObject(HorizontalResolution));
+ props.Add((NSString) "DPIHeight", NSObject.FromObject(VerticalResolution));
+ if (options.Quality != -1 && imageFormat is ImageFileFormat.Jpeg or ImageFileFormat.Jpeg2000)
{
- // We only want to save with the needed color info to minimize file sizes
- using var copy = (MacImage) this.CopyWithPixelFormat(targetFormat);
- return copy.Rep.RepresentationUsingTypeProperties(fileType, props);
+ props.Add((NSString) "kCGImageDestinationLossyCompressionQuality", NSNumber.FromFloat(options.Quality / 100.0f));
}
- return Rep.RepresentationUsingTypeProperties(fileType, props);
+#if MONOMAC
+ using var dest = CGImageDestination.FromData(data, fileType, 1);
+#else
+ using var dest = CGImageDestination.Create(data, fileType.ToString(), 1)!;
+#endif
+ dest.AddImage(cgImage, props);
+ dest.Close();
+ return data;
}
}
@@ -193,11 +206,17 @@ public IMemoryImage Clone()
#else
var nsImage = (NSImage) NsImage.Copy();
#endif
- return new MacImage(ImageContext, nsImage)
+ return new MacImage(nsImage)
{
OriginalFileFormat = OriginalFileFormat,
LogicalPixelFormat = LogicalPixelFormat
};
}
}
+
+ public void Dispose()
+ {
+ Rep.Dispose();
+ NsImage.Dispose();
+ }
}
\ No newline at end of file
diff --git a/NAPS2.Images.Mac/MacImageContext.cs b/NAPS2.Images.Mac/MacImageContext.cs
index 2d9190adfd..583a1ee9a2 100644
--- a/NAPS2.Images.Mac/MacImageContext.cs
+++ b/NAPS2.Images.Mac/MacImageContext.cs
@@ -9,9 +9,8 @@ public class MacImageContext : ImageContext
private readonly MacImageTransformer _imageTransformer;
- public MacImageContext(IPdfRenderer? pdfRenderer = null) : base(typeof(MacImage), pdfRenderer)
+ public MacImageContext() : base(typeof(MacImage))
{
- // TODO: Not sure if this is truly thread safe.
NSApplication.CheckForIllegalCrossThreadCalls = false;
_imageTransformer = new MacImageTransformer(this);
}
@@ -29,13 +28,24 @@ protected override IMemoryImage LoadCore(Stream stream, ImageFileFormat format)
{
lock (ConstructorLock)
{
- var image = new NSImage(NSData.FromStream(stream) ?? throw new ArgumentException(nameof(stream)));
+ var image = NSImage.FromStream(stream)!;
var reps = image.Representations();
- if (reps.Length > 1)
+ try
{
- return CreateImage(reps[0]);
+ if (reps.Length > 1)
+ {
+ image.Dispose();
+ return CreateImage(reps[0]);
+ }
+ return new MacImage(image);
+ }
+ finally
+ {
+ foreach (var rep in reps)
+ {
+ rep.Dispose();
+ }
}
- return new MacImage(this, image);
}
}
@@ -48,17 +58,33 @@ protected override void LoadFramesCore(Action produceImage, Stream
image = new NSImage(NSData.FromStream(stream) ?? throw new ArgumentException(nameof(stream)));
}
var reps = image.Representations();
- for (int i = 0; i < reps.Length; i++)
+ try
{
- progress.Report(i, reps.Length);
- if (progress.IsCancellationRequested) break;
- produceImage(CreateImage(reps[i]));
+ for (int i = 0; i < reps.Length; i++)
+ {
+ progress.Report(i, reps.Length);
+ if (progress.IsCancellationRequested) break;
+ produceImage(CreateImage(reps[i]));
+ }
+ progress.Report(reps.Length, reps.Length);
+ }
+ finally
+ {
+ image.Dispose();
+ foreach (var rep in reps)
+ {
+ rep.Dispose();
+ }
}
- progress.Report(reps.Length, reps.Length);
}
public override ITiffWriter TiffWriter { get; } = new MacTiffWriter();
+ public NSImage RenderToNsImage(IRenderableImage image)
+ {
+ return ((MacImage) Render(image)).NsImage;
+ }
+
private IMemoryImage CreateImage(NSImageRep rep)
{
NSImage frame;
@@ -67,7 +93,7 @@ private IMemoryImage CreateImage(NSImageRep rep)
frame = new NSImage(rep.Size);
}
frame.AddRepresentation(rep);
- return new MacImage(this, frame);
+ return new MacImage(frame);
}
public override IMemoryImage Create(int width, int height, ImagePixelFormat pixelFormat)
@@ -77,7 +103,8 @@ public override IMemoryImage Create(int width, int height, ImagePixelFormat pixe
var rep = MacBitmapHelper.CreateRep(width, height, pixelFormat);
var image = new NSImage(rep.Size);
image.AddRepresentation(rep);
- return new MacImage(this, image);
+ rep.Dispose();
+ return new MacImage(image);
}
}
}
\ No newline at end of file
diff --git a/NAPS2.Images.Mac/MacImageExtensions.cs b/NAPS2.Images.Mac/MacImageExtensions.cs
new file mode 100644
index 0000000000..304a470824
--- /dev/null
+++ b/NAPS2.Images.Mac/MacImageExtensions.cs
@@ -0,0 +1,18 @@
+
+namespace NAPS2.Images.Mac;
+
+public static class MacImageExtensions
+{
+ public static NSImage RenderToNsImage(this IRenderableImage image)
+ {
+ var macImageContext = image.ImageContext as MacImageContext ??
+ throw new ArgumentException("The provided image does not have a MacImageContext");
+ return macImageContext.RenderToNsImage(image);
+ }
+
+ public static NSImage AsNsImage(this IMemoryImage image)
+ {
+ var macImage = image as MacImage ?? throw new ArgumentException("Expected a MacImage", nameof(image));
+ return macImage.NsImage;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images.Mac/MacImageTransformer.cs b/NAPS2.Images.Mac/MacImageTransformer.cs
index 58963c26c8..4730b92288 100644
--- a/NAPS2.Images.Mac/MacImageTransformer.cs
+++ b/NAPS2.Images.Mac/MacImageTransformer.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Mac;
-public class MacImageTransformer : AbstractImageTransformer
+internal class MacImageTransformer : AbstractImageTransformer
{
public MacImageTransformer(ImageContext imageContext) : base(imageContext)
{
@@ -32,7 +32,9 @@ protected override MacImage PerformTransform(MacImage image, RotationTransform t
c.ConcatCTM(CGAffineTransform.Multiply(CGAffineTransform.Multiply(t1, t2), t3));
CGRect rect = new CGRect(0, 0, image.Width, image.Height);
- c.DrawImage(rect, image.Rep.AsCGImage(ref rect, null, null));
+ using var cgImage = image.Rep.AsCGImage(ref rect, null, null);
+ c.DrawImage(rect, cgImage);
+ OptimizePixelFormat(image, ref newImage);
image.Dispose();
return newImage;
}
@@ -46,7 +48,8 @@ protected override MacImage PerformTransform(MacImage image, ResizeTransform tra
using CGBitmapContext c = MacBitmapHelper.CreateContext(newImage);
CGRect rect = new CGRect(0, 0, transform.Width, transform.Height);
// TODO: This changes the image size to match the original which we probably don't want.
- c.DrawImage(rect, image.Rep.AsCGImage(ref rect, null, null));
+ using var cgImage = image.Rep.AsCGImage(ref rect, null, null);
+ c.DrawImage(rect, cgImage);
image.Dispose();
return newImage;
}
diff --git a/NAPS2.Images.Mac/MacTiffWriter.cs b/NAPS2.Images.Mac/MacTiffWriter.cs
index 19516abb89..e7c0fe3714 100644
--- a/NAPS2.Images.Mac/MacTiffWriter.cs
+++ b/NAPS2.Images.Mac/MacTiffWriter.cs
@@ -2,7 +2,7 @@
namespace NAPS2.Images.Mac;
-public class MacTiffWriter : ITiffWriter
+internal class MacTiffWriter : ITiffWriter
{
public bool SaveTiff(IList images, string path,
TiffCompressionType compression = TiffCompressionType.Auto, ProgressHandler progress = default)
@@ -30,7 +30,8 @@ private static NSData GetTiffData(IList images, TiffCompressionTyp
lock (MacImageContext.ConstructorLock)
{
data = new NSMutableData();
- // TODO: Fix unsupported warning
+ // TODO: We get a warning for UTType
+#pragma warning disable CA1416,CA1422
#if MONOMAC
dest = CGImageDestination.FromData(
#else
diff --git a/NAPS2.Images.Mac/NAPS2.Images.Mac.csproj b/NAPS2.Images.Mac/NAPS2.Images.Mac.csproj
index 6c847d2974..2cb119960d 100644
--- a/NAPS2.Images.Mac/NAPS2.Images.Mac.csproj
+++ b/NAPS2.Images.Mac/NAPS2.Images.Mac.csproj
@@ -1,26 +1,30 @@
- net6;net6-macos10.15
+ net6;net8;net8-macos
enable
true
false
NAPS2.Images.Mac
NAPS2.Images.Mac
- Copyright 2022 Ben Olden-Cooligan
+ NAPS2.Images.Mac
+ Images based on AppKit.NSImage for NAPS2.Sdk.
+ naps2
+
+
-
+
-
+
MONOMAC
-
+
@@ -28,7 +32,7 @@
-
+
diff --git a/NAPS2.Images.Wpf/.gitignore b/NAPS2.Images.Wpf/.gitignore
new file mode 100644
index 0000000000..866b3a4299
--- /dev/null
+++ b/NAPS2.Images.Wpf/.gitignore
@@ -0,0 +1,34 @@
+Thumbs.db
+*.obj
+*.exe
+*.pdb
+*.user
+*.aps
+*.pch
+*.vspscc
+*_i.c
+*_p.c
+*.ncb
+*.suo
+*.sln.docstates
+*.tlb
+*.tlh
+*.bak
+*.cache
+*.ilk
+*.log
+[Bb]in
+[Dd]ebug*/
+*.lib
+*.sbr
+obj/
+[Rr]elease*/
+_ReSharper*/
+[Tt]est[Rr]esult*
+*.vssscc
+$tf*/
+publish/
+bin/
+temp/
+google.credentials.json
+microsoft.credentials.json
\ No newline at end of file
diff --git a/NAPS2.Images.Wpf/LICENSE b/NAPS2.Images.Wpf/LICENSE
new file mode 100644
index 0000000000..f62d4e9cab
--- /dev/null
+++ b/NAPS2.Images.Wpf/LICENSE
@@ -0,0 +1,518 @@
+NAPS2.Images
+https://www.github.com/cyanfish/naps2/
+
+Copyright 2009-2025 NAPS2 Contributors
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+Lesser General Public License for more details.
+
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it. You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+ When we speak of free software, we are referring to freedom of use,
+not price. Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+ To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights. These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ To protect each distributor, we want to make it very clear that
+there is no warranty for the free library. Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+ Finally, software patents pose a constant threat to the existence of
+any free program. We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder. Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+ Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License. This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License. We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+ When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library. The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom. The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+ We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License. It also provides other free software developers Less
+of an advantage over competing non-free programs. These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries. However, the Lesser license provides advantages in certain
+special circumstances.
+
+ For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard. To achieve this, non-free programs must be
+allowed to use the library. A more frequent case is that a free
+library does the same job as widely used non-free libraries. In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+ In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software. For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+ Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (1) uses at run time a
+ copy of the library already present on the user's computer system,
+ rather than copying library functions into the executable, and (2)
+ will operate properly with a modified version of the library, if
+ the user installs one, as long as the modified version is
+ interface-compatible with the version that the work was made with.
+
+ c) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ d) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ e) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Libraries
+
+ If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change. You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+ To apply these terms, attach the following notices to the library. It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, write to the Free Software
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the
+ library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+ , 1 April 1990
+ Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/NAPS2.Images.Wpf/NAPS2.Images.Wpf.csproj b/NAPS2.Images.Wpf/NAPS2.Images.Wpf.csproj
new file mode 100644
index 0000000000..0c41f21d44
--- /dev/null
+++ b/NAPS2.Images.Wpf/NAPS2.Images.Wpf.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net462;net6-windows;net8-windows
+ enable
+ true
+ NAPS2.Images.Wpf
+ true
+ true
+
+ NAPS2.Images.Wpf
+ NAPS2.Images.Wpf
+ Images based on WPF for NAPS2.Sdk.
+ naps2
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>NAPS2.Sdk.Tests
+
+
+
+
+
+
+
diff --git a/NAPS2.Images.Wpf/WpfExtensions.cs b/NAPS2.Images.Wpf/WpfExtensions.cs
new file mode 100644
index 0000000000..8dbcf78714
--- /dev/null
+++ b/NAPS2.Images.Wpf/WpfExtensions.cs
@@ -0,0 +1,19 @@
+using System.Windows.Media.Imaging;
+
+namespace NAPS2.Images.Wpf;
+
+public static class WpfExtensions
+{
+ public static BitmapSource RenderToBitmapSource(this IRenderableImage image)
+ {
+ var wpfImageContext = image.ImageContext as WpfImageContext ??
+ throw new ArgumentException("The provided image does not have a WpfImageContext");
+ return wpfImageContext.RenderToBitmapSource(image);
+ }
+
+ public static BitmapSource AsBitmapSource(this IMemoryImage image)
+ {
+ var wpfImage = image as WpfImage ?? throw new ArgumentException("Expected a WpfImage", nameof(image));
+ return wpfImage.Bitmap;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images.Wpf/WpfImage.cs b/NAPS2.Images.Wpf/WpfImage.cs
new file mode 100644
index 0000000000..1b90395c55
--- /dev/null
+++ b/NAPS2.Images.Wpf/WpfImage.cs
@@ -0,0 +1,179 @@
+using System.Reflection;
+using System.Windows.Media.Imaging;
+using System.Windows.Threading;
+using NAPS2.Images.Bitwise;
+using NAPS2.Util;
+
+namespace NAPS2.Images.Wpf;
+
+public class WpfImage : IMemoryImage
+{
+ private static MethodInfo? _detachFromDispatcher;
+
+ private static void DetachFromDispatcher(DispatcherObject dispatcherObject)
+ {
+ _detachFromDispatcher ??=
+ typeof(DispatcherObject).GetMethod("DetachFromDispatcher", BindingFlags.Instance | BindingFlags.NonPublic);
+ _detachFromDispatcher!.Invoke(dispatcherObject, Array.Empty());
+ }
+
+ private bool _disposed;
+
+ public WpfImage(WriteableBitmap bitmap)
+ {
+ LeakTracer.StartTracking(this);
+ // TODO: Something similar to MacImage where if it's not a supported pixel type we convert
+ WpfPixelFormatFixer.MaybeFixPixelFormat(ref bitmap);
+ Bitmap = bitmap;
+ DetachFromDispatcher(Bitmap);
+ }
+
+ public ImageContext ImageContext { get; } = new WpfImageContext();
+
+ public WriteableBitmap Bitmap { get; private set; }
+
+ public int Width => Bitmap.PixelWidth;
+
+ public int Height => Bitmap.PixelHeight;
+
+ public float HorizontalResolution => (float) Bitmap.DpiX;
+
+ public float VerticalResolution => (float) Bitmap.DpiY;
+
+ public void SetResolution(float xDpi, float yDpi)
+ {
+ var src = Bitmap.BackBuffer;
+ var srcInfo = new PixelInfo(Width, Height, GetSubPixelType(), Bitmap.BackBufferStride);
+
+ var newImage = new WriteableBitmap(Width, Height, xDpi, yDpi, Bitmap.Format, null);
+ var dst = newImage.BackBuffer;
+ var dstInfo = new PixelInfo(Width, Height, GetSubPixelType(), newImage.BackBufferStride);
+
+ new CopyBitwiseImageOp().Perform(src, srcInfo, dst, dstInfo);
+ DetachFromDispatcher(newImage);
+ Bitmap = newImage;
+ }
+
+ public ImagePixelFormat PixelFormat => Bitmap.Format switch
+ {
+ { BitsPerPixel: 1 } => ImagePixelFormat.BW1,
+ { BitsPerPixel: 8 } => ImagePixelFormat.Gray8,
+ { BitsPerPixel: 24 } => ImagePixelFormat.RGB24,
+ { BitsPerPixel: 32 } => ImagePixelFormat.ARGB32,
+ _ => throw new InvalidOperationException("Unsupported pixel format")
+ };
+
+ public unsafe ImageLockState Lock(LockMode lockMode, out BitwiseImageData imageData)
+ {
+ if (_disposed) throw new InvalidOperationException();
+ if (lockMode != LockMode.ReadOnly)
+ {
+ LogicalPixelFormat = ImagePixelFormat.Unknown;
+ }
+ var subPixelType = GetSubPixelType();
+ imageData = new BitwiseImageData((byte*) Bitmap.BackBuffer,
+ new PixelInfo(Width, Height, subPixelType, Bitmap.BackBufferStride));
+ return new WpfImageLockState();
+ }
+
+ private class WpfImageLockState : ImageLockState
+ {
+ public override void Dispose()
+ {
+ }
+ }
+
+ private SubPixelType GetSubPixelType() => PixelFormat switch
+ {
+ ImagePixelFormat.RGB24 => SubPixelType.Bgr,
+ ImagePixelFormat.ARGB32 => SubPixelType.Bgra,
+ ImagePixelFormat.Gray8 => SubPixelType.Gray,
+ ImagePixelFormat.BW1 => SubPixelType.Bit,
+ _ => throw new InvalidOperationException("Unsupported pixel format")
+ };
+
+ public ImageFileFormat OriginalFileFormat { get; set; }
+
+ public ImagePixelFormat LogicalPixelFormat { get; set; }
+
+ public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unknown,
+ ImageSaveOptions? options = null)
+ {
+ if (_disposed) throw new InvalidOperationException();
+ if (imageFormat == ImageFileFormat.Unknown)
+ {
+ imageFormat = ImageContext.GetFileFormatFromExtension(path);
+ }
+ ImageContext.CheckSupportsFormat(imageFormat);
+
+ options ??= new ImageSaveOptions();
+ using var helper = PixelFormatHelper.Create(this, options.PixelFormatHint, minFormat: ImagePixelFormat.Gray8);
+ var encoder = GetImageEncoder(imageFormat, options);
+ encoder.Frames.Add(BitmapFrame.Create(helper.Image.Bitmap));
+ using var stream = new FileStream(path, FileMode.Create, FileAccess.Write);
+ encoder.Save(stream);
+ }
+
+ public void Save(Stream stream, ImageFileFormat imageFormat, ImageSaveOptions? options = null)
+ {
+ if (_disposed) throw new InvalidOperationException();
+ if (imageFormat == ImageFileFormat.Unknown)
+ {
+ throw new ArgumentException("Format required to save to a stream", nameof(imageFormat));
+ }
+ ImageContext.CheckSupportsFormat(imageFormat);
+
+ options ??= new ImageSaveOptions();
+ using var helper = PixelFormatHelper.Create(this, options.PixelFormatHint, minFormat: ImagePixelFormat.Gray8);
+ var encoder = GetImageEncoder(imageFormat, options);
+ encoder.Frames.Add(BitmapFrame.Create(helper.Image.Bitmap));
+ SaveMaybeWithoutSeeking(stream, encoder);
+ }
+
+ private static void SaveMaybeWithoutSeeking(Stream stream, BitmapEncoder encoder)
+ {
+ if (stream.CanSeek)
+ {
+ encoder.Save(stream);
+ }
+ else
+ {
+ var memoryStream = new MemoryStream();
+ encoder.Save(memoryStream);
+ memoryStream.Seek(0, SeekOrigin.Begin);
+ memoryStream.CopyTo(stream);
+ }
+ }
+
+ private static BitmapEncoder GetImageEncoder(ImageFileFormat imageFormat, ImageSaveOptions options)
+ {
+ var encoder = imageFormat switch
+ {
+ ImageFileFormat.Bmp => (BitmapEncoder) new BmpBitmapEncoder(),
+ ImageFileFormat.Png => new PngBitmapEncoder(),
+ ImageFileFormat.Jpeg => new JpegBitmapEncoder
+ {
+ QualityLevel = options.Quality == -1 ? 75 : options.Quality
+ },
+ ImageFileFormat.Tiff => new TiffBitmapEncoder(),
+ _ => throw new InvalidOperationException()
+ };
+ return encoder;
+ }
+
+ public IMemoryImage Clone()
+ {
+ if (_disposed) throw new InvalidOperationException();
+ return new WpfImage(Bitmap.Clone())
+ {
+ OriginalFileFormat = OriginalFileFormat,
+ LogicalPixelFormat = LogicalPixelFormat
+ };
+ }
+
+ public void Dispose()
+ {
+ _disposed = true;
+ LeakTracer.StopTracking(this);
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images.Wpf/WpfImageContext.cs b/NAPS2.Images.Wpf/WpfImageContext.cs
new file mode 100644
index 0000000000..94b4554987
--- /dev/null
+++ b/NAPS2.Images.Wpf/WpfImageContext.cs
@@ -0,0 +1,82 @@
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using NAPS2.Util;
+using Transform = NAPS2.Images.Transforms.Transform;
+
+namespace NAPS2.Images.Wpf;
+
+public class WpfImageContext : ImageContext
+{
+ private readonly WpfImageTransformer _imageTransformer;
+
+ public WpfImageContext() : base(typeof(WpfImage))
+ {
+ _imageTransformer = new WpfImageTransformer(this);
+ }
+
+ protected override bool SupportsTiff => true;
+
+ public override ITiffWriter TiffWriter => new WpfTiffWriter();
+
+ public override IMemoryImage PerformTransform(IMemoryImage image, Transform transform)
+ {
+ var wpfImage = image as WpfImage ?? throw new ArgumentException("Expected WpfImage object");
+ return _imageTransformer.Apply(wpfImage, transform);
+ }
+
+ protected override IMemoryImage LoadCore(Stream stream, ImageFileFormat format)
+ {
+ var bitmap = new BitmapImage();
+ bitmap.BeginInit();
+ bitmap.StreamSource = stream;
+ bitmap.CacheOption = BitmapCacheOption.OnLoad;
+ bitmap.EndInit();
+ bitmap.Freeze();
+ return new WpfImage(new WriteableBitmap(bitmap));
+ }
+
+ protected override void LoadFramesCore(Action produceImage, Stream stream,
+ ImageFileFormat format, ProgressHandler progress)
+ {
+ if (format == ImageFileFormat.Tiff)
+ {
+ var decoder = new TiffBitmapDecoder(stream, BitmapCreateOptions.DelayCreation, BitmapCacheOption.OnLoad);
+ progress.Report(0, decoder.Frames.Count);
+ int i = 0;
+ foreach (var frame in decoder.Frames)
+ {
+ if (progress.IsCancellationRequested) return;
+ produceImage(new WpfImage(new WriteableBitmap(frame)));
+ progress.Report(++i, decoder.Frames.Count);
+ }
+ return;
+ }
+ progress.Report(0, 1);
+ if (progress.IsCancellationRequested) return;
+ produceImage(LoadCore(stream, format));
+ progress.Report(1, 1);
+ }
+
+ public BitmapSource RenderToBitmapSource(IRenderableImage image)
+ {
+ return ((WpfImage) Render(image)).Bitmap;
+ }
+
+ public override IMemoryImage Create(int width, int height, ImagePixelFormat pixelFormat)
+ {
+ if (pixelFormat == ImagePixelFormat.Unknown)
+ {
+ throw new ArgumentException("Unsupported pixel format");
+ }
+ var wpfPixelFormat = pixelFormat switch
+ {
+ ImagePixelFormat.ARGB32 => PixelFormats.Bgr32,
+ ImagePixelFormat.RGB24 => PixelFormats.Bgr24,
+ ImagePixelFormat.Gray8 => PixelFormats.Gray8,
+ ImagePixelFormat.BW1 => PixelFormats.BlackWhite,
+ _ => throw new InvalidOperationException("Unsupported pixel format")
+ };
+ var image = new WriteableBitmap(width, height, 0, 0, wpfPixelFormat, null);
+ return new WpfImage(image);
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images.Wpf/WpfImageTransformer.cs b/NAPS2.Images.Wpf/WpfImageTransformer.cs
new file mode 100644
index 0000000000..82d22b2e21
--- /dev/null
+++ b/NAPS2.Images.Wpf/WpfImageTransformer.cs
@@ -0,0 +1,62 @@
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace NAPS2.Images.Wpf;
+
+internal class WpfImageTransformer : AbstractImageTransformer
+{
+ public WpfImageTransformer(ImageContext imageContext) : base(imageContext)
+ {
+ }
+
+ protected override WpfImage PerformTransform(WpfImage image, RotationTransform transform)
+ {
+ var width = image.Width;
+ var height = image.Height;
+ float xres = image.HorizontalResolution, yres = image.VerticalResolution;
+ if (transform.Angle is > 45.0 and < 135.0 or > 225.0 and < 315.0)
+ {
+ (width, height) = (height, width);
+ (xres, yres) = (yres, xres);
+ }
+
+ var visual = new DrawingVisual();
+ using (var dc = visual.RenderOpen())
+ {
+ dc.DrawRectangle(Brushes.White, null, new Rect(0, 0, width, height));
+ dc.PushTransform(new TranslateTransform(width / 2.0, height / 2.0));
+ dc.PushTransform(new RotateTransform(transform.Angle));
+ dc.PushTransform(new TranslateTransform(-image.Width / 2.0, -image.Height / 2.0));
+ dc.DrawImage(image.Bitmap, new Rect(0, 0, image.Width, image.Height));
+ }
+
+ var rtb = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Default);
+ rtb.Render(visual);
+
+ var newImage = new WpfImage(new WriteableBitmap(rtb));
+ // TODO: In Gdi, we convert this back to BW1 (or the original pixel format). Should we do the same?
+ newImage.LogicalPixelFormat = image.LogicalPixelFormat == ImagePixelFormat.BW1
+ ? ImagePixelFormat.Gray8
+ : image.LogicalPixelFormat;
+ newImage.SetResolution(xres, yres);
+ image.Dispose();
+ return newImage;
+ }
+
+ protected override WpfImage PerformTransform(WpfImage image, ResizeTransform transform)
+ {
+ var copy = new TransformedBitmap(image.Bitmap,
+ new System.Windows.Media.ScaleTransform(transform.Width / (double) image.Width,
+ transform.Height / (double) image.Height));
+ var newImage = new WpfImage(new WriteableBitmap(copy));
+ newImage.LogicalPixelFormat = image.LogicalPixelFormat == ImagePixelFormat.BW1
+ ? ImagePixelFormat.Gray8
+ : image.LogicalPixelFormat;
+ newImage.SetResolution(
+ image.HorizontalResolution * image.Width / transform.Width,
+ image.VerticalResolution * image.Height / transform.Height);
+ image.Dispose();
+ return newImage;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images.Wpf/WpfPixelFormatFixer.cs b/NAPS2.Images.Wpf/WpfPixelFormatFixer.cs
new file mode 100644
index 0000000000..8a0b69babd
--- /dev/null
+++ b/NAPS2.Images.Wpf/WpfPixelFormatFixer.cs
@@ -0,0 +1,41 @@
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using NAPS2.Images.Bitwise;
+
+namespace NAPS2.Images.Wpf;
+
+///
+/// Ensures that bitmaps use a standard pixel format/palette.
+///
+internal static class WpfPixelFormatFixer
+{
+ public static bool MaybeFixPixelFormat(ref WriteableBitmap bitmap)
+ {
+ if (bitmap.Format.BitsPerPixel == 1)
+ {
+ if (bitmap.Palette?.Colors[0] == Colors.White && bitmap.Palette?.Colors[1] == Colors.Black)
+ {
+ InvertPalette(ref bitmap);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static void InvertPalette(ref WriteableBitmap bitmap)
+ {
+ int w = bitmap.PixelWidth;
+ int h = bitmap.PixelHeight;
+
+ var src = bitmap.BackBuffer;
+ var srcInfo = new PixelInfo(w, h, SubPixelType.InvertedBit, bitmap.BackBufferStride);
+
+ var newBitmap = new WriteableBitmap(w, h, bitmap.DpiX, bitmap.DpiY, PixelFormats.BlackWhite, null);
+ var dst = newBitmap.BackBuffer;
+ var dstInfo = new PixelInfo(w, h, SubPixelType.Bit, newBitmap.BackBufferStride);
+
+ new CopyBitwiseImageOp().Perform(src, srcInfo, dst, dstInfo);
+ newBitmap.Freeze();
+ bitmap = newBitmap;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images.Wpf/WpfTiffWriter.cs b/NAPS2.Images.Wpf/WpfTiffWriter.cs
new file mode 100644
index 0000000000..be664178ce
--- /dev/null
+++ b/NAPS2.Images.Wpf/WpfTiffWriter.cs
@@ -0,0 +1,49 @@
+using System.Windows.Media.Imaging;
+using NAPS2.Util;
+
+namespace NAPS2.Images.Wpf;
+
+internal class WpfTiffWriter : ITiffWriter
+{
+ public bool SaveTiff(IList images, string path,
+ TiffCompressionType compression = TiffCompressionType.Auto, ProgressHandler progress = default)
+ {
+ using var fileStream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite);
+ return SaveTiff(images, fileStream, compression, progress);
+ }
+
+ public bool SaveTiff(IList images, Stream stream,
+ TiffCompressionType compression = TiffCompressionType.Auto, ProgressHandler progress = default)
+ {
+ if (progress.IsCancellationRequested) return false;
+ var tiffEncoder = new TiffBitmapEncoder
+ {
+ Compression = compression switch
+ {
+ TiffCompressionType.Ccitt4 => TiffCompressOption.Ccitt4,
+ TiffCompressionType.Lzw => TiffCompressOption.Lzw,
+ TiffCompressionType.None => TiffCompressOption.None,
+ _ => TiffCompressOption.Default
+ }
+ };
+ int i = 0;
+ progress.Report(i, images.Count);
+ foreach (var image in images)
+ {
+ image.UpdateLogicalPixelFormat();
+ if (compression == TiffCompressionType.Ccitt4 && image.LogicalPixelFormat != ImagePixelFormat.BW1)
+ {
+ using var bwCopy = image.Clone().PerformTransform(new BlackWhiteTransform());
+ tiffEncoder.Frames.Add(BitmapFrame.Create(((WpfImage) bwCopy).Bitmap));
+ }
+ else
+ {
+ tiffEncoder.Frames.Add(BitmapFrame.Create(((WpfImage) image).Bitmap));
+ }
+ progress.Report(++i, images.Count);
+ if (progress.IsCancellationRequested) return false;
+ }
+ tiffEncoder.Save(stream);
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images/Bitwise/BilateralFilterOp.cs b/NAPS2.Images/Bitwise/BilateralFilterOp.cs
index 28cd1d2846..866752bc39 100644
--- a/NAPS2.Images/Bitwise/BilateralFilterOp.cs
+++ b/NAPS2.Images/Bitwise/BilateralFilterOp.cs
@@ -4,7 +4,7 @@ namespace NAPS2.Images.Bitwise;
/// Runs a bilateral filter operation, which reduces noise without losing edges or fine details.
/// https://en.wikipedia.org/wiki/Bilateral_filter
///
-public class BilateralFilterOp : BinaryBitwiseImageOp
+internal class BilateralFilterOp : BinaryBitwiseImageOp
{
// The color distance (in the 0-255 range) at which pixels are weighted to 0.
// The weight linearly scales up as the color distance approaches 0.
@@ -20,11 +20,14 @@ public class BilateralFilterOp : BinaryBitwiseImageOp
protected override void PerformCore(BitwiseImageData src, BitwiseImageData dst, int partStart, int partEnd)
{
- // TODO: Implement grayscale?
if (src.bytesPerPixel is 3 or 4 && dst.bytesPerPixel is 3 or 4)
{
PerformRgba(src, dst, partStart, partEnd);
}
+ else if (src.bytesPerPixel == 1 && dst.bytesPerPixel == 1)
+ {
+ PerformGray(src, dst, partStart, partEnd);
+ }
else
{
throw new InvalidOperationException("Unsupported pixel format");
@@ -36,25 +39,8 @@ private unsafe void PerformRgba(BitwiseImageData src, BitwiseImageData dst, int
bool copyAlpha = src.hasAlpha && dst.hasAlpha;
const int s = FILTER_SIZE / 2;
- var filter = new int[FILTER_SIZE, FILTER_SIZE];
- for (int filterX = 0; filterX < FILTER_SIZE; filterX++)
- {
- for (int filterY = 0; filterY < FILTER_SIZE; filterY++)
- {
- int dx = filterX - s;
- int dy = filterY - s;
- var dmax = Math.Sqrt(2 * s * s);
- var d = Math.Sqrt(dx * dx + dy * dy) / dmax;
- filter[filterX, filterY] = (int)((1 - d) * 256);
- }
- }
-
- var diffWeights = new int[256 * 3 * 2];
- for (int i = 0; i < COLOR_DIST_MAX * 3; i++)
- {
- diffWeights[256 * 3 + i] = COLOR_DIST_MAX - i / 3;
- diffWeights[256 * 3 - i] = COLOR_DIST_MAX - i / 3;
- }
+ var filter = BuildFilter();
+ var diffWeights = BuildRgbaDiffWeights();
for (int i = partStart; i < partEnd; i++)
{
@@ -81,7 +67,8 @@ private unsafe void PerformRgba(BitwiseImageData src, BitwiseImageData dst, int
byte nextR = *(nextPixel + src.rOff);
byte nextG = *(nextPixel + src.gOff);
byte nextB = *(nextPixel + src.bOff);
- if (prevR == 255 && prevG == 255 && prevB == 255 && nextR == 255 && nextG == 255 && nextB == 255)
+ if (prevR == 255 && prevG == 255 && prevB == 255 && nextR == 255 && nextG == 255 &&
+ nextB == 255)
{
skipPixel = true;
}
@@ -105,7 +92,7 @@ private unsafe void PerformRgba(BitwiseImageData src, BitwiseImageData dst, int
// TODO: Better color distance
var diff = (r + g + b) - (r2 + g2 + b2) + 256 * 3;
- var weight = filter[filterX, filterY] * diffWeights[diff];
+ var weight = filter[filterX + filterY * FILTER_SIZE] * diffWeights[diff];
weightTotal += weight;
rTotal += r2 * weight;
gTotal += g2 * weight;
@@ -127,4 +114,104 @@ private unsafe void PerformRgba(BitwiseImageData src, BitwiseImageData dst, int
}
}
}
+
+ private unsafe void PerformGray(BitwiseImageData src, BitwiseImageData dst, int partStart, int partEnd)
+ {
+ const int s = FILTER_SIZE / 2;
+
+ var filter = BuildFilter();
+ var diffWeights = BuildGrayDiffWeights();
+
+ for (int i = partStart; i < partEnd; i++)
+ {
+ var srcRow = src.ptr + src.stride * i;
+ var dstRow = dst.ptr + dst.stride * i;
+ for (int j = 0; j < src.w; j++)
+ {
+ var srcPixel = srcRow + j * src.bytesPerPixel;
+ var dstPixel = dstRow + j * dst.bytesPerPixel;
+ int lum = *srcPixel;
+
+ if (j > s && j < src.w - s && i > s && i < src.h - s)
+ {
+ bool skipPixel = false;
+ if (lum == 255)
+ {
+ var prevPixel = src.ptr + src.stride * i + src.bytesPerPixel * j - 1;
+ var nextPixel = src.ptr + src.stride * i + src.bytesPerPixel * j + 1;
+ byte prev = *prevPixel;
+ byte next = *nextPixel;
+ if (prev == 255 && next == 255)
+ {
+ skipPixel = true;
+ }
+ }
+ if (!skipPixel)
+ {
+ int lumTotal = 0;
+ int weightTotal = 0;
+ for (int filterX = 0; filterX < FILTER_SIZE; filterX++)
+ {
+ for (int filterY = 0; filterY < FILTER_SIZE; filterY++)
+ {
+ int imageX = j - s + filterX;
+ int imageY = i - s + filterY;
+
+ var pixel = src.ptr + src.stride * imageY + src.bytesPerPixel * imageX;
+
+ var lum2 = *pixel;
+
+ var diff = lum - lum2 + 256;
+ var weight = filter[filterX + filterY * FILTER_SIZE] * diffWeights[diff];
+ weightTotal += weight;
+ lumTotal += lum2 * weight;
+ }
+ }
+ lum = lumTotal / weightTotal;
+ }
+ }
+ *dstPixel = (byte) lum;
+ }
+ }
+ }
+
+ private static int[] BuildFilter()
+ {
+ const int s = FILTER_SIZE / 2;
+ var filter = new int[FILTER_SIZE * FILTER_SIZE];
+ for (int filterX = 0; filterX < FILTER_SIZE; filterX++)
+ {
+ for (int filterY = 0; filterY < FILTER_SIZE; filterY++)
+ {
+ int dx = filterX - s;
+ int dy = filterY - s;
+ var dmax = Math.Sqrt(2 * s * s);
+ var d = Math.Sqrt(dx * dx + dy * dy) / dmax;
+ filter[filterX + filterY * FILTER_SIZE] = (int) ((1 - d) * 256);
+ }
+ }
+ return filter;
+ }
+
+ private static int[] BuildRgbaDiffWeights()
+ {
+ var diffWeights = new int[256 * 3 * 2];
+ for (int i = 0; i < COLOR_DIST_MAX * 3; i++)
+ {
+ diffWeights[256 * 3 + i] = COLOR_DIST_MAX - i / 3;
+ diffWeights[256 * 3 - i] = COLOR_DIST_MAX - i / 3;
+ }
+ return diffWeights;
+ }
+
+ private static int[] BuildGrayDiffWeights()
+ {
+ var diffWeights = new int[256 * 2];
+ for (int i = 0; i < COLOR_DIST_MAX; i++)
+ {
+ diffWeights[256 + i] = COLOR_DIST_MAX - i;
+ diffWeights[256 - i] = COLOR_DIST_MAX - i;
+ }
+ return diffWeights;
+ }
}
\ No newline at end of file
diff --git a/NAPS2.Images/Bitwise/BinaryBitwiseImageOp.cs b/NAPS2.Images/Bitwise/BinaryBitwiseImageOp.cs
index 12f649efc3..868037c19b 100644
--- a/NAPS2.Images/Bitwise/BinaryBitwiseImageOp.cs
+++ b/NAPS2.Images/Bitwise/BinaryBitwiseImageOp.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public abstract class BinaryBitwiseImageOp : BitwiseImageOp
+internal abstract class BinaryBitwiseImageOp : BitwiseImageOp
{
public void Perform(IMemoryImage src, IMemoryImage dst)
{
@@ -38,6 +38,26 @@ public unsafe void Perform(byte[] src, PixelInfo srcPixelInfo, IMemoryImage dst)
}
}
+ public unsafe void Perform(byte[] src, PixelInfo srcPixelInfo, byte[] dst, PixelInfo dstPixelInfo)
+ {
+ if (src.Length < srcPixelInfo.Length)
+ {
+ throw new ArgumentException("Source byte array length is less than expected");
+ }
+ if (dst.Length < dstPixelInfo.Length)
+ {
+ throw new ArgumentException(
+ $"Destination byte array length {dst.Length} is less than expected for height {dstPixelInfo.Height} and stride {dstPixelInfo.Stride}");
+ }
+ fixed (byte* srcPtr = src)
+ fixed (byte* dstPtr = dst)
+ {
+ var srcData = new BitwiseImageData(srcPtr, srcPixelInfo);
+ var dstData = new BitwiseImageData(dstPtr, dstPixelInfo);
+ ValidateAndPerform(srcData, dstData);
+ }
+ }
+
public void Perform(IntPtr src, PixelInfo srcPixelInfo, IMemoryImage dst)
{
using var dstLock = dst.Lock(DstLockMode, out var dstData);
@@ -52,6 +72,13 @@ public void Perform(IMemoryImage src, IntPtr dst, PixelInfo dstPixelInfo)
ValidateAndPerform(srcData, dstData);
}
+ public void Perform(IntPtr src, PixelInfo srcPixelInfo, IntPtr dst, PixelInfo dstPixelInfo)
+ {
+ var srcData = new BitwiseImageData(src, srcPixelInfo);
+ var dstData = new BitwiseImageData(dst, dstPixelInfo);
+ ValidateAndPerform(srcData, dstData);
+ }
+
private void ValidateAndPerform(BitwiseImageData src, BitwiseImageData dst)
{
ValidateConsistency(src);
diff --git a/NAPS2.Images/Bitwise/BitPixelReader.cs b/NAPS2.Images/Bitwise/BitPixelReader.cs
index 43b54d76e8..76c94ded19 100644
--- a/NAPS2.Images/Bitwise/BitPixelReader.cs
+++ b/NAPS2.Images/Bitwise/BitPixelReader.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public class BitPixelReader : IDisposable
+internal class BitPixelReader : IDisposable
{
private const int THRESHOLD = 140 * 1000;
diff --git a/NAPS2.Images/Bitwise/BitwiseImageOp.cs b/NAPS2.Images/Bitwise/BitwiseImageOp.cs
index e327129951..10819a46c2 100644
--- a/NAPS2.Images/Bitwise/BitwiseImageOp.cs
+++ b/NAPS2.Images/Bitwise/BitwiseImageOp.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public class BitwiseImageOp
+internal class BitwiseImageOp
{
public const int R_MULT = 299;
public const int G_MULT = 587;
diff --git a/NAPS2.Images/Bitwise/BitwisePrimitives.cs b/NAPS2.Images/Bitwise/BitwisePrimitives.cs
index a763b80575..f4e35bf24f 100644
--- a/NAPS2.Images/Bitwise/BitwisePrimitives.cs
+++ b/NAPS2.Images/Bitwise/BitwisePrimitives.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public static class BitwisePrimitives
+internal static class BitwisePrimitives
{
public static unsafe void Invert(BitwiseImageData data, int partStart = -1, int partEnd = -1)
{
@@ -34,6 +34,7 @@ public static unsafe void Fill(BitwiseImageData data, byte value, int partStart
{
if (partStart == -1) partStart = 0;
if (partEnd == -1) partEnd = data.h;
+ if (data.invertColorSpace) value = (byte) ~value;
var longCount = data.stride / 8;
var remainingStart = longCount * 8;
diff --git a/NAPS2.Images/Bitwise/BlankDetectionImageOp.cs b/NAPS2.Images/Bitwise/BlankDetectionImageOp.cs
new file mode 100644
index 0000000000..d6bdb5c399
--- /dev/null
+++ b/NAPS2.Images/Bitwise/BlankDetectionImageOp.cs
@@ -0,0 +1,160 @@
+namespace NAPS2.Images.Bitwise;
+
+internal class BlankDetectionImageOp : UnaryBitwiseImageOp
+{
+ // If the pixel value (0-255) >= white_threshold, then it counts as a white pixel.
+ private const int WHITE_THRESHOLD_MIN = 1;
+ private const int WHITE_THRESHOLD_MAX = 255;
+ // If the fraction of non-white pixels > coverage_threshold, then it counts as a non-blank page.
+ private const double COVERAGE_THRESHOLD_MIN = 0.00;
+ private const double COVERAGE_THRESHOLD_MAX = 0.01;
+ private const double IGNORE_EDGE_FRACTION = 0.01;
+
+ private readonly int _whiteThresholdAdjusted;
+ private readonly double _coverageThresholdAdjusted;
+
+ private int _startX;
+ private int _startY;
+ private int _endX;
+ private int _endY;
+ private long _totalMatch;
+ private long _totalPixels;
+
+ public BlankDetectionImageOp(int whiteThreshold, int coverageThreshold)
+ {
+ _whiteThresholdAdjusted = (int) Math.Round(WHITE_THRESHOLD_MIN +
+ (whiteThreshold / 100.0) *
+ (WHITE_THRESHOLD_MAX - WHITE_THRESHOLD_MIN));
+ _coverageThresholdAdjusted = COVERAGE_THRESHOLD_MIN +
+ (coverageThreshold / 100.0) * (COVERAGE_THRESHOLD_MAX - COVERAGE_THRESHOLD_MIN);
+ }
+
+ protected override LockMode LockMode => LockMode.ReadOnly;
+
+ public double Coverage { get; private set; }
+
+ public bool IsBlank { get; private set; }
+
+ protected override void ValidateCore(BitwiseImageData data)
+ {
+ }
+
+ protected override void StartCore(BitwiseImageData data)
+ {
+ _totalPixels = data.w * data.h;
+ _startX = (int) (data.w * IGNORE_EDGE_FRACTION);
+ _startY = (int) (data.h * IGNORE_EDGE_FRACTION);
+ _endX = (int) (data.w * (1 - IGNORE_EDGE_FRACTION));
+ _endY = (int) (data.h * (1 - IGNORE_EDGE_FRACTION));
+ }
+
+ protected override void PerformCore(BitwiseImageData data, int partStart, int partEnd)
+ {
+ if (data.bytesPerPixel is 3 or 4)
+ {
+ PerformRgba(data, partStart, partEnd);
+ }
+ else if (data.bytesPerPixel == 1)
+ {
+ PerformGray(data, partStart, partEnd);
+ }
+ else if (data.bitsPerPixel == 1)
+ {
+ PerformBit(data, partStart, partEnd);
+ }
+ else
+ {
+ throw new InvalidOperationException("Unsupported pixel format");
+ }
+ }
+
+ private unsafe void PerformRgba(BitwiseImageData data, int partStart, int partEnd)
+ {
+ int match = 0;
+ for (int i = partStart; i < partEnd; i++)
+ {
+ if (i < _startY || i > _endY) continue;
+ var row = data.ptr + data.stride * i;
+ for (int j = 0; j < data.w; j++)
+ {
+ if (j < _startX || j > _endX) continue;
+ var pixel = row + j * data.bytesPerPixel;
+ var r = *(pixel + data.rOff);
+ var g = *(pixel + data.gOff);
+ var b = *(pixel + data.bOff);
+
+ int luma = r * 299 + g * 587 + b * 114;
+ if (luma < _whiteThresholdAdjusted * 1000)
+ {
+ match++;
+ }
+ }
+ }
+ lock (this)
+ {
+ _totalMatch += match;
+ }
+ }
+
+ private unsafe void PerformGray(BitwiseImageData data, int partStart, int partEnd)
+ {
+ int match = 0;
+ for (int i = partStart; i < partEnd; i++)
+ {
+ if (i < _startY || i > _endY) continue;
+ var row = data.ptr + data.stride * i;
+ for (int j = 0; j < data.w; j++)
+ {
+ if (j < _startX || j > _endX) continue;
+ var pixel = row + j * data.bytesPerPixel;
+ var luma = *pixel;
+
+ if (luma < _whiteThresholdAdjusted)
+ {
+ match++;
+ }
+ }
+ }
+ lock (this)
+ {
+ _totalMatch += match;
+ }
+ }
+
+ private unsafe void PerformBit(BitwiseImageData data, int partStart, int partEnd)
+ {
+ int match = 0;
+ for (int i = partStart; i < partEnd; i++)
+ {
+ if (i < _startY || i > _endY) continue;
+ var row = data.ptr + data.stride * i;
+ for (int j = 0; j < data.w; j += 8)
+ {
+ if (j < _startX || j > _endX) continue;
+ byte fullByte = *(row + j / 8);
+ for (int k = 7; k >= 0; k--)
+ {
+ var bit = fullByte & 1;
+ fullByte >>= 1;
+ if (j + k < data.w)
+ {
+ if (bit == 0)
+ {
+ match++;
+ }
+ }
+ }
+ }
+ }
+ lock (this)
+ {
+ _totalMatch += match;
+ }
+ }
+
+ protected override void FinishCore()
+ {
+ Coverage = _totalMatch / (double) _totalPixels;
+ IsBlank = Coverage < _coverageThresholdAdjusted;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images/Bitwise/BrightnessBitwiseImageOp.cs b/NAPS2.Images/Bitwise/BrightnessBitwiseImageOp.cs
index eb3d3f371f..f64f8f0ab8 100644
--- a/NAPS2.Images/Bitwise/BrightnessBitwiseImageOp.cs
+++ b/NAPS2.Images/Bitwise/BrightnessBitwiseImageOp.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public class BrightnessBitwiseImageOp : UnaryBitwiseImageOp
+internal class BrightnessBitwiseImageOp : UnaryBitwiseImageOp
{
private readonly float _brightnessAdjusted;
diff --git a/NAPS2.Images/Bitwise/ColorChannel.cs b/NAPS2.Images/Bitwise/ColorChannel.cs
index 67b1e4bb0a..f4ce043de2 100644
--- a/NAPS2.Images/Bitwise/ColorChannel.cs
+++ b/NAPS2.Images/Bitwise/ColorChannel.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public enum ColorChannel
+internal enum ColorChannel
{
All,
Red,
diff --git a/NAPS2.Images/Bitwise/ColumnColorOp.cs b/NAPS2.Images/Bitwise/ColumnColorOp.cs
index bb6ebfcd21..9e378803e1 100644
--- a/NAPS2.Images/Bitwise/ColumnColorOp.cs
+++ b/NAPS2.Images/Bitwise/ColumnColorOp.cs
@@ -9,7 +9,7 @@ namespace NAPS2.Images.Bitwise;
/// be calibrated independently. Of course that means this correction must happen before deskew or anything else that
/// can combine values across columns.
///
-public class ColumnColorOp : UnaryBitwiseImageOp
+internal class ColumnColorOp : UnaryBitwiseImageOp
{
///
/// Performs this operation including pre-processing steps.
diff --git a/NAPS2.Images/Bitwise/ColumnColorPreOp.cs b/NAPS2.Images/Bitwise/ColumnColorPreOp.cs
index bdd337c1f1..647b955f6f 100644
--- a/NAPS2.Images/Bitwise/ColumnColorPreOp.cs
+++ b/NAPS2.Images/Bitwise/ColumnColorPreOp.cs
@@ -3,7 +3,7 @@ namespace NAPS2.Images.Bitwise;
///
/// Performs pre-processing for the ColumnColorOp.
///
-public class ColumnColorPreOp : UnaryBitwiseImageOp
+internal class ColumnColorPreOp : UnaryBitwiseImageOp
{
private const double COL_IGNORE_TOP_AND_BOTTOM = 0.02;
diff --git a/NAPS2.Images/Bitwise/ContrastBitwiseImageOp.cs b/NAPS2.Images/Bitwise/ContrastBitwiseImageOp.cs
index 5f80e98a16..5b4f3af78d 100644
--- a/NAPS2.Images/Bitwise/ContrastBitwiseImageOp.cs
+++ b/NAPS2.Images/Bitwise/ContrastBitwiseImageOp.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public class ContrastBitwiseImageOp : UnaryBitwiseImageOp
+internal class ContrastBitwiseImageOp : UnaryBitwiseImageOp
{
private readonly float _contrastAdjusted;
private readonly float _offset;
diff --git a/NAPS2.Images/Bitwise/CopyBitwiseImageOp.cs b/NAPS2.Images/Bitwise/CopyBitwiseImageOp.cs
index a518ec904c..bdbed0a960 100644
--- a/NAPS2.Images/Bitwise/CopyBitwiseImageOp.cs
+++ b/NAPS2.Images/Bitwise/CopyBitwiseImageOp.cs
@@ -1,7 +1,7 @@
namespace NAPS2.Images.Bitwise;
// TODO: Need to double check callers set resolution when needed
-public class CopyBitwiseImageOp : BinaryBitwiseImageOp
+internal class CopyBitwiseImageOp : BinaryBitwiseImageOp
{
// TODO: Consider requiring an explicit DiscardAlpha parameter
@@ -48,10 +48,10 @@ protected override void ValidateCore(BitwiseImageData src, BitwiseImageData dst)
throw new ArgumentException(
"DestChannel is only supported when the source is grayscale/color and the destination is color.");
}
- if ((src.invertColorSpace || dst.invertColorSpace) && (src.bitsPerPixel != 1 || dst.bitsPerPixel != 1))
+ if (src.invertColorSpace & dst.invertColorSpace && (src.hasAlpha || dst.hasAlpha))
{
throw new ArgumentException(
- "SubPixelType.InvertedBit is only supported when both source and destination are 1 bit per pixel");
+ "SubPixelType.InvertedBit is not supported with alpha channels.");
}
}
@@ -59,8 +59,14 @@ protected override void ValidateCore(BitwiseImageData src, BitwiseImageData dst)
protected override void PerformCore(BitwiseImageData src, BitwiseImageData dst, int partStart, int partEnd)
{
if (src.BitLayout == dst.BitLayout &&
- (src.bytesPerPixel > 0 || (SourceXOffset % 8 == 0 && DestXOffset % 8 == 0)) &&
- DestChannel == ColorChannel.All)
+ DestChannel == ColorChannel.All &&
+ (src.bytesPerPixel > 0 ||
+ // For Black & White images, to use the fast copy path, we must have that:
+ // 1. The offsets are to whole bytes
+ // 2a. Either we copy whole bytes, or
+ // 2b. We end at the far-right side of the destination (so any excess bits copied will be ignored)
+ (SourceXOffset % 8 == 0 && DestXOffset % 8 == 0 &&
+ (src.w % 8 == 0 || src.w + DestXOffset == dst.w))))
{
FastCopy(src, dst, partStart, partEnd);
}
@@ -254,6 +260,7 @@ private unsafe void UnalignedBitCopy(BitwiseImageData src, BitwiseImageData dst,
var dstPixelIndex = j + DestXOffset;
var dstPtr = dstRow + dstPixelIndex / 8;
var dstByte = *dstPtr;
+ dstByte &= (byte) ~(1 << (7 - dstPixelIndex % 8));
dstByte |= (byte) (bit << (7 - dstPixelIndex % 8));
*dstPtr = dstByte;
}
diff --git a/NAPS2.Images/Bitwise/DecolorBitwiseImageOp.cs b/NAPS2.Images/Bitwise/DecolorBitwiseImageOp.cs
index 83b998bb76..dfe4be8584 100644
--- a/NAPS2.Images/Bitwise/DecolorBitwiseImageOp.cs
+++ b/NAPS2.Images/Bitwise/DecolorBitwiseImageOp.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public class DecolorBitwiseImageOp : UnaryBitwiseImageOp
+internal class DecolorBitwiseImageOp : UnaryBitwiseImageOp
{
private readonly bool _blackAndWhite;
diff --git a/NAPS2.Images/Bitwise/FillColorImageOp.cs b/NAPS2.Images/Bitwise/FillColorImageOp.cs
new file mode 100644
index 0000000000..027402e469
--- /dev/null
+++ b/NAPS2.Images/Bitwise/FillColorImageOp.cs
@@ -0,0 +1,71 @@
+namespace NAPS2.Images.Bitwise;
+
+internal class FillColorImageOp : UnaryBitwiseImageOp
+{
+ public static FillColorImageOp Black => new(0, 0, 0, 255);
+ public static FillColorImageOp White => new(255, 255, 255, 255);
+
+ private readonly byte _r, _g, _b, _a;
+
+ public FillColorImageOp(byte r, byte g, byte b, byte a)
+ {
+ _r = r;
+ _g = g;
+ _b = b;
+ _a = a;
+ }
+
+ protected override void ValidateCore(BitwiseImageData data)
+ {
+ }
+
+ protected override void PerformCore(BitwiseImageData data, int partStart, int partEnd)
+ {
+ if (data.bytesPerPixel is 1 or 3 or 4)
+ {
+ PerformRgba(data, partStart, partEnd);
+ }
+ else if (data.bitsPerPixel == 1 && (_r, _g, _b, _a) is (0, 0, 0, 255) or (255, 255, 255, 255))
+ {
+ PerformBw(data, partStart, partEnd);
+ }
+ else
+ {
+ throw new InvalidOperationException("Unsupported pixel format");
+ }
+ }
+
+ private unsafe void PerformRgba(BitwiseImageData data, int partStart, int partEnd)
+ {
+ bool gray = data.bytesPerPixel == 1;
+ byte luma = (byte) ((_r * R_MULT + _g * G_MULT + _b * B_MULT) / 1000);
+ for (int i = partStart; i < partEnd; i++)
+ {
+ var row = data.ptr + data.stride * i;
+ for (int j = 0; j < data.w; j++)
+ {
+ var pixel = row + j * data.bytesPerPixel;
+ if (gray)
+ {
+ *pixel = luma;
+ }
+ else
+ {
+ *(pixel + data.rOff) = _r;
+ *(pixel + data.gOff) = _g;
+ *(pixel + data.bOff) = _b;
+ }
+ if (data.hasAlpha)
+ {
+ *(pixel + data.aOff) = _a;
+ }
+ }
+ }
+ }
+
+ private void PerformBw(BitwiseImageData data, int partStart, int partEnd)
+ {
+ byte fill = (byte) (_r == 255 ? 0xFF : 0x00);
+ BitwisePrimitives.Fill(data, fill, partStart, partEnd);
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images/Bitwise/GammaTables.cs b/NAPS2.Images/Bitwise/GammaTables.cs
index 1677669bf8..ea9fbf77fc 100644
--- a/NAPS2.Images/Bitwise/GammaTables.cs
+++ b/NAPS2.Images/Bitwise/GammaTables.cs
@@ -1,20 +1,35 @@
namespace NAPS2.Images.Bitwise;
+///
+/// The normal 0-255 RGB values we use are in the "intensity" space. We can use a gamma conversion
+/// to convert these to the "luminance" space, which is better for certain kinds of image processing.
+/// Then convert back to the "intensity" space once we're done. This class provides pre-calculated tables
+/// for these conversions.
+/// https://www.benq.com/en-ca/knowledge-center/knowledge/gamma-monitor.html
+///
internal static class GammaTables
{
- public static byte[] IntensityToLum { get; }
+ // Doing math in the 0-255 integral space means there's a significant loss of precision.
+ // Instead we can work in the 0-MAX_LUM space to give more granularity without needing to use slower floating point.
+ public const int MULTIPLIER = 16;
+ public const int MAX_LUM = 255 * MULTIPLIER;
+
+ public static short[] IntensityToLum { get; }
public static byte[] LumToIntensity { get; }
static GammaTables()
{
const double gamma = 2.2;
- IntensityToLum = new byte[256];
- LumToIntensity = new byte[256];
+ IntensityToLum = new short[256];
+ LumToIntensity = new byte[MAX_LUM + 1];
for (int x = 0; x < 256; x++)
{
var i = Math.Pow(x / 255.0, 1 / gamma);
- IntensityToLum[x] = (byte) Math.Round(i * 255);
- var l = Math.Pow(x / 255.0, gamma);
+ IntensityToLum[x] = (short) Math.Round(i * MAX_LUM);
+ }
+ for (int x = 0; x <= MAX_LUM; x++)
+ {
+ var l = Math.Pow(x / (double) MAX_LUM, gamma);
LumToIntensity[x] = (byte) Math.Round(l * 255);
}
}
diff --git a/NAPS2.Images/Bitwise/HueShiftBitwiseImageOp.cs b/NAPS2.Images/Bitwise/HueShiftBitwiseImageOp.cs
index c1fe367221..c0beff58a4 100644
--- a/NAPS2.Images/Bitwise/HueShiftBitwiseImageOp.cs
+++ b/NAPS2.Images/Bitwise/HueShiftBitwiseImageOp.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public class HueShiftBitwiseImageOp : UnaryBitwiseImageOp
+internal class HueShiftBitwiseImageOp : UnaryBitwiseImageOp
{
private readonly float _shiftAdjusted;
diff --git a/NAPS2.Images/Bitwise/LogicalPixelFormatOp.cs b/NAPS2.Images/Bitwise/LogicalPixelFormatOp.cs
index 9d779313cc..c45762304f 100644
--- a/NAPS2.Images/Bitwise/LogicalPixelFormatOp.cs
+++ b/NAPS2.Images/Bitwise/LogicalPixelFormatOp.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public class LogicalPixelFormatOp : UnaryBitwiseImageOp
+internal class LogicalPixelFormatOp : UnaryBitwiseImageOp
{
public ImagePixelFormat LogicalPixelFormat { get; private set; }
diff --git a/NAPS2.Images/Bitwise/RgbPixelReader.cs b/NAPS2.Images/Bitwise/RgbPixelReader.cs
index bcb477e9e4..b607c8d86a 100644
--- a/NAPS2.Images/Bitwise/RgbPixelReader.cs
+++ b/NAPS2.Images/Bitwise/RgbPixelReader.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public class RgbPixelReader : IDisposable
+internal class RgbPixelReader : IDisposable
{
private readonly ImageLockState _lock;
private readonly BitwiseImageData _data;
diff --git a/NAPS2.Images/Bitwise/RmseBitwiseImageOp.cs b/NAPS2.Images/Bitwise/RmseBitwiseImageOp.cs
index a8de16fff3..aa320d9066 100644
--- a/NAPS2.Images/Bitwise/RmseBitwiseImageOp.cs
+++ b/NAPS2.Images/Bitwise/RmseBitwiseImageOp.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public class RmseBitwiseImageOp : BinaryBitwiseImageOp
+internal class RmseBitwiseImageOp : BinaryBitwiseImageOp
{
protected override LockMode SrcLockMode => LockMode.ReadOnly;
protected override LockMode DstLockMode => LockMode.ReadOnly;
diff --git a/NAPS2.Images/Bitwise/SaturationBitwiseImageOp.cs b/NAPS2.Images/Bitwise/SaturationBitwiseImageOp.cs
index fe47aeec0c..a258eba3dd 100644
--- a/NAPS2.Images/Bitwise/SaturationBitwiseImageOp.cs
+++ b/NAPS2.Images/Bitwise/SaturationBitwiseImageOp.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public class SaturationBitwiseImageOp : UnaryBitwiseImageOp
+internal class SaturationBitwiseImageOp : UnaryBitwiseImageOp
{
private readonly float _saturationAdjusted;
diff --git a/NAPS2.Images/Bitwise/SharpenBitwiseImageOp.cs b/NAPS2.Images/Bitwise/SharpenBitwiseImageOp.cs
index ec2cc6b21a..0bacfb4944 100644
--- a/NAPS2.Images/Bitwise/SharpenBitwiseImageOp.cs
+++ b/NAPS2.Images/Bitwise/SharpenBitwiseImageOp.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public class SharpenBitwiseImageOp : BinaryBitwiseImageOp
+internal class SharpenBitwiseImageOp : BinaryBitwiseImageOp
{
private readonly float _sharpness;
diff --git a/NAPS2.Images/Bitwise/UnaryBitwiseImageOp.cs b/NAPS2.Images/Bitwise/UnaryBitwiseImageOp.cs
index f8153e09b9..c0a06ca28f 100644
--- a/NAPS2.Images/Bitwise/UnaryBitwiseImageOp.cs
+++ b/NAPS2.Images/Bitwise/UnaryBitwiseImageOp.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public abstract class UnaryBitwiseImageOp : BitwiseImageOp
+internal abstract class UnaryBitwiseImageOp : BitwiseImageOp
{
public void Perform(IMemoryImage image)
{
diff --git a/NAPS2.Images/Bitwise/UnmultiplyAlphaOp.cs b/NAPS2.Images/Bitwise/UnmultiplyAlphaOp.cs
index 5625f778e3..00bd9a4d2b 100644
--- a/NAPS2.Images/Bitwise/UnmultiplyAlphaOp.cs
+++ b/NAPS2.Images/Bitwise/UnmultiplyAlphaOp.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images.Bitwise;
-public class UnmultiplyAlphaOp : UnaryBitwiseImageOp
+internal class UnmultiplyAlphaOp : UnaryBitwiseImageOp
{
protected override void PerformCore(BitwiseImageData data, int partStart, int partEnd)
{
diff --git a/NAPS2.Images/Bitwise/WhiteBlackPointOp.cs b/NAPS2.Images/Bitwise/WhiteBlackPointOp.cs
index 68d5cf3842..0fb5949389 100644
--- a/NAPS2.Images/Bitwise/WhiteBlackPointOp.cs
+++ b/NAPS2.Images/Bitwise/WhiteBlackPointOp.cs
@@ -3,7 +3,7 @@ namespace NAPS2.Images.Bitwise;
///
/// Corrects images with poor calibration for white/black values.
///
-public class WhiteBlackPointOp : UnaryBitwiseImageOp
+internal class WhiteBlackPointOp : UnaryBitwiseImageOp
{
// When we've identified the block of pixel values that we consider white (or black),
// this is the percentile (counting from the mid levels) at which we set the
@@ -49,7 +49,7 @@ public WhiteBlackPointOp(WhiteBlackPointPreOp preOp)
_blackPoint = GetBlackPoint(counts, blackPeak);
_valid = true;
- Console.WriteLine($"Correcting with whitepoint {_whitePoint} blackpoint {_blackPoint}");
+ // Console.WriteLine($"Correcting with whitepoint {_whitePoint} blackpoint {_blackPoint}");
}
private static int GetWhitePoint(int[] counts, Peak whitePeak)
@@ -201,9 +201,9 @@ protected override unsafe void PerformCore(BitwiseImageData data, int partStart,
int gL = iToL[g];
int bL = iToL[b];
// Scale the color values in the luminescence space
- rL = (rL - blackL) * 255 / (whiteL - blackL);
- gL = (gL - blackL) * 255 / (whiteL - blackL);
- bL = (bL - blackL) * 255 / (whiteL - blackL);
+ rL = (rL - blackL) * GammaTables.MAX_LUM / (whiteL - blackL);
+ gL = (gL - blackL) * GammaTables.MAX_LUM / (whiteL - blackL);
+ bL = (bL - blackL) * GammaTables.MAX_LUM / (whiteL - blackL);
// Convert back to the intensity space
r = lToI[rL];
g = lToI[gL];
diff --git a/NAPS2.Images/Bitwise/WhiteBlackPointPreOp.cs b/NAPS2.Images/Bitwise/WhiteBlackPointPreOp.cs
index d0601f72db..0f904251c3 100644
--- a/NAPS2.Images/Bitwise/WhiteBlackPointPreOp.cs
+++ b/NAPS2.Images/Bitwise/WhiteBlackPointPreOp.cs
@@ -3,7 +3,7 @@ namespace NAPS2.Images.Bitwise;
///
/// Performs pre-processing for the WhiteBlackPointOp.
///
-public class WhiteBlackPointPreOp : UnaryBitwiseImageOp
+internal class WhiteBlackPointPreOp : UnaryBitwiseImageOp
{
public WhiteBlackPointPreOp(CorrectionMode mode)
{
diff --git a/NAPS2.Images/Bitwise/BitwiseImageData.cs b/NAPS2.Images/BitwiseImageData.cs
similarity index 97%
rename from NAPS2.Images/Bitwise/BitwiseImageData.cs
rename to NAPS2.Images/BitwiseImageData.cs
index b944910608..b5c65f7447 100644
--- a/NAPS2.Images/Bitwise/BitwiseImageData.cs
+++ b/NAPS2.Images/BitwiseImageData.cs
@@ -1,4 +1,4 @@
-namespace NAPS2.Images.Bitwise;
+namespace NAPS2.Images;
public struct BitwiseImageData
{
diff --git a/NAPS2.Images/Storage/FileStorageManager.cs b/NAPS2.Images/FileStorageManager.cs
similarity index 96%
rename from NAPS2.Images/Storage/FileStorageManager.cs
rename to NAPS2.Images/FileStorageManager.cs
index 89d644ab22..0817de1b49 100644
--- a/NAPS2.Images/Storage/FileStorageManager.cs
+++ b/NAPS2.Images/FileStorageManager.cs
@@ -1,6 +1,6 @@
using System.Globalization;
-namespace NAPS2.Images.Storage;
+namespace NAPS2.Images;
public class FileStorageManager : IDisposable
{
diff --git a/NAPS2.Images/Storage/IImageStorage.cs b/NAPS2.Images/IImageStorage.cs
similarity index 52%
rename from NAPS2.Images/Storage/IImageStorage.cs
rename to NAPS2.Images/IImageStorage.cs
index 2760a6cb9b..d5ba11b7e1 100644
--- a/NAPS2.Images/Storage/IImageStorage.cs
+++ b/NAPS2.Images/IImageStorage.cs
@@ -1,8 +1,7 @@
-namespace NAPS2.Images.Storage;
+namespace NAPS2.Images;
///
-/// Base type for image storage, which can be a normal in-memory image (see IMemoryImage) or an image stored on the
-/// filesystem (see ImageFileStorage).
+/// Base type for image storage, which can be a normal in-memory image or an image stored on the filesystem.
///
public interface IImageStorage : IDisposable
{
diff --git a/NAPS2.Images/IMemoryImage.cs b/NAPS2.Images/IMemoryImage.cs
index b3e6c87f27..4b4ea01a0c 100644
--- a/NAPS2.Images/IMemoryImage.cs
+++ b/NAPS2.Images/IMemoryImage.cs
@@ -67,7 +67,9 @@ public interface IMemoryImage : IImageStorage
///
/// Gets the color content of the image. For example, an image might be stored in memory with PixelFormat = ARGB32,
- /// but if it's a grayscale image with no transparency, then LogicalPixelFormat = Gray8.
+ /// but if it's a grayscale image with no transparency, then LogicalPixelFormat = Gray8. By default this is not
+ /// calculated and is set to ImagePixelFormat.Unsupported. Call IMemoryImage.UpdateLogicalPixelFormat() to ensure
+ /// this is calculated.
///
ImagePixelFormat LogicalPixelFormat { get; set; }
@@ -77,16 +79,16 @@ public interface IMemoryImage : IImageStorage
///
/// The path to save the image file to.
/// The file format to use.
- /// The quality parameter for JPEG compression, if applicable. -1 for default.
- void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified, int quality = -1);
+ /// Options for saving, e.g. JPEG quality.
+ void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unknown, ImageSaveOptions? options = null);
///
/// Saves the image to the given stream. The file format must be specified.
///
/// The stream to save the image to.
/// The file format to use.
- /// The quality parameter for JPEG compression, if applicable. -1 for default.
- void Save(Stream stream, ImageFileFormat imageFormat, int quality = -1);
+ /// Options for saving, e.g. JPEG quality.
+ void Save(Stream stream, ImageFileFormat imageFormat, ImageSaveOptions? options = null);
///
/// Creates a copy of the image so that one can be edited or disposed without affecting the other.
diff --git a/NAPS2.Images/IPdfRenderer.cs b/NAPS2.Images/IPdfRenderer.cs
index 3ac58daa18..702e1a4755 100644
--- a/NAPS2.Images/IPdfRenderer.cs
+++ b/NAPS2.Images/IPdfRenderer.cs
@@ -1,9 +1,16 @@
namespace NAPS2.Images;
-public interface IPdfRenderer
+internal interface IPdfRenderer
{
- IEnumerable Render(ImageContext imageContext, string path, PdfRenderSize renderSize, string? password = null);
+ IEnumerable Render(ImageContext imageContext, string path, PdfRenderSize renderSize,
+ string? password = null);
IEnumerable Render(ImageContext imageContext, byte[] buffer, int length, PdfRenderSize renderSize,
string? password = null);
+
+ IMemoryImage RenderPage(ImageContext imageContext, string path, PdfRenderSize renderSize, int pageIndex = 0,
+ string? password = null);
+
+ IMemoryImage RenderPage(ImageContext imageContext, byte[] buffer, int length, PdfRenderSize renderSize,
+ int pageIndex = 0, string? password = null);
}
\ No newline at end of file
diff --git a/NAPS2.Images/IPdfRendererProvider.cs b/NAPS2.Images/IPdfRendererProvider.cs
new file mode 100644
index 0000000000..26c1a704da
--- /dev/null
+++ b/NAPS2.Images/IPdfRendererProvider.cs
@@ -0,0 +1,6 @@
+namespace NAPS2.Images;
+
+internal interface IPdfRendererProvider
+{
+ IPdfRenderer PdfRenderer { get; }
+}
\ No newline at end of file
diff --git a/NAPS2.Images/ImageContext.cs b/NAPS2.Images/ImageContext.cs
index f7f95e5033..cb20468e85 100644
--- a/NAPS2.Images/ImageContext.cs
+++ b/NAPS2.Images/ImageContext.cs
@@ -1,12 +1,11 @@
using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
using NAPS2.Util;
namespace NAPS2.Images;
public abstract class ImageContext
{
- private readonly IPdfRenderer? _pdfRenderer;
-
public static ImageFileFormat GetFileFormatFromExtension(string path)
{
return Path.GetExtension(path).ToLowerInvariant() switch
@@ -16,7 +15,7 @@ public static ImageFileFormat GetFileFormatFromExtension(string path)
".jpg" or ".jpeg" => ImageFileFormat.Jpeg,
".tif" or ".tiff" => ImageFileFormat.Tiff,
".jp2" or ".jpx" => ImageFileFormat.Jpeg2000,
- _ => throw new ArgumentException($"Could not infer file format from extension: {path}")
+ _ => ImageFileFormat.Unknown
};
}
@@ -24,74 +23,64 @@ private static ImageFileFormat GetFileFormatFromFirstBytes(Stream stream)
{
if (!stream.CanSeek)
{
- return ImageFileFormat.Unspecified;
+ return ImageFileFormat.Unknown;
}
var firstBytes = new byte[8];
stream.Seek(0, SeekOrigin.Begin);
stream.Read(firstBytes, 0, 8);
stream.Seek(0, SeekOrigin.Begin);
- if (firstBytes[0] == 0x89 && firstBytes[1] == 0x50 && firstBytes[2] == 0x4E && firstBytes[3] == 0x47)
- {
- return ImageFileFormat.Png;
- }
- if (firstBytes[0] == 0xFF && firstBytes[1] == 0xD8)
- {
- return ImageFileFormat.Jpeg;
- }
- if (firstBytes[0] == 0x42 && firstBytes[1] == 0x4D)
- {
- return ImageFileFormat.Bmp;
- }
- if (firstBytes[0] == 0x49 && firstBytes[1] == 0x49 && firstBytes[2] == 0x2A && firstBytes[3] == 0x00)
- {
- return ImageFileFormat.Tiff;
- }
- if (firstBytes[0] == 0x4D && firstBytes[1] == 0x4D && firstBytes[2] == 0x00 && firstBytes[3] == 0x2A)
- {
- return ImageFileFormat.Tiff;
- }
- if (firstBytes[4] == 0x6A && firstBytes[5] == 0x50 && firstBytes[6] == 0x20 && firstBytes[7] == 0x20)
+
+ return GetFileFormatFromFirstBytes(firstBytes);
+ }
+
+ public static ImageFileFormat GetFileFormatFromFirstBytes(byte[] firstBytes)
+ {
+ return firstBytes switch
{
- return ImageFileFormat.Jpeg2000;
- }
- return ImageFileFormat.Unspecified;
+ [0x89, 0x50, 0x4E, 0x47, ..] => ImageFileFormat.Png,
+ [0xFF, 0xD8, ..] => ImageFileFormat.Jpeg,
+ [0x42, 0x4D, ..] => ImageFileFormat.Bmp,
+ [0x49, 0x49, 0x2A, 0x00, ..] => ImageFileFormat.Tiff,
+ [0x4D, 0x4D, 0x00, 0x2A, ..] => ImageFileFormat.Tiff,
+ [_, _, _, _, 0x6A, 0x50, 0x20, 0x20, ..] => ImageFileFormat.Jpeg2000,
+ _ => ImageFileFormat.Unknown
+ };
}
- protected ImageContext(Type imageType, IPdfRenderer? pdfRenderer = null)
+ protected ImageContext(Type imageType)
{
ImageType = imageType;
- _pdfRenderer = pdfRenderer;
}
- // TODO: Add NotNullWhen attribute?
- private bool MaybeRenderPdf(ImageFileStorage fileStorage, out IMemoryImage? renderedPdf)
+ private bool MaybeRenderPdf(ImageFileStorage fileStorage, IPdfRenderer? pdfRenderer,
+ [NotNullWhen(true)] out IMemoryImage? renderedPdf)
{
if (Path.GetExtension(fileStorage.FullPath).ToLowerInvariant() == ".pdf")
{
- if (_pdfRenderer == null)
+ if (pdfRenderer == null)
{
throw new InvalidOperationException(
- "Unable to render pdf page as the ImageContext wasn't created with an IPdfRenderer.");
+ "Unable to render pdf page as the IRenderableImage didn't implement IPdfRendererProvider.");
}
- renderedPdf = _pdfRenderer.Render(this, fileStorage.FullPath, PdfRenderSize.Default).Single();
+ renderedPdf = pdfRenderer.RenderPage(this, fileStorage.FullPath, PdfRenderSize.Default);
return true;
}
renderedPdf = null;
return false;
}
- private bool MaybeRenderPdf(ImageMemoryStorage memoryStorage, out IMemoryImage? renderedPdf)
+ private bool MaybeRenderPdf(ImageMemoryStorage memoryStorage, IPdfRenderer? pdfRenderer,
+ [NotNullWhen(true)] out IMemoryImage? renderedPdf)
{
if (memoryStorage.TypeHint == ".pdf")
{
- if (_pdfRenderer == null)
+ if (pdfRenderer == null)
{
throw new InvalidOperationException(
- "Unable to render pdf page as the ImageContext wasn't created with an IPdfRenderer.");
+ "Unable to render pdf page as the IRenderableImage didn't implement IPdfRendererProvider.");
}
var stream = memoryStorage.Stream;
- renderedPdf = _pdfRenderer.Render(this, stream.GetBuffer(), (int) stream.Length, PdfRenderSize.Default)
- .Single();
+ renderedPdf = pdfRenderer.RenderPage(this, stream.GetBuffer(), (int) stream.Length, PdfRenderSize.Default);
return true;
}
renderedPdf = null;
@@ -133,8 +122,6 @@ format is ImageFileFormat.Bmp or ImageFileFormat.Jpeg or ImageFileFormat.Png ||
protected virtual bool SupportsTiff => false;
protected virtual bool SupportsJpeg2000 => false;
- // TODO: Implement these 4 load methods here, calling protected abstract internal methods.
- // TODO: That will let us implement common behavior (reading file formats, setting originalfileformat/logicalpixelformat) consistently.
///
/// Loads an image from the given file path.
///
@@ -156,11 +143,10 @@ public IMemoryImage Load(Stream stream)
var format = GetFileFormatFromFirstBytes(stream);
CheckSupportsFormat(format);
var image = LoadCore(stream, format);
- if (image.OriginalFileFormat == ImageFileFormat.Unspecified)
+ if (image.OriginalFileFormat == ImageFileFormat.Unknown)
{
image.OriginalFileFormat = format;
}
- image.UpdateLogicalPixelFormat();
return image;
}
@@ -234,11 +220,10 @@ private async IAsyncEnumerable WrapSource(IAsyncEnumerable
+ /// Checks if we can copy the source JPEG directly rather than re-encoding and suffering JPEG degradation.
+ ///
+ ///
+ ///
+ ///
+ internal static bool IsUntransformedJpegFile(this IRenderableImage image, out string jpegPath)
+ {
+ if (image is { Storage: ImageFileStorage fileStorage, TransformState.IsEmpty: true } &&
+ ImageContext.GetFileFormatFromExtension(fileStorage.FullPath) == ImageFileFormat.Jpeg)
+ {
+ jpegPath = fileStorage.FullPath;
+ return true;
+ }
+ jpegPath = null!;
+ return false;
+ }
+
+ ///
+ /// Saves the image to the given file path. If the file format is unspecified, it will be inferred from the
+ /// file extension if possible.
+ ///
+ /// The image to save.
+ /// The path to save the image file to.
+ /// The file format to use.
+ /// Options for saving, e.g. JPEG quality.
+ public static void Save(this IRenderableImage image, string path,
+ ImageFileFormat imageFormat = ImageFileFormat.Unknown, ImageSaveOptions? options = null)
+ {
+ if (imageFormat == ImageFileFormat.Unknown)
+ {
+ imageFormat = ImageContext.GetFileFormatFromExtension(path);
+ }
+ if (imageFormat == ImageFileFormat.Jpeg && image.IsUntransformedJpegFile(out var jpegPath))
+ {
+ File.Copy(jpegPath, path);
+ return;
+ }
+ using var renderedImage = image.Render();
+ renderedImage.Save(path, imageFormat, options);
+ }
+
+ ///
+ /// Saves the image to the given stream. The file format must be specified.
+ ///
+ /// The image to save.
+ /// The stream to save the image to.
+ /// The file format to use.
+ /// Options for saving, e.g. JPEG quality.
+ public static void Save(this IRenderableImage image, Stream stream,
+ ImageFileFormat imageFormat = ImageFileFormat.Unknown, ImageSaveOptions? options = null)
+ {
+ if (imageFormat == ImageFileFormat.Unknown)
+ {
+ throw new ArgumentException("Format required to save to a stream", nameof(imageFormat));
+ }
+ if (imageFormat == ImageFileFormat.Jpeg && image.IsUntransformedJpegFile(out var jpegPath))
+ {
+ using var fileStream = File.OpenRead(jpegPath);
+ fileStream.CopyTo(stream);
+ return;
+ }
+ using var renderedImage = image.Render();
+ renderedImage.Save(stream, imageFormat, options);
+ }
+
+ ///
+ /// Saves the image to a new MemoryStream object. The file format must be specified.
+ ///
+ /// The image to save.
+ /// The file format to use.
+ /// Options for saving, e.g. JPEG quality.
+ public static MemoryStream SaveToMemoryStream(this IMemoryImage image, ImageFileFormat imageFormat,
+ ImageSaveOptions? options = null)
+ {
+ var stream = new MemoryStream();
+ image.Save(stream, imageFormat, options);
+ stream.Seek(0, SeekOrigin.Begin);
+ return stream;
+ }
+
+ ///
+ /// Saves the image to a new MemoryStream object. The file format must be specified.
+ ///
+ /// The image to save.
+ /// The file format to use.
+ /// Options for saving, e.g. JPEG quality.
+ public static MemoryStream SaveToMemoryStream(this IRenderableImage image, ImageFileFormat imageFormat,
+ ImageSaveOptions? options = null)
+ {
+ var stream = new MemoryStream();
+ image.Save(stream, imageFormat, options);
+ return stream;
+ }
+
public static IMemoryImage PerformTransform(this IMemoryImage image, Transform transform)
{
return image.ImageContext.PerformTransform(image, transform);
@@ -19,11 +115,16 @@ public static IMemoryImage PerformAllTransforms(this IMemoryImage image, IEnumer
return image.ImageContext.PerformAllTransforms(image, transforms);
}
- public static void UpdateLogicalPixelFormat(this IMemoryImage image)
+ public static ImagePixelFormat UpdateLogicalPixelFormat(this IMemoryImage image)
{
+ if (image.LogicalPixelFormat != ImagePixelFormat.Unknown)
+ {
+ return image.LogicalPixelFormat;
+ }
var op = new LogicalPixelFormatOp();
op.Perform(image);
image.LogicalPixelFormat = op.LogicalPixelFormat;
+ return image.LogicalPixelFormat;
}
///
@@ -47,6 +148,17 @@ public static IMemoryImage Copy(this IMemoryImage source)
return source.CopyWithPixelFormat(source.PixelFormat);
}
+ ///
+ /// Creates a new image with the same content, dimensions, and resolution as this image.
+ ///
+ ///
+ ///
+ ///
+ public static IMemoryImage Copy(this IMemoryImage source, ImageContext imageContext)
+ {
+ return source.CopyWithPixelFormat(imageContext, source.PixelFormat);
+ }
+
///
/// Creates a new image with the same content, dimensions, and resolution as this image, but possibly with a different pixel format.
/// This can result in some loss of information (e.g. when converting color to gray or black/white).
@@ -56,13 +168,28 @@ public static IMemoryImage Copy(this IMemoryImage source)
///
public static IMemoryImage CopyWithPixelFormat(this IMemoryImage source, ImagePixelFormat pixelFormat)
{
- if (pixelFormat == ImagePixelFormat.Unsupported) throw new ArgumentException();
- var newImage = source.CopyBlankWithPixelFormat(pixelFormat);
+ return source.CopyWithPixelFormat(source.ImageContext, pixelFormat);
+ }
+
+ ///
+ /// Creates a new image with the same content, dimensions, and resolution as this image, but possibly with a different pixel format.
+ /// This can result in some loss of information (e.g. when converting color to gray or black/white).
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static IMemoryImage CopyWithPixelFormat(this IMemoryImage source, ImageContext imageContext,
+ ImagePixelFormat pixelFormat)
+ {
+ if (pixelFormat == ImagePixelFormat.Unknown) throw new ArgumentException();
+ var newImage = source.CopyBlankWithPixelFormat(imageContext, pixelFormat);
new CopyBitwiseImageOp().Perform(source, newImage);
newImage.OriginalFileFormat = source.OriginalFileFormat;
- if (source.LogicalPixelFormat < pixelFormat)
+ if (source.LogicalPixelFormat != ImagePixelFormat.Unknown)
{
- newImage.LogicalPixelFormat = source.LogicalPixelFormat;
+ newImage.LogicalPixelFormat =
+ source.LogicalPixelFormat < pixelFormat ? source.LogicalPixelFormat : pixelFormat;
}
return newImage;
}
@@ -85,19 +212,23 @@ public static IMemoryImage CopyBlank(this IMemoryImage source)
///
public static IMemoryImage CopyBlankWithPixelFormat(this IMemoryImage source, ImagePixelFormat pixelFormat)
{
- if (pixelFormat == ImagePixelFormat.Unsupported) throw new ArgumentException();
- var newImage = source.ImageContext.Create(source.Width, source.Height, pixelFormat);
- newImage.SetResolution(source.HorizontalResolution, source.VerticalResolution);
- return newImage;
+ return CopyBlankWithPixelFormat(source, source.ImageContext, pixelFormat);
}
- public static MemoryStream SaveToMemoryStream(this IMemoryImage image, ImageFileFormat imageFormat,
- int quality = -1)
+ ///
+ /// Creates a new (empty) image with the same dimensions and resolution as this image, and the specified pixel format.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static IMemoryImage CopyBlankWithPixelFormat(this IMemoryImage source, ImageContext imageContext,
+ ImagePixelFormat pixelFormat)
{
- var stream = new MemoryStream();
- image.Save(stream, imageFormat, quality);
- stream.Seek(0, SeekOrigin.Begin);
- return stream;
+ if (pixelFormat == ImagePixelFormat.Unknown) throw new ArgumentException();
+ var newImage = imageContext.Create(source.Width, source.Height, pixelFormat);
+ newImage.SetResolution(source.HorizontalResolution, source.VerticalResolution);
+ return newImage;
}
public static string AsTypeHint(this ImageFileFormat imageFormat)
diff --git a/NAPS2.Images/ImageFileFormat.cs b/NAPS2.Images/ImageFileFormat.cs
index 996f291f20..416861993a 100644
--- a/NAPS2.Images/ImageFileFormat.cs
+++ b/NAPS2.Images/ImageFileFormat.cs
@@ -2,7 +2,7 @@ namespace NAPS2.Images;
public enum ImageFileFormat
{
- Unspecified,
+ Unknown,
Bmp,
Jpeg,
Jpeg2000,
diff --git a/NAPS2.Images/Storage/ImageFileStorage.cs b/NAPS2.Images/ImageFileStorage.cs
similarity index 90%
rename from NAPS2.Images/Storage/ImageFileStorage.cs
rename to NAPS2.Images/ImageFileStorage.cs
index c841fae71b..2c4c2541aa 100644
--- a/NAPS2.Images/Storage/ImageFileStorage.cs
+++ b/NAPS2.Images/ImageFileStorage.cs
@@ -1,6 +1,6 @@
-namespace NAPS2.Images.Storage;
+namespace NAPS2.Images;
-public class ImageFileStorage : IImageStorage
+internal class ImageFileStorage : IImageStorage
{
private bool _disposed;
diff --git a/NAPS2.Images/Storage/ImageMemoryStorage.cs b/NAPS2.Images/ImageMemoryStorage.cs
similarity index 91%
rename from NAPS2.Images/Storage/ImageMemoryStorage.cs
rename to NAPS2.Images/ImageMemoryStorage.cs
index 5859c0332d..9ddcccc345 100644
--- a/NAPS2.Images/Storage/ImageMemoryStorage.cs
+++ b/NAPS2.Images/ImageMemoryStorage.cs
@@ -1,4 +1,4 @@
-namespace NAPS2.Images.Storage;
+namespace NAPS2.Images;
///
/// A special type of image storage that stores an image encoded as an in-memory PNG/JPEG/PDF stream. Normally in-memory
@@ -6,7 +6,7 @@
/// serialization use cases where we don't know yet if the image will be stored in-memory or on disk. And for PDFs
/// this is the only option for in-memory storage.
///
-public class ImageMemoryStorage : IImageStorage
+internal class ImageMemoryStorage : IImageStorage
{
public ImageMemoryStorage(MemoryStream stream, string typeHint)
{
diff --git a/NAPS2.Images/ImagePixelFormat.cs b/NAPS2.Images/ImagePixelFormat.cs
index 3f7c379cce..5349b3ed63 100644
--- a/NAPS2.Images/ImagePixelFormat.cs
+++ b/NAPS2.Images/ImagePixelFormat.cs
@@ -2,9 +2,9 @@
public enum ImagePixelFormat
{
- Unsupported,
+ Unknown,
BW1,
Gray8,
- RGB24, // This is actually BGR in the binary representation
+ RGB24,
ARGB32
}
\ No newline at end of file
diff --git a/NAPS2.Images/ImageSaveOptions.cs b/NAPS2.Images/ImageSaveOptions.cs
new file mode 100644
index 0000000000..4ea35eeb0c
--- /dev/null
+++ b/NAPS2.Images/ImageSaveOptions.cs
@@ -0,0 +1,18 @@
+namespace NAPS2.Images;
+
+public record ImageSaveOptions
+{
+ ///
+ /// The quality parameter for JPEG compression, if applicable. -1 for default.
+ ///
+ public int Quality { get; init; } = -1;
+
+ ///
+ /// The preferred pixel format that should be used for saving. If not specified, the image's LogicalPixelFormat
+ /// will be preferred to minimize disk space used.
+ ///
+ /// This will not result in a loss of information, e.g. if you set this to BW1 but your image has color in it, it
+ /// will have no effect. If you want to change the color information, use CopyWithPixelFormat before saving.
+ ///
+ public ImagePixelFormat PixelFormatHint { get; init; }
+}
\ No newline at end of file
diff --git a/NAPS2.Images/LICENSE b/NAPS2.Images/LICENSE
index 3f89270f9d..f62d4e9cab 100644
--- a/NAPS2.Images/LICENSE
+++ b/NAPS2.Images/LICENSE
@@ -1,7 +1,7 @@
NAPS2.Images
https://www.github.com/cyanfish/naps2/
-Copyright 2009-2022 NAPS2 Contributors
+Copyright 2009-2025 NAPS2 Contributors
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
diff --git a/NAPS2.Images/NAPS2.Images.csproj b/NAPS2.Images/NAPS2.Images.csproj
index 7056ad8f25..4d6750708c 100644
--- a/NAPS2.Images/NAPS2.Images.csproj
+++ b/NAPS2.Images/NAPS2.Images.csproj
@@ -1,7 +1,7 @@
- net6;net462;netstandard2.0
+ net6;net8;net462;netstandard2.0
enable
true
false
@@ -9,14 +9,20 @@
true
NAPS2.Images
- Copyright 2022 Ben Olden-Cooligan
+ NAPS2.Images
+ Base image abstraction for NAPS2.Sdk. Don't reference this project directly.
+ naps2
+
+
-
-
-
-
+
+
+
+
+
+
@@ -26,9 +32,27 @@
<_Parameter1>NAPS2.Sdk.Tests
+
+ <_Parameter1>NAPS2.Lib.Tests
+
<_Parameter1>NAPS2.Lib
+
+ <_Parameter1>NAPS2.Images.Gdi
+
+
+ <_Parameter1>NAPS2.Images.Wpf
+
+
+ <_Parameter1>NAPS2.Images.Gtk
+
+
+ <_Parameter1>NAPS2.Images.Mac
+
+
+ <_Parameter1>NAPS2.Images.ImageSharp
+
diff --git a/NAPS2.Images/PdfRenderSize.cs b/NAPS2.Images/PdfRenderSize.cs
index 5fd9df5948..4e43029173 100644
--- a/NAPS2.Images/PdfRenderSize.cs
+++ b/NAPS2.Images/PdfRenderSize.cs
@@ -1,6 +1,6 @@
namespace NAPS2.Images;
-public class PdfRenderSize
+internal class PdfRenderSize
{
public static readonly PdfRenderSize Default = FromDpi(300);
diff --git a/NAPS2.Images/PixelFormatHelper.cs b/NAPS2.Images/PixelFormatHelper.cs
new file mode 100644
index 0000000000..9fbe7cbb05
--- /dev/null
+++ b/NAPS2.Images/PixelFormatHelper.cs
@@ -0,0 +1,60 @@
+namespace NAPS2.Images;
+
+// TODO: Use this for TIFF saving too, maybe
+internal static class PixelFormatHelper
+{
+ public static PixelFormatHelper Create(T image,
+ ImagePixelFormat targetFormat = ImagePixelFormat.Unknown,
+ ImagePixelFormat minFormat = ImagePixelFormat.Unknown) where T : IMemoryImage
+ {
+ return new PixelFormatHelper(image, targetFormat, minFormat);
+ }
+}
+
+internal class PixelFormatHelper : IDisposable where T : IMemoryImage
+{
+ public PixelFormatHelper(T image, ImagePixelFormat targetFormat, ImagePixelFormat minFormat)
+ {
+ image.UpdateLogicalPixelFormat();
+ // TODO: Maybe we can be aware of the target filetype, e.g. JPEG doesn't have 1bpp. Although the specifics
+ // are going to be platform-dependent.
+ if (targetFormat == ImagePixelFormat.Unknown)
+ {
+ // If targetFormat is not specified, we'll use the logical format to minimize on-disk size.
+ targetFormat = image.LogicalPixelFormat;
+ }
+ if (targetFormat < image.LogicalPixelFormat)
+ {
+ // We never want to lose color information.
+ targetFormat = image.LogicalPixelFormat;
+ }
+ if (targetFormat < minFormat)
+ {
+ // GTK only supports RGB24/ARGB32 so it's pointless to target BW1/Gray8 as it will end up as RGB24 anyway.
+ targetFormat = minFormat;
+ }
+
+ if (targetFormat != image.PixelFormat)
+ {
+ Image = (T) image.CopyWithPixelFormat(targetFormat);
+ IsCopy = true;
+ }
+ else
+ {
+ Image = image;
+ IsCopy = false;
+ }
+ }
+
+ public T Image { get; }
+
+ public bool IsCopy { get; }
+
+ public void Dispose()
+ {
+ if (IsCopy)
+ {
+ Image.Dispose();
+ }
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images/Bitwise/PixelInfo.cs b/NAPS2.Images/PixelInfo.cs
similarity index 78%
rename from NAPS2.Images/Bitwise/PixelInfo.cs
rename to NAPS2.Images/PixelInfo.cs
index f9d37378a4..ea21af4819 100644
--- a/NAPS2.Images/Bitwise/PixelInfo.cs
+++ b/NAPS2.Images/PixelInfo.cs
@@ -1,8 +1,8 @@
-namespace NAPS2.Images.Bitwise;
+namespace NAPS2.Images;
public class PixelInfo
{
- public PixelInfo(int width, int height, SubPixelType subPixelType, int stride = -1)
+ public PixelInfo(int width, int height, SubPixelType subPixelType, int stride = -1, int strideAlign = -1)
{
var minStride = (width * subPixelType.BitsPerPixel + 7) / 8;
if (stride == -1)
@@ -13,6 +13,10 @@ public PixelInfo(int width, int height, SubPixelType subPixelType, int stride =
{
throw new ArgumentException("Invalid stride");
}
+ if (strideAlign > 0)
+ {
+ stride = (stride + (strideAlign - 1)) / strideAlign * strideAlign;
+ }
Width = width;
Height = height;
SubPixelType = subPixelType;
diff --git a/NAPS2.Images/Bitwise/SubPixelType.cs b/NAPS2.Images/SubPixelType.cs
similarity index 98%
rename from NAPS2.Images/Bitwise/SubPixelType.cs
rename to NAPS2.Images/SubPixelType.cs
index 602792a3f9..5007cea6ad 100644
--- a/NAPS2.Images/Bitwise/SubPixelType.cs
+++ b/NAPS2.Images/SubPixelType.cs
@@ -1,4 +1,4 @@
-namespace NAPS2.Images.Bitwise;
+namespace NAPS2.Images;
public class SubPixelType
{
diff --git a/NAPS2.Images/TransformState.cs b/NAPS2.Images/TransformState.cs
index 52f759e7a8..6ecdc84f88 100644
--- a/NAPS2.Images/TransformState.cs
+++ b/NAPS2.Images/TransformState.cs
@@ -3,7 +3,6 @@
namespace NAPS2.Images;
-// TODO: Make sure transform equality works
public record TransformState(ImmutableList Transforms)
{
public static readonly TransformState Empty = new(ImmutableList.Empty);
diff --git a/NAPS2.Images/Storage/AbstractImageTransformer.cs b/NAPS2.Images/Transforms/AbstractImageTransformer.cs
similarity index 83%
rename from NAPS2.Images/Storage/AbstractImageTransformer.cs
rename to NAPS2.Images/Transforms/AbstractImageTransformer.cs
index 15a981648f..c37c04fe14 100644
--- a/NAPS2.Images/Storage/AbstractImageTransformer.cs
+++ b/NAPS2.Images/Transforms/AbstractImageTransformer.cs
@@ -1,8 +1,9 @@
using NAPS2.Images.Bitwise;
+using NAPS2.Util;
-namespace NAPS2.Images.Storage;
+namespace NAPS2.Images.Transforms;
-public abstract class AbstractImageTransformer where TImage : IMemoryImage
+internal abstract class AbstractImageTransformer where TImage : IMemoryImage
{
protected AbstractImageTransformer(ImageContext imageContext)
{
@@ -13,7 +14,7 @@ protected AbstractImageTransformer(ImageContext imageContext)
public TImage Apply(TImage image, Transform transform)
{
- if (image.PixelFormat == ImagePixelFormat.Unsupported)
+ if (image.PixelFormat == ImagePixelFormat.Unknown)
{
throw new ArgumentException("Unsupported pixel format for transforms");
}
@@ -41,6 +42,8 @@ public TImage Apply(TImage image, Transform transform)
return PerformTransform(image, thumbnailTransform);
case BlackWhiteTransform blackWhiteTransform:
return PerformTransform(image, blackWhiteTransform);
+ case GrayscaleTransform grayscaleTransform:
+ return PerformTransform(image, grayscaleTransform);
case ColorBitDepthTransform colorBitDepthTransform:
return PerformTransform(image, colorBitDepthTransform);
case CorrectionTransform correctionTransform:
@@ -52,16 +55,21 @@ public TImage Apply(TImage image, Transform transform)
private TImage PerformTransform(TImage image, CorrectionTransform transform)
{
+ image.UpdateLogicalPixelFormat();
+ if (image.LogicalPixelFormat == ImagePixelFormat.BW1)
+ {
+ return image;
+ }
// TODO: Include deskew?
// TODO: Add border detection/removal? After deskew.
var stopwatch = Stopwatch.StartNew();
- ColumnColorOp.PerformFullOp(image);
- Console.WriteLine($"Column color op time: {stopwatch.ElapsedMilliseconds}");
+ // ColumnColorOp.PerformFullOp(image);
+ // Console.WriteLine($"Column color op time: {stopwatch.ElapsedMilliseconds}");
stopwatch.Restart();
if (transform.Mode == CorrectionMode.Document)
{
WhiteBlackPointOp.PerformFullOp(image, transform.Mode);
- Console.WriteLine($"White/black point op time: {stopwatch.ElapsedMilliseconds}");
+ // Console.WriteLine($"White/black point op time: {stopwatch.ElapsedMilliseconds}");
stopwatch.Restart();
// A previous version ran a filter pass before white/black point correction with the theory that it could
// help the accuracy of that correction.
@@ -70,7 +78,7 @@ private TImage PerformTransform(TImage image, CorrectionTransform transform)
// not yet corrected) it could potentially remove fine details.
var image2 = (TImage) image.CopyBlank();
new BilateralFilterOp().Perform(image, image2);
- Console.WriteLine($"Bilateral filter op time: {stopwatch.ElapsedMilliseconds}");
+ // Console.WriteLine($"Bilateral filter op time: {stopwatch.ElapsedMilliseconds}");
stopwatch.Restart();
image.Dispose();
return image2;
@@ -78,7 +86,7 @@ private TImage PerformTransform(TImage image, CorrectionTransform transform)
else
{
WhiteBlackPointOp.PerformFullOp(image, transform.Mode);
- Console.WriteLine($"White/black point op time: {stopwatch.ElapsedMilliseconds}");
+ // Console.WriteLine($"White/black point op time: {stopwatch.ElapsedMilliseconds}");
stopwatch.Restart();
return image;
}
@@ -86,12 +94,6 @@ private TImage PerformTransform(TImage image, CorrectionTransform transform)
protected virtual TImage PerformTransform(TImage image, BrightnessTransform transform)
{
- if (image.PixelFormat is ImagePixelFormat.BW1)
- {
- // No need to handle black & white since brightness is a null transform
- return image;
- }
-
float brightnessNormalized = transform.Brightness / 1000f;
EnsurePixelFormat(ref image);
new BrightnessBitwiseImageOp(brightnessNormalized).Perform(image);
@@ -162,11 +164,11 @@ protected virtual TImage PerformTransform(TImage image, CropTransform transform)
double xScale = image.Width / (double) (transform.OriginalWidth ?? image.Width),
yScale = image.Height / (double) (transform.OriginalHeight ?? image.Height);
- int x = Clamp((int) Math.Round(transform.Left * xScale), 0, image.Width - 1);
- int y = Clamp((int) Math.Round(transform.Top * yScale), 0, image.Height - 1);
- int width = Clamp(image.Width - (int) Math.Round((transform.Left + transform.Right) * xScale), 1,
+ int x = ((int) Math.Round(transform.Left * xScale)).Clamp(0, image.Width - 1);
+ int y = ((int) Math.Round(transform.Top * yScale)).Clamp(0, image.Height - 1);
+ int width = (image.Width - (int) Math.Round((transform.Left + transform.Right) * xScale)).Clamp(1,
image.Width - x);
- int height = Clamp(image.Height - (int) Math.Round((transform.Top + transform.Bottom) * yScale), 1,
+ int height = (image.Height - (int) Math.Round((transform.Top + transform.Bottom) * yScale)).Clamp(1,
image.Height - y);
var result = ImageContext.Create(width, height, image.PixelFormat);
@@ -183,23 +185,10 @@ protected virtual TImage PerformTransform(TImage image, CropTransform transform)
return (TImage) result;
}
- private int Clamp(int val, int min, int max)
- {
- if (val.CompareTo(min) < 0)
- {
- return min;
- }
- if (val.CompareTo(max) > 0)
- {
- return max;
- }
- return val;
- }
-
protected virtual TImage PerformTransform(TImage image, ScaleTransform transform)
{
- var width = (int) Math.Round(image.Width * transform.ScaleFactor);
- var height = (int) Math.Round(image.Height * transform.ScaleFactor);
+ var width = (int) Math.Max(Math.Round(image.Width * transform.ScaleFactor), 1);
+ var height = (int) Math.Max(Math.Round(image.Height * transform.ScaleFactor), 1);
return PerformTransform(image, new ResizeTransform(width, height));
}
@@ -234,6 +223,18 @@ protected virtual TImage PerformTransform(TImage image, BlackWhiteTransform tran
return (TImage) monoBitmap;
}
+ protected virtual TImage PerformTransform(TImage image, GrayscaleTransform transform)
+ {
+ if (image.PixelFormat is ImagePixelFormat.BW1 or ImagePixelFormat.Gray8)
+ {
+ return image;
+ }
+
+ var grayscaleBitmap = image.CopyWithPixelFormat(ImagePixelFormat.Gray8);
+ image.Dispose();
+ return (TImage) grayscaleBitmap;
+ }
+
protected virtual TImage PerformTransform(TImage image, ColorBitDepthTransform transform)
{
if (image.PixelFormat is ImagePixelFormat.RGB24 or ImagePixelFormat.ARGB32)
@@ -265,7 +266,7 @@ protected void EnsurePixelFormat(ref TImage image)
/// The result that may be replaced.
protected void OptimizePixelFormat(TImage original, ref TImage result)
{
- if (original.PixelFormat == ImagePixelFormat.BW1)
+ if (original.UpdateLogicalPixelFormat() == ImagePixelFormat.BW1)
{
result = PerformTransform(result, new BlackWhiteTransform());
}
diff --git a/NAPS2.Images/Transforms/BlackWhiteTransform.cs b/NAPS2.Images/Transforms/BlackWhiteTransform.cs
index 07290355c9..e0400fcbed 100644
--- a/NAPS2.Images/Transforms/BlackWhiteTransform.cs
+++ b/NAPS2.Images/Transforms/BlackWhiteTransform.cs
@@ -3,7 +3,7 @@
namespace NAPS2.Images.Transforms;
-public class BlackWhiteTransform : Transform
+public record BlackWhiteTransform : Transform
{
public BlackWhiteTransform()
{
diff --git a/NAPS2.Images/Transforms/BrightnessTransform.cs b/NAPS2.Images/Transforms/BrightnessTransform.cs
index e705808df2..9462021d71 100644
--- a/NAPS2.Images/Transforms/BrightnessTransform.cs
+++ b/NAPS2.Images/Transforms/BrightnessTransform.cs
@@ -3,7 +3,7 @@
namespace NAPS2.Images.Transforms;
-public class BrightnessTransform : Transform
+public record BrightnessTransform : Transform
{
public BrightnessTransform()
{
diff --git a/NAPS2.Images/Transforms/ColorBitDepthTransform.cs b/NAPS2.Images/Transforms/ColorBitDepthTransform.cs
index a187fe39e2..732264d727 100644
--- a/NAPS2.Images/Transforms/ColorBitDepthTransform.cs
+++ b/NAPS2.Images/Transforms/ColorBitDepthTransform.cs
@@ -1,6 +1,5 @@
namespace NAPS2.Images.Transforms;
-// TODO: Maybe replace with CopyWithPixelFormat?
-public class ColorBitDepthTransform : Transform
+public record ColorBitDepthTransform : Transform
{
}
\ No newline at end of file
diff --git a/NAPS2.Images/Transforms/CombineOrientation.cs b/NAPS2.Images/Transforms/CombineOrientation.cs
new file mode 100644
index 0000000000..a7ffe620bb
--- /dev/null
+++ b/NAPS2.Images/Transforms/CombineOrientation.cs
@@ -0,0 +1,7 @@
+namespace NAPS2.Images.Transforms;
+
+public enum CombineOrientation
+{
+ Horizontal,
+ Vertical
+}
\ No newline at end of file
diff --git a/NAPS2.Images/Transforms/CorrectionTransform.cs b/NAPS2.Images/Transforms/CorrectionTransform.cs
index 6aa941dba6..cadce1134f 100644
--- a/NAPS2.Images/Transforms/CorrectionTransform.cs
+++ b/NAPS2.Images/Transforms/CorrectionTransform.cs
@@ -1,7 +1,6 @@
namespace NAPS2.Images.Transforms;
-// TODO: experimental
-public class CorrectionTransform : Transform
+public record CorrectionTransform : Transform
{
public CorrectionTransform()
{
@@ -15,4 +14,17 @@ public CorrectionTransform(CorrectionMode mode)
public CorrectionMode Mode { get; private set; }
public override bool IsNull => Mode == CorrectionMode.None;
+
+ public override bool CanSimplify(Transform other) => (other as CorrectionTransform)?.Mode == Mode;
+
+ public override Transform Simplify(Transform other)
+ {
+ if ((other as CorrectionTransform)?.Mode != Mode)
+ {
+ throw new InvalidOperationException();
+ }
+ // It's not technically correct to say that this transform is idempotent, but in practice if you run it twice in
+ // a row we probably only want it to be applied once.
+ return this;
+ }
}
\ No newline at end of file
diff --git a/NAPS2.Images/Transforms/CropTransform.cs b/NAPS2.Images/Transforms/CropTransform.cs
index b994109c8b..b6d96fcc77 100644
--- a/NAPS2.Images/Transforms/CropTransform.cs
+++ b/NAPS2.Images/Transforms/CropTransform.cs
@@ -4,7 +4,7 @@
namespace NAPS2.Images.Transforms;
-public class CropTransform : Transform
+public record CropTransform : Transform
{
public CropTransform()
{
diff --git a/NAPS2.Images/Transforms/GrayscaleTransform.cs b/NAPS2.Images/Transforms/GrayscaleTransform.cs
new file mode 100644
index 0000000000..4e3b598c4c
--- /dev/null
+++ b/NAPS2.Images/Transforms/GrayscaleTransform.cs
@@ -0,0 +1,5 @@
+namespace NAPS2.Images.Transforms;
+
+public record GrayscaleTransform : Transform
+{
+}
\ No newline at end of file
diff --git a/NAPS2.Images/Transforms/HueTransform.cs b/NAPS2.Images/Transforms/HueTransform.cs
index 91e50aecd5..03f91de9ec 100644
--- a/NAPS2.Images/Transforms/HueTransform.cs
+++ b/NAPS2.Images/Transforms/HueTransform.cs
@@ -3,7 +3,7 @@
namespace NAPS2.Images.Transforms;
-public class HueTransform : Transform
+public record HueTransform : Transform
{
public HueTransform()
{
diff --git a/NAPS2.Images/Transforms/MoreImageTransforms.cs b/NAPS2.Images/Transforms/MoreImageTransforms.cs
new file mode 100644
index 0000000000..32fa0e9bab
--- /dev/null
+++ b/NAPS2.Images/Transforms/MoreImageTransforms.cs
@@ -0,0 +1,50 @@
+using NAPS2.Images.Bitwise;
+
+namespace NAPS2.Images.Transforms;
+
+public static class MoreImageTransforms
+{
+ public static IMemoryImage Combine(IMemoryImage first, IMemoryImage second, CombineOrientation orientation,
+ double offset = 0.5)
+ {
+ var imageContext = first.ImageContext;
+ var pixelFormat = first.PixelFormat > second.PixelFormat
+ ? first.PixelFormat
+ : second.PixelFormat;
+ int width = orientation == CombineOrientation.Horizontal
+ ? first.Width + second.Width
+ : Math.Max(first.Width, second.Width);
+ int height = orientation == CombineOrientation.Vertical
+ ? first.Height + second.Height
+ : Math.Max(first.Height, second.Height);
+
+ var combinedImage = imageContext.Create(width, height, pixelFormat);
+ combinedImage.SetResolution(
+ Math.Max(first.HorizontalResolution, second.HorizontalResolution),
+ Math.Max(first.VerticalResolution, second.VerticalResolution));
+
+ FillColorImageOp.White.Perform(combinedImage);
+
+ new CopyBitwiseImageOp
+ {
+ DestXOffset = orientation == CombineOrientation.Horizontal ? 0 :
+ first.Width > second.Width ? 0 :
+ (int) (offset * (second.Width - first.Width)),
+ DestYOffset = orientation == CombineOrientation.Vertical ? 0 :
+ first.Height > second.Height ? 0 :
+ (int) (offset * (second.Height - first.Height))
+ }.Perform(first, combinedImage);
+
+ new CopyBitwiseImageOp
+ {
+ DestXOffset = orientation == CombineOrientation.Horizontal ? first.Width :
+ second.Width > first.Width ? 0 :
+ (int) (offset * (first.Width - second.Width)),
+ DestYOffset = orientation == CombineOrientation.Vertical ? first.Height :
+ second.Height > first.Height ? 0 :
+ (int) (offset * (first.Height - second.Height))
+ }.Perform(second, combinedImage);
+
+ return combinedImage;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images/Transforms/ResizeTransform.cs b/NAPS2.Images/Transforms/ResizeTransform.cs
index 9c5cd6b2f2..bace9cb803 100644
--- a/NAPS2.Images/Transforms/ResizeTransform.cs
+++ b/NAPS2.Images/Transforms/ResizeTransform.cs
@@ -3,7 +3,7 @@
namespace NAPS2.Images.Transforms;
-public class ResizeTransform : Transform
+public record ResizeTransform : Transform
{
public ResizeTransform()
{
diff --git a/NAPS2.Images/Transforms/RotationTransform.cs b/NAPS2.Images/Transforms/RotationTransform.cs
index c1c6cb8e81..def7a50ba6 100644
--- a/NAPS2.Images/Transforms/RotationTransform.cs
+++ b/NAPS2.Images/Transforms/RotationTransform.cs
@@ -4,7 +4,7 @@
namespace NAPS2.Images.Transforms;
-public class RotationTransform : Transform
+public record RotationTransform : Transform
{
public const double TOLERANCE = 0.001;
diff --git a/NAPS2.Images/Transforms/SaturationTransform.cs b/NAPS2.Images/Transforms/SaturationTransform.cs
index e0a5259b9d..88a6010afe 100644
--- a/NAPS2.Images/Transforms/SaturationTransform.cs
+++ b/NAPS2.Images/Transforms/SaturationTransform.cs
@@ -3,7 +3,7 @@
namespace NAPS2.Images.Transforms;
-public class SaturationTransform : Transform
+public record SaturationTransform : Transform
{
public SaturationTransform()
{
diff --git a/NAPS2.Images/Transforms/ScaleTransform.cs b/NAPS2.Images/Transforms/ScaleTransform.cs
index de2b6244d7..2f523ab16f 100644
--- a/NAPS2.Images/Transforms/ScaleTransform.cs
+++ b/NAPS2.Images/Transforms/ScaleTransform.cs
@@ -3,7 +3,7 @@
namespace NAPS2.Images.Transforms;
-public class ScaleTransform : Transform
+public record ScaleTransform : Transform
{
public ScaleTransform()
{
diff --git a/NAPS2.Images/Transforms/SharpenTransform.cs b/NAPS2.Images/Transforms/SharpenTransform.cs
index 3140d33ee3..0128b96d33 100644
--- a/NAPS2.Images/Transforms/SharpenTransform.cs
+++ b/NAPS2.Images/Transforms/SharpenTransform.cs
@@ -3,7 +3,7 @@
namespace NAPS2.Images.Transforms;
-public class SharpenTransform : Transform
+public record SharpenTransform : Transform
{
public SharpenTransform()
{
diff --git a/NAPS2.Images/Transforms/ThumbnailTransform.cs b/NAPS2.Images/Transforms/ThumbnailTransform.cs
index 7e0a94b02e..96d84ec34f 100644
--- a/NAPS2.Images/Transforms/ThumbnailTransform.cs
+++ b/NAPS2.Images/Transforms/ThumbnailTransform.cs
@@ -1,9 +1,8 @@
-
-// ReSharper disable AutoPropertyCanBeMadeGetOnly.Local
+// ReSharper disable AutoPropertyCanBeMadeGetOnly.Local
namespace NAPS2.Images.Transforms;
-public class ThumbnailTransform : Transform
+public record ThumbnailTransform : Transform
{
public const int DEFAULT_SIZE = 256;
@@ -30,7 +29,7 @@ public ThumbnailTransform(int size)
width = Size;
left = 0;
// Scale the drawing height to match the original bitmap's aspect ratio
- height = (int)(originalHeight * (Size / (double)originalWidth));
+ height = (int) Math.Max(originalHeight * (Size / (double) originalWidth), 1);
// Center the drawing vertically
top = (Size - height) / 2;
}
@@ -40,7 +39,7 @@ public ThumbnailTransform(int size)
height = Size;
top = 0;
// Scale the drawing width to match the original bitmap's aspect ratio
- width = (int)(originalWidth * (Size / (double)originalHeight));
+ width = (int) Math.Max(originalWidth * (Size / (double) originalHeight), 1);
// Center the drawing horizontally
left = (Size - width) / 2;
}
diff --git a/NAPS2.Images/Transforms/Transform.cs b/NAPS2.Images/Transforms/Transform.cs
index e4285f298f..74b18c40a3 100644
--- a/NAPS2.Images/Transforms/Transform.cs
+++ b/NAPS2.Images/Transforms/Transform.cs
@@ -1,9 +1,25 @@
using System.Collections.Immutable;
+using System.Reflection;
+using NAPS2.Serialization;
namespace NAPS2.Images.Transforms;
-public abstract class Transform
+public abstract record Transform
{
+ static Transform()
+ {
+ XmlSerializer.RegisterCustomTypes(new TransformTypes());
+ }
+
+ private class TransformTypes : CustomXmlTypes
+ {
+ protected override Type[] GetKnownTypes() => Assembly
+ .GetExecutingAssembly()
+ .GetTypes()
+ .Where(t => typeof(Transform).IsAssignableFrom(t))
+ .ToArray();
+ }
+
///
/// Appends the specified transform to the list, merging with the previous transform on the list if simplication is possible.
///
diff --git a/NAPS2.Images/Transforms/TrueContrastTransform.cs b/NAPS2.Images/Transforms/TrueContrastTransform.cs
index 72decf44cd..483e793361 100644
--- a/NAPS2.Images/Transforms/TrueContrastTransform.cs
+++ b/NAPS2.Images/Transforms/TrueContrastTransform.cs
@@ -3,7 +3,7 @@
namespace NAPS2.Images.Transforms;
-public class TrueContrastTransform : Transform
+public record TrueContrastTransform : Transform
{
public TrueContrastTransform()
{
diff --git a/NAPS2.Images/Util/IsExternalInit.cs b/NAPS2.Images/Util/IsExternalInit.cs
deleted file mode 100644
index ba0435b8b5..0000000000
--- a/NAPS2.Images/Util/IsExternalInit.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-// https://sergiopedri.medium.com/enabling-and-using-c-9-features-on-older-and-unsupported-runtimes-ce384d8debb
-// ReSharper disable once CheckNamespace
-namespace System.Runtime.CompilerServices;
-
-public static class IsExternalInit {}
\ No newline at end of file
diff --git a/NAPS2.Internals/.gitignore b/NAPS2.Internals/.gitignore
new file mode 100644
index 0000000000..50815a5c0e
--- /dev/null
+++ b/NAPS2.Internals/.gitignore
@@ -0,0 +1,32 @@
+Thumbs.db
+*.obj
+*.exe
+*.pdb
+*.user
+*.aps
+*.pch
+*.vspscc
+*_i.c
+*_p.c
+*.ncb
+*.suo
+*.sln.docstates
+*.tlb
+*.tlh
+*.bak
+*.cache
+*.ilk
+*.log
+[Bb]in
+[Dd]ebug*/
+*.lib
+*.sbr
+obj/
+[Rr]elease*/
+_ReSharper*/
+[Tt]est[Rr]esult*
+*.vssscc
+$tf*/
+publish/
+bin/
+temp/
\ No newline at end of file
diff --git a/NAPS2.Internals/LICENSE b/NAPS2.Internals/LICENSE
new file mode 100644
index 0000000000..9920ca593b
--- /dev/null
+++ b/NAPS2.Internals/LICENSE
@@ -0,0 +1,518 @@
+NAPS2.Internals
+https://www.github.com/cyanfish/naps2/
+
+Copyright 2009-2025 NAPS2 Contributors
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+Lesser General Public License for more details.
+
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it. You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+ When we speak of free software, we are referring to freedom of use,
+not price. Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+ To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights. These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ To protect each distributor, we want to make it very clear that
+there is no warranty for the free library. Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+ Finally, software patents pose a constant threat to the existence of
+any free program. We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder. Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+ Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License. This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License. We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+ When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library. The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom. The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+ We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License. It also provides other free software developers Less
+of an advantage over competing non-free programs. These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries. However, the Lesser license provides advantages in certain
+special circumstances.
+
+ For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard. To achieve this, non-free programs must be
+allowed to use the library. A more frequent case is that a free
+library does the same job as widely used non-free libraries. In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+ In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software. For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+ Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (1) uses at run time a
+ copy of the library already present on the user's computer system,
+ rather than copying library functions into the executable, and (2)
+ will operate properly with a modified version of the library, if
+ the user installs one, as long as the modified version is
+ interface-compatible with the version that the work was made with.
+
+ c) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ d) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ e) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Libraries
+
+ If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change. You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+ To apply these terms, attach the following notices to the library. It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, write to the Free Software
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the
+ library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+ , 1 April 1990
+ Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/NAPS2.Internals/NAPS2.Internals.csproj b/NAPS2.Internals/NAPS2.Internals.csproj
new file mode 100644
index 0000000000..bbb4993c1e
--- /dev/null
+++ b/NAPS2.Internals/NAPS2.Internals.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net6;net8;net462;netstandard2.0
+ enable
+ NAPS2
+
+ NAPS2.Internals
+ NAPS2.Internals
+ Internal code for NAPS2.Sdk. Don't reference this project directly.
+ naps2
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/NAPS2.Sdk/Serialization/CustomXmlSerializer.cs b/NAPS2.Internals/Serialization/CustomXmlSerializer.cs
similarity index 100%
rename from NAPS2.Sdk/Serialization/CustomXmlSerializer.cs
rename to NAPS2.Internals/Serialization/CustomXmlSerializer.cs
diff --git a/NAPS2.Sdk/Serialization/CustomXmlTypes.cs b/NAPS2.Internals/Serialization/CustomXmlTypes.cs
similarity index 100%
rename from NAPS2.Sdk/Serialization/CustomXmlTypes.cs
rename to NAPS2.Internals/Serialization/CustomXmlTypes.cs
diff --git a/NAPS2.Sdk/Serialization/ISerializer.cs b/NAPS2.Internals/Serialization/ISerializer.cs
similarity index 100%
rename from NAPS2.Sdk/Serialization/ISerializer.cs
rename to NAPS2.Internals/Serialization/ISerializer.cs
diff --git a/NAPS2.Sdk/Serialization/SerializerExtensions.cs b/NAPS2.Internals/Serialization/SerializerExtensions.cs
similarity index 100%
rename from NAPS2.Sdk/Serialization/SerializerExtensions.cs
rename to NAPS2.Internals/Serialization/SerializerExtensions.cs
diff --git a/NAPS2.Sdk/Serialization/XmlObjectSerializer.cs b/NAPS2.Internals/Serialization/XmlObjectSerializer.cs
similarity index 100%
rename from NAPS2.Sdk/Serialization/XmlObjectSerializer.cs
rename to NAPS2.Internals/Serialization/XmlObjectSerializer.cs
diff --git a/NAPS2.Sdk/Serialization/XmlSerializer.cs b/NAPS2.Internals/Serialization/XmlSerializer.cs
similarity index 89%
rename from NAPS2.Sdk/Serialization/XmlSerializer.cs
rename to NAPS2.Internals/Serialization/XmlSerializer.cs
index 7d6f24e1b8..5149dbe800 100644
--- a/NAPS2.Sdk/Serialization/XmlSerializer.cs
+++ b/NAPS2.Internals/Serialization/XmlSerializer.cs
@@ -6,6 +6,7 @@
using System.Text;
using System.Xml;
using System.Xml.Serialization;
+using NAPS2.Util;
namespace NAPS2.Serialization;
@@ -17,13 +18,13 @@ public abstract class XmlSerializer
protected static readonly Dictionary> CustomTypesCache = new();
- protected static readonly List ArrayLikeTypes = new()
- {
+ protected static readonly List ArrayLikeTypes =
+ [
typeof(List<>),
typeof(HashSet<>),
typeof(ImmutableList<>),
- typeof(ImmutableHashSet<>),
- };
+ typeof(ImmutableHashSet<>)
+ ];
protected static readonly Dictionary TypeInfoCache = new()
{
@@ -49,17 +50,8 @@ public abstract class XmlSerializer
{ typeof(ImmutableList<>), new XmlTypeInfo { CustomSerializer = new ImmutableListSerializer() } },
{ typeof(ImmutableHashSet<>), new XmlTypeInfo { CustomSerializer = new ImmutableHashSetSerializer() } },
{ typeof(DateTime), new XmlTypeInfo { CustomSerializer = new DateTimeSerializer() } },
+ { typeof(Guid), new XmlTypeInfo { CustomSerializer = new GuidSerializer() } },
{ typeof(Nullable<>), new XmlTypeInfo { CustomSerializer = new NullableSerializer() } },
- {
- typeof(Transform), new XmlTypeInfo
- {
- KnownTypesByElementName = Assembly
- .GetAssembly(typeof(Transform))!
- .GetTypes()
- .Where(t => typeof(Transform).IsAssignableFrom(t))
- .ToDictionary(GetElementNameForType, t => t)
- }
- },
};
private static readonly Dictionary PrimitiveTypesByElementName =
@@ -88,7 +80,7 @@ public static void RegisterCustomTypes(Type type, CustomXmlTypes customTypes)
{
lock (TypeInfoCache)
{
- CustomTypesCache.GetOrSet(type, new List()).Add(customTypes);
+ CustomTypesCache.GetOrSet(type, []).Add(customTypes);
}
}
@@ -151,7 +143,16 @@ protected static object DeserializeInternalNonNull(XElement element, Type type)
{
return typeInfo.CustomSerializer.DeserializeObject(element, actualType);
}
- var obj = Activator.CreateInstance(actualType, true)!;
+ object obj;
+ try
+ {
+ obj = Activator.CreateInstance(actualType, true)!;
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Could not create type for deserialization: {actualType.FullName}",
+ ex);
+ }
foreach (var propInfo in typeInfo.Properties!)
{
// TODO: Detect unmapped elements
@@ -240,6 +241,9 @@ protected static XmlTypeInfo GetTypeInfo(Type type)
if (typeInfo.CustomSerializer == null)
{
+ // Prevent infinite recursion by populating the partial type info
+ // Cycles in type info are ok (e.g. Node.Parent is a Node), just not in the actual objects
+ TypeInfoCache[type] = typeInfo;
var knownTypesFromPropertyTypes =
props.SelectMany(x => GetTypeInfo(x.PropertyType).KnownTypesByElementName);
var knownTypesFromActualType = GetKnownTypes(type);
@@ -296,7 +300,7 @@ protected class CharSerializer : CustomXmlSerializer
{
protected override void Serialize(char obj, XElement element)
{
- element.Value = obj.ToString();
+ element.Value = obj.ToString(CultureInfo.InvariantCulture);
}
protected override char Deserialize(XElement element)
@@ -335,12 +339,12 @@ protected class ByteSerializer : CustomXmlSerializer
{
protected override void Serialize(byte obj, XElement element)
{
- element.Value = obj.ToString();
+ element.Value = obj.ToString(CultureInfo.InvariantCulture);
}
protected override byte Deserialize(XElement element)
{
- return byte.Parse(element.Value);
+ return byte.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -348,12 +352,12 @@ protected class SByteSerializer : CustomXmlSerializer
{
protected override void Serialize(sbyte obj, XElement element)
{
- element.Value = obj.ToString();
+ element.Value = obj.ToString(CultureInfo.InvariantCulture);
}
protected override sbyte Deserialize(XElement element)
{
- return sbyte.Parse(element.Value);
+ return sbyte.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -361,12 +365,12 @@ protected class Int16Serializer : CustomXmlSerializer
{
protected override void Serialize(short obj, XElement element)
{
- element.Value = obj.ToString();
+ element.Value = obj.ToString(CultureInfo.InvariantCulture);
}
protected override short Deserialize(XElement element)
{
- return short.Parse(element.Value);
+ return short.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -374,12 +378,12 @@ protected class UInt16Serializer : CustomXmlSerializer
{
protected override void Serialize(ushort obj, XElement element)
{
- element.Value = obj.ToString();
+ element.Value = obj.ToString(CultureInfo.InvariantCulture);
}
protected override ushort Deserialize(XElement element)
{
- return ushort.Parse(element.Value);
+ return ushort.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -387,12 +391,12 @@ protected class Int32Serializer : CustomXmlSerializer
{
protected override void Serialize(int obj, XElement element)
{
- element.Value = obj.ToString();
+ element.Value = obj.ToString(CultureInfo.InvariantCulture);
}
protected override int Deserialize(XElement element)
{
- return int.Parse(element.Value);
+ return int.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -400,12 +404,12 @@ protected class UInt32Serializer : CustomXmlSerializer
{
protected override void Serialize(uint obj, XElement element)
{
- element.Value = obj.ToString();
+ element.Value = obj.ToString(CultureInfo.InvariantCulture);
}
protected override uint Deserialize(XElement element)
{
- return uint.Parse(element.Value);
+ return uint.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -413,12 +417,12 @@ protected class Int64Serializer : CustomXmlSerializer
{
protected override void Serialize(long obj, XElement element)
{
- element.Value = obj.ToString();
+ element.Value = obj.ToString(CultureInfo.InvariantCulture);
}
protected override long Deserialize(XElement element)
{
- return long.Parse(element.Value);
+ return long.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -426,12 +430,12 @@ protected class UInt64Serializer : CustomXmlSerializer
{
protected override void Serialize(ulong obj, XElement element)
{
- element.Value = obj.ToString();
+ element.Value = obj.ToString(CultureInfo.InvariantCulture);
}
protected override ulong Deserialize(XElement element)
{
- return ulong.Parse(element.Value);
+ return ulong.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -444,7 +448,7 @@ protected override void Serialize(float obj, XElement element)
protected override float Deserialize(XElement element)
{
- return float.Parse(element.Value);
+ return float.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -457,7 +461,7 @@ protected override void Serialize(double obj, XElement element)
protected override double Deserialize(XElement element)
{
- return double.Parse(element.Value);
+ return double.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -470,7 +474,7 @@ protected override void Serialize(decimal obj, XElement element)
protected override decimal Deserialize(XElement element)
{
- return decimal.Parse(element.Value);
+ return decimal.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -478,12 +482,12 @@ protected class IntPtrSerializer : CustomXmlSerializer
{
protected override void Serialize(IntPtr obj, XElement element)
{
- element.Value = obj.ToString();
+ element.Value = ((long) obj).ToString(CultureInfo.InvariantCulture);
}
protected override IntPtr Deserialize(XElement element)
{
- return (IntPtr) long.Parse(element.Value);
+ return (IntPtr) long.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -491,12 +495,12 @@ protected class UIntPtrSerializer : CustomXmlSerializer
{
protected override void Serialize(UIntPtr obj, XElement element)
{
- element.Value = obj.ToString();
+ element.Value = ((ulong) obj).ToString(CultureInfo.InvariantCulture);
}
protected override UIntPtr Deserialize(XElement element)
{
- return (UIntPtr) ulong.Parse(element.Value);
+ return (UIntPtr) ulong.Parse(element.Value, CultureInfo.InvariantCulture);
}
}
@@ -652,6 +656,19 @@ protected override DateTime Deserialize(XElement element)
}
}
+ protected class GuidSerializer : CustomXmlSerializer
+ {
+ protected override void Serialize(Guid obj, XElement element)
+ {
+ element.Value = obj.ToString();
+ }
+
+ protected override Guid Deserialize(XElement element)
+ {
+ return Guid.Parse(element.Value);
+ }
+ }
+
protected class NullableSerializer : CustomXmlSerializer
{
public override void SerializeObject(object obj, XElement element, Type type)
diff --git a/NAPS2.Images/Util/AsyncProducers.cs b/NAPS2.Internals/Threading/AsyncProducers.cs
similarity index 86%
rename from NAPS2.Images/Util/AsyncProducers.cs
rename to NAPS2.Internals/Threading/AsyncProducers.cs
index e164a29d5c..c7a58bf248 100644
--- a/NAPS2.Images/Util/AsyncProducers.cs
+++ b/NAPS2.Internals/Threading/AsyncProducers.cs
@@ -1,8 +1,14 @@
// ReSharper disable once CheckNamespace
namespace NAPS2.Util;
-internal static class AsyncProducers
+public static class AsyncProducers
{
+#pragma warning disable CS1998
+ public static async IAsyncEnumerable Empty()
+ {
+ yield break;
+ }
+
public static IAsyncEnumerable RunProducer(ItemProducer producer) where T : class
{
return RunProducer(new AsyncItemProducer(produce =>
diff --git a/NAPS2.Images/Util/AsyncSink.cs b/NAPS2.Internals/Threading/AsyncSink.cs
similarity index 91%
rename from NAPS2.Images/Util/AsyncSink.cs
rename to NAPS2.Internals/Threading/AsyncSink.cs
index 4ac5928303..fa2e0b1984 100644
--- a/NAPS2.Images/Util/AsyncSink.cs
+++ b/NAPS2.Internals/Threading/AsyncSink.cs
@@ -1,14 +1,11 @@
// ReSharper disable once CheckNamespace
namespace NAPS2.Util;
-internal class AsyncSink where T : class
+public class AsyncSink where T : class
{
private static TaskCompletionSource CreateTcs() => new(TaskCreationOptions.RunContinuationsAsynchronously);
- private readonly List> _items = new()
- {
- CreateTcs()
- };
+ private readonly List> _items = [CreateTcs()];
private bool _completed;
public async IAsyncEnumerable AsAsyncEnumerable()
diff --git a/NAPS2.Sdk/Threading/RefreshThrottle.cs b/NAPS2.Internals/Threading/RefreshThrottle.cs
similarity index 100%
rename from NAPS2.Sdk/Threading/RefreshThrottle.cs
rename to NAPS2.Internals/Threading/RefreshThrottle.cs
diff --git a/NAPS2.Sdk/Threading/SmoothProgress.cs b/NAPS2.Internals/Threading/SmoothProgress.cs
similarity index 96%
rename from NAPS2.Sdk/Threading/SmoothProgress.cs
rename to NAPS2.Internals/Threading/SmoothProgress.cs
index cc6dc17cf9..7b994a1f57 100644
--- a/NAPS2.Sdk/Threading/SmoothProgress.cs
+++ b/NAPS2.Internals/Threading/SmoothProgress.cs
@@ -39,9 +39,9 @@ public void Reset()
_stopwatch = Stopwatch.StartNew();
- _previousInputPos = new LinkedList();
+ _previousInputPos = [];
_previousInputPos.AddLast(0);
- _previousInputTimes = new LinkedList();
+ _previousInputTimes = [];
_previousInputTimes.AddLast(0);
}
}
diff --git a/NAPS2.Sdk/Threading/TaskExtensions.cs b/NAPS2.Internals/Threading/TaskExtensions.cs
similarity index 100%
rename from NAPS2.Sdk/Threading/TaskExtensions.cs
rename to NAPS2.Internals/Threading/TaskExtensions.cs
diff --git a/NAPS2.Sdk/Threading/TimedThrottle.cs b/NAPS2.Internals/Threading/TimedThrottle.cs
similarity index 100%
rename from NAPS2.Sdk/Threading/TimedThrottle.cs
rename to NAPS2.Internals/Threading/TimedThrottle.cs
diff --git a/NAPS2.Sdk/Unmanaged/UnmanagedArray.cs b/NAPS2.Internals/Unmanaged/UnmanagedArray.cs
similarity index 100%
rename from NAPS2.Sdk/Unmanaged/UnmanagedArray.cs
rename to NAPS2.Internals/Unmanaged/UnmanagedArray.cs
diff --git a/NAPS2.Sdk/Unmanaged/UnmanagedBase.cs b/NAPS2.Internals/Unmanaged/UnmanagedBase.cs
similarity index 100%
rename from NAPS2.Sdk/Unmanaged/UnmanagedBase.cs
rename to NAPS2.Internals/Unmanaged/UnmanagedBase.cs
diff --git a/NAPS2.Sdk/Unmanaged/UnmanagedObject.cs b/NAPS2.Internals/Unmanaged/UnmanagedObject.cs
similarity index 100%
rename from NAPS2.Sdk/Unmanaged/UnmanagedObject.cs
rename to NAPS2.Internals/Unmanaged/UnmanagedObject.cs
diff --git a/NAPS2.Sdk/Unmanaged/UnmanagedTypes.cs b/NAPS2.Internals/Unmanaged/UnmanagedTypes.cs
similarity index 100%
rename from NAPS2.Sdk/Unmanaged/UnmanagedTypes.cs
rename to NAPS2.Internals/Unmanaged/UnmanagedTypes.cs
diff --git a/NAPS2.Sdk/Util/CollectionExtensions.cs b/NAPS2.Internals/Util/CollectionExtensions.cs
similarity index 96%
rename from NAPS2.Sdk/Util/CollectionExtensions.cs
rename to NAPS2.Internals/Util/CollectionExtensions.cs
index 4e08bd05f9..63e4e4a4e6 100644
--- a/NAPS2.Sdk/Util/CollectionExtensions.cs
+++ b/NAPS2.Internals/Util/CollectionExtensions.cs
@@ -13,7 +13,7 @@ public static class CollectionExtensions
///
public static HashSet ToHashSet(this IEnumerable enumerable)
{
- return new HashSet(enumerable);
+ return [..enumerable];
}
#endif
@@ -134,7 +134,7 @@ public static void AddMulti(this Dictionary>
{
if (!dict.ContainsKey(key))
{
- dict[key] = new HashSet();
+ dict[key] = [];
}
dict[key].Add(value);
}
@@ -152,7 +152,7 @@ public static void AddMulti(this Dictionary>
{
if (!dict.ContainsKey(key))
{
- dict[key] = new HashSet();
+ dict[key] = [];
}
foreach (var value in values)
{
@@ -259,4 +259,12 @@ public static DisposableList ToDisposableList(this IEnumerable enumerab
{
return new DisposableList(enumerable.ToImmutableList());
}
+
+ public static void DisposeAll(this IEnumerable enumerable)
+ {
+ foreach (var item in enumerable)
+ {
+ item.Dispose();
+ }
+ }
}
\ No newline at end of file
diff --git a/NAPS2.Sdk/Util/DisposableList.cs b/NAPS2.Internals/Util/DisposableList.cs
similarity index 100%
rename from NAPS2.Sdk/Util/DisposableList.cs
rename to NAPS2.Internals/Util/DisposableList.cs
diff --git a/NAPS2.Sdk/Util/DisposableSet.cs b/NAPS2.Internals/Util/DisposableSet.cs
similarity index 91%
rename from NAPS2.Sdk/Util/DisposableSet.cs
rename to NAPS2.Internals/Util/DisposableSet.cs
index 714a93aaa1..95d0a69382 100644
--- a/NAPS2.Sdk/Util/DisposableSet.cs
+++ b/NAPS2.Internals/Util/DisposableSet.cs
@@ -2,7 +2,7 @@ namespace NAPS2.Util;
public class DisposableSet : IDisposable where T : IDisposable
{
- private readonly HashSet _set = new();
+ private readonly HashSet _set = [];
public void Add(T obj)
{
diff --git a/NAPS2.Images/Util/ExceptionExtensions.cs b/NAPS2.Internals/Util/ExceptionExtensions.cs
similarity index 94%
rename from NAPS2.Images/Util/ExceptionExtensions.cs
rename to NAPS2.Internals/Util/ExceptionExtensions.cs
index 96112d7d05..be48fe86ce 100644
--- a/NAPS2.Images/Util/ExceptionExtensions.cs
+++ b/NAPS2.Internals/Util/ExceptionExtensions.cs
@@ -3,7 +3,7 @@
// ReSharper disable once CheckNamespace
namespace NAPS2.Util;
-internal static class ExceptionExtensions
+public static class ExceptionExtensions
{
private static MethodInfo? _internalPreserveStackTrace;
diff --git a/NAPS2.Images/Util/LeakTracer.cs b/NAPS2.Internals/Util/LeakTracer.cs
similarity index 83%
rename from NAPS2.Images/Util/LeakTracer.cs
rename to NAPS2.Internals/Util/LeakTracer.cs
index e82a9feb5b..21b299488b 100644
--- a/NAPS2.Images/Util/LeakTracer.cs
+++ b/NAPS2.Internals/Util/LeakTracer.cs
@@ -28,6 +28,11 @@ public static void StopTracking(object obj)
public static void PrintTraces()
{
+ if (!ENABLE_TRACING)
+ {
+ Console.WriteLine("Leak tracing not enabled.");
+ return;
+ }
var traceCounts = new Dictionary();
foreach (var trace in _traces.Values)
{
@@ -42,5 +47,9 @@ public static void PrintTraces()
Console.WriteLine($"Potential leak (count: {kvp.Value}):");
Console.WriteLine(kvp.Key);
}
+ if (!traceCounts.Any())
+ {
+ Console.WriteLine("No leaks.");
+ }
}
}
\ No newline at end of file
diff --git a/NAPS2.Internals/Util/NumberExtensions.cs b/NAPS2.Internals/Util/NumberExtensions.cs
new file mode 100644
index 0000000000..6784ca7687
--- /dev/null
+++ b/NAPS2.Internals/Util/NumberExtensions.cs
@@ -0,0 +1,57 @@
+namespace NAPS2.Util;
+
+public static class NumberExtensions
+{
+ ///
+ /// Ensures the provided value is within the provided range (inclusive).
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static T Clamp(this T val, T min, T max) where T : IComparable
+ {
+ if (val.CompareTo(min) < 0)
+ {
+ return min;
+ }
+ if (val.CompareTo(max) > 0)
+ {
+ return max;
+ }
+ return val;
+ }
+
+ ///
+ /// Ensures the provided value is at least the provided minimum value (inclusive).
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static T AtLeast(this T val, T min) where T : IComparable
+ {
+ if (val.CompareTo(min) < 0)
+ {
+ return min;
+ }
+ return val;
+ }
+
+ ///
+ /// Ensures the provided value is at most the provided maximum value (inclusive).
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static T AtMost(this T val, T max) where T : IComparable
+ {
+ if (val.CompareTo(max) > 0)
+ {
+ return max;
+ }
+ return val;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images/Util/ObjectHelpers.cs b/NAPS2.Internals/Util/ObjectHelpers.cs
similarity index 94%
rename from NAPS2.Images/Util/ObjectHelpers.cs
rename to NAPS2.Internals/Util/ObjectHelpers.cs
index 469bfd62bf..f268fbaedc 100644
--- a/NAPS2.Images/Util/ObjectHelpers.cs
+++ b/NAPS2.Internals/Util/ObjectHelpers.cs
@@ -1,7 +1,7 @@
// ReSharper disable once CheckNamespace
namespace NAPS2.Util;
-internal static class ObjectHelpers
+public static class ObjectHelpers
{
public static bool ListEquals(IList first, IList second)
{
diff --git a/NAPS2.Internals/Util/Once.cs b/NAPS2.Internals/Util/Once.cs
new file mode 100644
index 0000000000..7f79ac23d3
--- /dev/null
+++ b/NAPS2.Internals/Util/Once.cs
@@ -0,0 +1,26 @@
+namespace NAPS2.Util;
+
+///
+/// Encapsulates an action that should be run once lazily.
+///
+public class Once
+{
+ private readonly Lazy _lazy;
+
+ public Once(Action action)
+ {
+ _lazy = new Lazy(() =>
+ {
+ action();
+ return new object();
+ });
+ }
+
+ ///
+ /// Runs the action if it hasn't already been run. If the action is already running, waits for completion.
+ ///
+ public void Run()
+ {
+ var _ = _lazy.Value;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Images/Util/ProgressCallback.cs b/NAPS2.Internals/Util/ProgressCallback.cs
similarity index 100%
rename from NAPS2.Images/Util/ProgressCallback.cs
rename to NAPS2.Internals/Util/ProgressCallback.cs
diff --git a/NAPS2.Images/Util/ProgressHandler.cs b/NAPS2.Internals/Util/ProgressHandler.cs
similarity index 100%
rename from NAPS2.Images/Util/ProgressHandler.cs
rename to NAPS2.Internals/Util/ProgressHandler.cs
diff --git a/NAPS2.Images/Util/RefCount.cs b/NAPS2.Internals/Util/RefCount.cs
similarity index 98%
rename from NAPS2.Images/Util/RefCount.cs
rename to NAPS2.Internals/Util/RefCount.cs
index f90d43fe49..cafacaab68 100644
--- a/NAPS2.Images/Util/RefCount.cs
+++ b/NAPS2.Internals/Util/RefCount.cs
@@ -1,7 +1,7 @@
// ReSharper disable once CheckNamespace
namespace NAPS2.Util;
-internal class RefCount
+public class RefCount
{
private readonly IDisposable _disposable;
private int _count;
diff --git a/NAPS2.Internals/Util/StringExtensions.cs b/NAPS2.Internals/Util/StringExtensions.cs
new file mode 100644
index 0000000000..f94989cc51
--- /dev/null
+++ b/NAPS2.Internals/Util/StringExtensions.cs
@@ -0,0 +1,9 @@
+namespace NAPS2.Util;
+
+public static class StringExtensions
+{
+ public static bool ContainsInvariantIgnoreCase(this string source, string value)
+ {
+ return source.IndexOf(value, StringComparison.InvariantCultureIgnoreCase) != -1;
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Lib.Gtk/EntryPoints/GtkEntryPoint.cs b/NAPS2.Lib.Gtk/EntryPoints/GtkEntryPoint.cs
index 204fb44420..b28cda6f63 100644
--- a/NAPS2.Lib.Gtk/EntryPoints/GtkEntryPoint.cs
+++ b/NAPS2.Lib.Gtk/EntryPoints/GtkEntryPoint.cs
@@ -1,52 +1,28 @@
-using Autofac;
-using NAPS2.EtoForms;
-using NAPS2.EtoForms.Ui;
+using NAPS2.EtoForms;
+using NAPS2.EtoForms.Gtk;
using NAPS2.Modules;
-using NAPS2.Remoting.Worker;
-using UnhandledExceptionEventArgs = Eto.UnhandledExceptionEventArgs;
namespace NAPS2.EntryPoints;
///
-/// The entry point logic for NAPS2.exe, the NAPS2 GUI.
+/// The entry point logic for the Gtk NAPS2 executable.
///
public static class GtkEntryPoint
{
public static int Run(string[] args)
{
- if (args.Length > 0 && args[0] is "cli" or "console")
- {
- return ConsoleEntryPoint.Run(args.Skip(1).ToArray(), new GtkModule());
- }
- if (args.Length > 0 && args[0] == "worker")
- {
- return WorkerEntryPoint.Run(args.Skip(1).ToArray(), new GtkModule());
- }
-
- // Initialize Autofac (the DI framework)
- var container = AutoFacHelper.FromModules(
- new CommonModule(), new GtkModule(), new RecoveryModule(), new ContextModule());
-
- Paths.ClearTemp();
-
- // Set up basic application configuration
- container.Resolve().SetCulturesFromConfig();
- TaskScheduler.UnobservedTaskException += UnhandledTaskException;
GLib.ExceptionManager.UnhandledException += UnhandledGtkException;
- Trace.Listeners.Add(new ConsoleTraceListener());
-
- // Start a pending worker process
- container.Resolve().Init();
+ GLibLogInterceptor.WriteToDebugLog();
+ EtoPlatform.Current = new GtkEtoPlatform();
- // Show the main form
- var application = EtoPlatform.Current.CreateApplication();
- application.UnhandledException += UnhandledException;
- var formFactory = container.Resolve();
- var desktop = formFactory.Create();
- // TODO: Clean up invoker setting
- // Invoker.Current = new WinFormsInvoker(desktop.ToNative());
- application.Run(desktop);
- return 0;
+ var subArgs = args.Skip(1).ToArray();
+ return args switch
+ {
+ ["cli" or "console", ..] => ConsoleEntryPoint.Run(subArgs, new GtkImagesModule(), new GtkModule()),
+ ["worker", ..] => WorkerEntryPoint.Run(subArgs, new GtkImagesModule()),
+ ["server", ..] => ServerEntryPoint.Run(subArgs, new GtkImagesModule(), new GtkModule()),
+ _ => GuiEntryPoint.Run(args, new GtkImagesModule(), new GtkModule())
+ };
}
private static void UnhandledGtkException(GLib.UnhandledExceptionArgs e)
@@ -60,15 +36,4 @@ private static void UnhandledGtkException(GLib.UnhandledExceptionArgs e)
Log.ErrorException("An unhandled error occurred.", e.ExceptionObject as Exception ?? new Exception());
}
}
-
- private static void UnhandledTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
- {
- Log.FatalException("An error occurred that caused the task to terminate.", e.Exception);
- e.SetObserved();
- }
-
- private static void UnhandledException(object? sender, UnhandledExceptionEventArgs e)
- {
- Log.FatalException("An error occurred that caused the application to close.", e.ExceptionObject as Exception ?? new Exception());
- }
}
\ No newline at end of file
diff --git a/NAPS2.Lib.Gtk/EtoForms/Gtk/GtkDarkModeProvider.cs b/NAPS2.Lib.Gtk/EtoForms/Gtk/GtkDarkModeProvider.cs
new file mode 100644
index 0000000000..1db3f50f66
--- /dev/null
+++ b/NAPS2.Lib.Gtk/EtoForms/Gtk/GtkDarkModeProvider.cs
@@ -0,0 +1,29 @@
+using Gtk;
+
+namespace NAPS2.EtoForms.Gtk;
+
+public class GtkDarkModeProvider : IDarkModeProvider
+{
+ // We need a real style context attached to the window, so it needs to be injected externally
+ public StyleContext? StyleContext { get; set; }
+
+ public bool IsDarkModeEnabled
+ {
+ get
+ {
+ var settings = Settings.GetForScreen(Gdk.Screen.Default);
+ bool isDarkByStyleContext = false;
+ if (StyleContext != null)
+ {
+ var color = StyleContext.GetColor(StateFlags.Normal);
+ isDarkByStyleContext = color is { Red: > 0.5, Green: > 0.5, Blue: > 0.5 };
+ }
+ return settings.ApplicationPreferDarkTheme || isDarkByStyleContext;
+ }
+ }
+
+ // Not sure if it's possible to detect live changes with Gtk
+#pragma warning disable CS0067
+ public event EventHandler? DarkModeChanged;
+#pragma warning restore CS0067
+}
\ No newline at end of file
diff --git a/NAPS2.Lib.Gtk/EtoForms/Gtk/GtkEtoPlatform.cs b/NAPS2.Lib.Gtk/EtoForms/Gtk/GtkEtoPlatform.cs
index 185bea5360..758b3a6340 100644
--- a/NAPS2.Lib.Gtk/EtoForms/Gtk/GtkEtoPlatform.cs
+++ b/NAPS2.Lib.Gtk/EtoForms/Gtk/GtkEtoPlatform.cs
@@ -4,8 +4,9 @@
using Eto.Forms;
using Eto.GtkSharp;
using Eto.GtkSharp.Drawing;
+using NAPS2.EtoForms.Widgets;
using NAPS2.Images.Gtk;
-using gtk = Gtk;
+using GTK = Gtk;
namespace NAPS2.EtoForms.Gtk;
@@ -14,10 +15,13 @@ public class GtkEtoPlatform : EtoPlatform
// TODO: Can we determine this dynamically? Tried container.GetAllocatedSize.Left/Top which works on LxQT but not Gnome
private const int X_OFF = 2;
private const int Y_OFF = 2;
-
+
public override bool IsGtk => true;
- public override Application CreateApplication()
+ public override IIconProvider IconProvider { get; } = new DefaultIconProvider();
+ public override IDarkModeProvider DarkModeProvider { get; } = new GtkDarkModeProvider();
+
+ public override Application CreateApplicationCore()
{
var application = new Application(Platforms.Gtk);
application.Initialized += (_, _) =>
@@ -33,17 +37,31 @@ public override Application CreateApplication()
public override IListView CreateListView(ListViewBehavior behavior) =>
new GtkListView(behavior);
- public override void ConfigureImageButton(Button button, bool big)
+ public override void ConfigureImageButton(Button button, ButtonFlags flags)
{
+ AttachDpiDependency(button, _ => button.ScaleImage());
}
public override Bitmap ToBitmap(IMemoryImage image)
{
- var pixbuf = ((GtkImage) image).Pixbuf;
+ var pixbuf = ((GtkImage) image).Pixbuf.Copy();
return new Bitmap(new BitmapHandler(pixbuf));
}
- public override IMemoryImage DrawHourglass(ImageContext imageContext, IMemoryImage image)
+ public override IMemoryImage FromBitmap(Bitmap bitmap)
+ {
+ return new GtkImage(bitmap.ToGdk());
+ }
+
+ public override void SetClipboardImage(Clipboard clipboard, ProcessedImage processedImage, IMemoryImage memoryImage)
+ {
+ // We deliberately don't dispose the image here as otherwise Gtk gives errors on paste.
+ // Presumably it assumes the application will keep the Pixbuf around
+ // (while on Windows/Mac we can just dispose right away).
+ clipboard.Image = memoryImage.ToEtoImage();
+ }
+
+ public override IMemoryImage DrawHourglass(IMemoryImage image)
{
// TODO
return image;
@@ -51,83 +69,208 @@ public override IMemoryImage DrawHourglass(ImageContext imageContext, IMemoryIma
public override void SetFrame(Control container, Control control, Point location, Size size, bool inOverlay)
{
- var overlay = (gtk.Overlay) container.ToNative();
- var panel = (gtk.Fixed) overlay.Children[inOverlay ? 1 : 0];
- panel.Move(control.ToNative(), location.X - X_OFF, location.Y - Y_OFF);
- control.ToNative().SetSizeRequest(size.Width, size.Height);
+ // TODO: Getting some errors when starting naps2 with the sidebar hidden then trying to show it
+ if (location.X < 0 || location.Y < 0) throw new InvalidOperationException();
+ var overlay = container.ToNative() as GTK.Overlay;
+ var panel = container.ToNative() as GTK.Fixed;
+ var widget = control.ToNative();
+ var parent = overlay?.Parent ?? panel?.Parent.Parent;
+ int xOff = 0;
+ int yOff = 0;
+ if (parent is GTK.Alignment)
+ {
+ // Top-level container, so we offset
+ xOff = X_OFF;
+ yOff = Y_OFF;
+ }
+ if (overlay != null)
+ {
+ // TODO: Ideally we would use GetChildPosition instead of margin but that signal is not firing, not sure why
+ widget.MarginTop = location.Y - yOff;
+ widget.MarginStart = location.X - xOff;
+ }
+ if (panel != null)
+ {
+ if (widget.Parent != panel) throw new InvalidOperationException("Invalid parent");
+ panel.Move(widget, location.X - xOff, location.Y - yOff);
+ }
+ widget.SetSizeRequest(size.Width, size.Height);
+ if (widget is GTK.Overlay childOverlay)
+ {
+ var childPanel = (GTK.Fixed) childOverlay.Child;
+ childPanel.SetSizeRequest(size.Width, size.Height);
+ }
}
- public override Control CreateContainer()
- {
- var overlay = new gtk.Overlay();
- overlay.Add(new gtk.Fixed());
- var overlayPanel = new gtk.Fixed();
- overlay.AddOverlay(overlayPanel);
- overlay.SetOverlayPassThrough(overlayPanel, true);
- return overlay.ToEto();
- }
+ public override Control CreateContainer() => new GTK.Fixed().AsEto();
public override void AddToContainer(Control container, Control control, bool inOverlay)
{
- var overlay = (gtk.Overlay) container.ToNative();
- var panel = (gtk.Fixed) overlay.Children[inOverlay ? 1 : 0];
+ var overlay = container.ToNative() as GTK.Overlay;
+ var panel = container.ToNative() as GTK.Fixed;
var widget = control.ToNative();
- panel.Add(widget);
+ if (overlay != null)
+ {
+ overlay.AddOverlay(widget);
+ widget.Halign = GTK.Align.Start;
+ widget.Valign = GTK.Align.Start;
+ }
+ if (panel != null)
+ {
+ panel.Add(widget);
+ }
widget.ShowAll();
}
+ public override Control? MaybeCreateOverlayContainer()
+ {
+ var overlay = new GTK.Overlay();
+ var panel = new GTK.Fixed();
+ overlay.Child = panel;
+ return overlay.AsEto();
+ }
+
+ public override Control? GetOverlayContainer(Control? container, bool inOverlay)
+ {
+ var overlay = (GTK.Overlay) container.ToNative();
+ return inOverlay ? overlay.AsEto() : overlay.Child.AsEto();
+ }
+
+ public override void RemoveFromContainer(Control container, Control control)
+ {
+ var overlay = container.ToNative() as GTK.Overlay;
+ var panel = container.ToNative() as GTK.Fixed;
+ var widget = control.ToNative();
+ overlay?.Remove(widget);
+ panel?.Remove(widget);
+ widget.Unrealize();
+ }
+
public override void SetContainerSize(Window _window, Control container, Size size, int padding)
{
- var overlay = (gtk.Overlay) container.ToNative();
+ var native = (GTK.Fixed) container.ToNative();
if (!_window.Resizable)
{
// This ensures the window has the appropriate margins, otherwise with resizable=false it changes to fit
// the contents
- overlay.MarginBottom = padding - Y_OFF;
- overlay.MarginEnd = padding - X_OFF;
+ native.MarginBottom = padding - Y_OFF;
+ native.MarginEnd = padding - X_OFF;
}
}
public override Size GetFormSize(Window window)
{
- var gtkWindow = (gtk.Window) window.ToNative();
+ var gtkWindow = (GTK.Window) window.ToNative();
gtkWindow.GetSize(out int w, out int h);
return new Size(w, h);
}
public override void SetFormSize(Window window, Size size)
{
- var gtkWindow = (gtk.Window) window.ToNative();
+ var gtkWindow = (GTK.Window) window.ToNative();
gtkWindow.SetDefaultSize(size.Width, size.Height);
}
public override SizeF GetPreferredSize(Control control, SizeF availableSpace)
{
var widget = control.ToNative();
- if (widget.IsRealized)
+ // TODO: This is a hack to make overlays work. Gtk Overlay uses the margin to adjust the control position but
+ // then the margin messes with size calculations. If we set the margin to 0 here (which should be fine as we
+ // don't use margin otherwise) then the size calculations normalize, while the overlay has already determined
+ // the position so it doesn't affect that any more. However, there is a chance this will break in some edge
+ // cases.
+ widget.Margin = 0;
+ if (control is ImageView && control.Size.Width > 1)
+ {
+ return control.Size;
+ }
+ if (widget.IsRealized && widget is not GTK.DrawingArea)
{
- return base.GetPreferredSize(control, availableSpace);
+ widget.GetSizeRequest(out var oldWidth, out var oldHeight);
+ widget.SetSizeRequest(0, 0);
+ try
+ {
+ return base.GetPreferredSize(control, availableSpace);
+ }
+ finally
+ {
+ widget.SetSizeRequest(oldWidth, oldHeight);
+ }
}
widget.GetPreferredSize(out var minSize, out var naturalSize);
return new SizeF(naturalSize.Width, naturalSize.Height);
}
- public override Size GetClientSize(Window window)
+ public override SizeF GetWrappedSize(Control control, int defaultWidth)
+ {
+ var widget = control.ToNative();
+ if (widget is GTK.Bin { Child: GTK.Label label })
+ {
+ label.MaxWidthChars = EstimateCharactersWide(defaultWidth, label.GetFont());
+ label.GetPreferredSize(out var minSize, out var naturalSize);
+ label.GetPreferredHeightForWidth(defaultWidth, out var minHeight, out var naturalHeight);
+ return new SizeF(Math.Min(naturalSize.Width + 10, defaultWidth), naturalHeight);
+ }
+ return base.GetWrappedSize(control, defaultWidth);
+ }
+
+ private static int EstimateCharactersWide(int pixelWidth, Pango.FontDescription font)
{
- var gtkWindow = (gtk.Window) window.ToNative();
+ // TODO: This could vary based on font and text. Can we do better somehow?
+ // Ideally we'd be able to wrap based on a pixel width. Maybe if we put the label in a container?
+ var fontSize = font.Size / Pango.Scale.PangoScale;
+ var approxCharWidth = fontSize * 0.75;
+ return (int) Math.Floor(pixelWidth / approxCharWidth);
+ }
+
+ public override void ConfigureEllipsis(Label label)
+ {
+ var eventBox = (GTK.EventBox) label.ToNative();
+ var gtkLabel = (GTK.Label) eventBox.Child;
+ gtkLabel.Ellipsize = Pango.EllipsizeMode.End;
+ }
+
+ public override void ConfigureDropDown(DropDown dropDown, bool scale)
+ {
+ if (scale)
+ {
+ var native = (GTK.ComboBox) ((GTK.EventBox) dropDown.ToNative()).Child;
+ var cell = native.Cells.OfType().FirstOrDefault()!;
+ // TODO: I should probably test this on more desktop environments
+ // Setting the renderer width to 1 allows the width to be set properly as part of layouting
+ cell.Width = 1;
+ cell.Height = 25;
+ }
+ }
+
+ public override Size GetClientSize(Window window, bool excludeToolbars)
+ {
+ var gtkWindow = (GTK.Window) window.ToNative();
gtkWindow.GetSize(out var w, out var h);
- return new Size(w, h);
+ var size = new Size(w, h);
+ if (excludeToolbars && window.ToolBar != null)
+ {
+ var toolbar = (GTK.Toolbar) window.ToolBar.ControlObject;
+ var vbox = (GTK.VBox) toolbar.Parent;
+ var heights = vbox.Children.OfType().Select(x =>
+ {
+ x.GetPreferredHeight(out _, out int naturalHeight);
+ return naturalHeight;
+ });
+ size -= new Size(0, heights.Sum());
+ }
+ return size;
}
public override void SetClientSize(Window window, Size clientSize)
{
- var gtkWindow = (gtk.Window) window.ToNative();
+ var gtkWindow = (GTK.Window) window.ToNative();
gtkWindow.Resize(clientSize.Width, clientSize.Height);
}
public override void SetMinimumClientSize(Window window, Size minSize)
{
- var gtkWindow = (gtk.Window) window.ToNative();
+ var gtkWindow = (GTK.Window) window.ToNative();
gtkWindow.SetSizeRequest(minSize.Width, minSize.Height);
}
@@ -136,26 +279,87 @@ public override void SetFormLocation(Window window, Point location)
// TODO: Gtk windows drift if we remember location. For now using the default location is fine.
}
- public override Control AccessibleImageButton(Image image, string text, Action onClick,
- int xOffset = 0, int yOffset = 0)
+ public override float GetScaleFactor(Window window)
+ {
+ // GTK scale factors are integers. Any fractional scaling (e.g. 1.5x) works by rendering at 2x and then scaling
+ // down.
+ return window.ToNative().ScaleFactor;
+ }
+
+ public override void AttachDpiDependency(Control control, Action callback)
{
- var button = new gtk.Button
+ if (control.Loaded)
{
- // Label = text,
- Image = image.ToGtk(),
- ImagePosition = gtk.PositionType.Left
- };
- button.StyleContext.AddClass("accessible-image-button");
- button.Clicked += (_, _) => onClick();
- return button.ToEto();
+ callback(GetScaleFactor(control.ParentWindow));
+ }
+ else
+ {
+ control.Load += (_, _) => callback(GetScaleFactor(control.ParentWindow));
+ }
}
- public override void ConfigureZoomButton(Button button)
+ public override void ConfigureDonateButton(Button button)
{
+ var native = (GTK.Button) button.ToNative();
+ native.StyleContext.AddClass("donate-button");
+ }
+
+ public override void ConfigureZoomButton(Button button, string icon)
+ {
+ var gtkButton = button.ToNative();
button.Text = "";
+ button.Image = IconProvider.GetIcon(icon, gtkButton.ScaleFactor);
+ button.ScaleImage();
button.Size = Size.Empty;
- var gtkButton = button.ToNative();
gtkButton.StyleContext.AddClass("zoom-button");
gtkButton.SetSizeRequest(0, 0);
}
+
+ public override void AttachMouseWheelEvent(Control control, EventHandler eventHandler)
+ {
+ var native = control.ToNative();
+ // Attach to the child so that the scrollbars don't steal the event from us
+ if (native is GTK.EventBox eventBox)
+ {
+ native = eventBox.Child;
+ }
+ if (native is GTK.ScrolledWindow scrolledWindow)
+ {
+ native = scrolledWindow.Child;
+ }
+ native.ScrollEvent += (sender, args) =>
+ {
+ var ev = args.Event;
+ var newArgs = new MouseEventArgs(
+ MouseButtons.None,
+ ev.State.ToEtoKey(),
+ new PointF((float) ev.X, (float) ev.Y),
+ // Negate deltaY to match WinForms
+ new SizeF((float) ev.DeltaX, (float) -ev.DeltaY));
+ eventHandler.Invoke(sender, newArgs);
+ args.RetVal = newArgs.Handled;
+ };
+ }
+
+ public override void SetSplitterPosition(Splitter splitter, int pos)
+ {
+ if (splitter.Width == 1)
+ {
+ // TODO: Fix Eto so that SplitterHandler.EnsurePosition doesn't ignore the specified position
+ var paned = (GTK.Paned) splitter.ControlObject;
+ void OnDrawn(object o, EventArgs drawnArgs)
+ {
+ if (splitter.Width != 1)
+ {
+ paned.Drawn -= OnDrawn;
+ splitter.Position = pos;
+ }
+ }
+ paned.Drawn += OnDrawn;
+ }
+ else
+ {
+ splitter.Position = pos;
+ }
+ }
}
\ No newline at end of file
diff --git a/NAPS2.Lib.Gtk/EtoForms/Gtk/GtkListView.cs b/NAPS2.Lib.Gtk/EtoForms/Gtk/GtkListView.cs
index 9dd257def1..c8a3718e05 100644
--- a/NAPS2.Lib.Gtk/EtoForms/Gtk/GtkListView.cs
+++ b/NAPS2.Lib.Gtk/EtoForms/Gtk/GtkListView.cs
@@ -1,6 +1,9 @@
using Eto.Forms;
using Eto.GtkSharp;
+using Gdk;
using Gtk;
+using NAPS2.EtoForms.Widgets;
+using Drag = Gtk.Drag;
using Label = Gtk.Label;
using Orientation = Gtk.Orientation;
@@ -8,13 +11,20 @@ namespace NAPS2.EtoForms.Gtk;
public class GtkListView : IListView where T : notnull
{
+ private static readonly TargetEntry[] DragTargetEntries =
+ {
+ // TODO: Maybe use a different mime for different list types (profiles/images)?
+ // i.e. something similar to _behavior.CustomDragDataType but in mime format (maybe)
+ new("application/x-naps2-items", 0, 0)
+ };
+
private readonly ListViewBehavior _behavior;
private ListSelection _selection = ListSelection.Empty();
private bool _refreshing;
private readonly ScrolledWindow _scrolledWindow;
private readonly FlowBox _flowBox;
- private List _entries = new();
+ private List _entries = [];
public GtkListView(ListViewBehavior behavior)
{
@@ -40,37 +50,51 @@ public GtkListView(ListViewBehavior behavior)
{
_flowBox.SelectedChildrenChanged += FlowBoxSelectionChanged;
}
- _scrolledWindow.Add(_flowBox);
+ _flowBox.ChildActivated += OnChildActivated;
+ _flowBox.ButtonPressEvent += OnButtonPress;
+ var eventBox = new EventBox();
+ eventBox.Child = _flowBox;
+ if (_behavior.AllowDragDrop)
+ {
+ Drag.DestSet(eventBox, DestDefaults.All, GetDropTargetEntries(), DragAction.Copy | DragAction.Move);
+ eventBox.DragDataReceived += OnDragDataReceived;
+ eventBox.DragMotion += OnDragMotion;
+ eventBox.DragLeave += OnDragLeave;
+ }
+ _scrolledWindow.Add(eventBox);
_scrolledWindow.StyleContext.AddClass("listview");
+ Control = _scrolledWindow.AsEto();
}
- public int ImageSize { get; set; }
+ private void OnButtonPress(object o, ButtonPressEventArgs args)
+ {
+ if (args.Event.Button == 3)
+ {
+ // Right click
+ ContextMenu?.Show();
+ }
+ }
- // TODO: Properties here vs on behavior?
- public bool AllowDrag { get; set; }
+ private void OnChildActivated(object o, ChildActivatedArgs args)
+ {
+ ItemClicked?.Invoke(this, EventArgs.Empty);
+ }
- public bool AllowDrop { get; set; }
+ public Eto.Drawing.Size ImageSize { get; set; }
public ScrolledWindow NativeControl => _scrolledWindow;
- public Control Control => _scrolledWindow.ToEto();
+ public Control Control { get; }
- // TODO: Make this work
public ContextMenu? ContextMenu { get; set; }
public event EventHandler? Updated;
public event EventHandler? SelectionChanged;
- // TODO: Implement item double-click
-#pragma warning disable CS0067
public event EventHandler? ItemClicked;
-#pragma warning restore CS0067
- // TODO: Implement drag/drop
-#pragma warning disable CS0067
public event EventHandler? Drop;
-#pragma warning restore CS0067
public void SetItems(IEnumerable items)
{
@@ -114,8 +138,8 @@ private Widget GetItemWidget(T item)
}
else
{
- using var image = _behavior.GetImage(item, ImageSize);
- var imageWidget = image.ToGtk();
+ using var image = _behavior.GetImage(this, item);
+ var imageWidget = image.ToGdk().ToScaledImage(_flowBox.ScaleFactor);
// TODO: Is there a better way to prevent the image from expanding in both dimensions?
var hframe = new Box(Orientation.Horizontal, 0);
hframe.Halign = Align.Center;
@@ -134,20 +158,31 @@ private Widget GetItemWidget(T item)
};
vframe.Add(label);
}
- flowBoxChild.Add(vframe);
+ // TODO: Event box around the image instead of the frame?
+ var eventBox = new EventBox();
+ eventBox.Child = vframe;
+ if (_behavior.AllowDragDrop || _behavior.AllowFileDrop)
+ {
+ eventBox.DragBegin += OnDragBegin;
+ eventBox.DragDataGet += OnDragDataGet;
+ Drag.SourceSet(eventBox, ModifierType.Button1Mask, DragTargetEntries, DragAction.Move);
+ }
+ flowBoxChild.Add(eventBox);
}
flowBoxChild.StyleContext.AddClass("listview-item");
return flowBoxChild;
}
- // TODO: Do we need this method? Clean up the name/doc at least
- // TODO: Seems like we might not need it at all, the syncer is working? Or is the idea this is faster for WinForms? But in that case we should probably not update on sync.
public void RegenerateImages()
{
if (_refreshing)
{
throw new InvalidOperationException();
}
+ if (_entries.Count == 0)
+ {
+ return;
+ }
_refreshing = true;
foreach (var entry in _entries)
{
@@ -289,6 +324,142 @@ private static CheckButton GetCheckButton(Widget widget)
return (CheckButton) ((FlowBoxChild) widget).Child;
}
+ private TargetEntry[] GetDropTargetEntries()
+ {
+ var list = new List();
+ if (_behavior.AllowDragDrop)
+ {
+ list.Add(new("application/x-naps2-items", 0, 0));
+ }
+ if (_behavior.AllowFileDrop)
+ {
+ list.Add(new("text/uri-list", 0, 0));
+ }
+ return list.ToArray();
+ }
+
+ private void OnDragBegin(object sender, DragBeginArgs args)
+ {
+ // Select the item under the mouse cursor if not already.
+ var dragWidget = (FlowBoxChild) ((EventBox) sender).Parent;
+ var dragItem = ByWidget()[dragWidget].Item;
+ if (!Selection.Contains(dragItem))
+ {
+ Selection = ListSelection.Of(dragItem);
+ }
+ }
+
+ private void OnDragDataGet(object sender, DragDataGetArgs args)
+ {
+ if (Selection.Any())
+ {
+ // TODO: Can we set a pixbuf for the drag?
+ args.SelectionData.Set(
+ Atom.Intern(_behavior.CustomDragDataType, false),
+ 8,
+ _behavior.SerializeCustomDragData(Selection.ToArray()));
+ }
+ }
+
+ private void OnDragDataReceived(object sender, DragDataReceivedArgs args)
+ {
+ var index = GetDragIndex();
+ if (args.SelectionData.DataType.Name == _behavior.CustomDragDataType && _behavior.AllowDragDrop)
+ {
+ Drop?.Invoke(this, new DropEventArgs(index, args.SelectionData.Data));
+ }
+ else if (args.SelectionData.Uris.Any() && _behavior.AllowFileDrop)
+ {
+ Drop?.Invoke(this, new DropEventArgs(index, args.SelectionData.Uris.Select(x => new Uri(x).LocalPath)));
+ }
+ }
+
+ private void OnDragMotion(object sender, DragMotionArgs args)
+ {
+ if (args.Context.SelectedAction != DragAction.Move) return;
+ // Show a visual indicator of the drop location
+ ClearDropIndicator();
+ var index = GetDragIndex();
+ if (index == -1) return;
+ var widgets = _flowBox.Children;
+ // Show on the left (of the image to the right) if we're moving to the left, or on the right (of the image to
+ // the left) if we're moving to the right.
+ // This gives an accurate indication of where the image will appear especially if we're dragging across rows.
+ // If the drop will have no effect (because we're dropping next to the selected image) this will show nothing.
+ // TODO: This doesn't show a drop indicator if we're dropping inside a disjointed selection
+ var selectedIndices = Selection.ToSelectedIndices(_entries.Select(x => x.Item).ToList()).ToList();
+ var selectionMin = selectedIndices.Min();
+ var selectionMax = selectedIndices.Max() + 1;
+ if (index < selectionMin)
+ {
+ widgets[index].StyleContext.AddClass("drop-before");
+ }
+ if (index > selectionMax)
+ {
+ widgets[index - 1].StyleContext.AddClass("drop-after");
+ }
+ }
+
+ private void OnDragLeave(object sender, DragLeaveArgs args)
+ {
+ ClearDropIndicator();
+ }
+
+ private void ClearDropIndicator()
+ {
+ foreach (var widget in _flowBox.Children)
+ {
+ widget.StyleContext.RemoveClass("drop-before");
+ widget.StyleContext.RemoveClass("drop-after");
+ }
+ }
+
+ private int GetDragIndex()
+ {
+ if (_entries.Count == 0)
+ {
+ return 0;
+ }
+ var cp = GetMousePosRelativeToFlowbox();
+ var dragToItem = _flowBox.GetChildAtPos(cp.X, cp.Y);
+ if (dragToItem == null)
+ {
+ var items = _flowBox.Children.Cast().ToList();
+ var minY = items.Select(x => x.Allocation.Top).Min();
+ var maxY = items.Select(x => x.Allocation.Bottom).Max();
+ if (cp.Y < minY)
+ {
+ cp.Y = minY;
+ }
+ if (cp.Y > maxY)
+ {
+ cp.Y = maxY;
+ }
+ var row = items.Where(x => x.Allocation.Top <= cp.Y && x.Allocation.Bottom >= cp.Y)
+ .OrderBy(x => x.Allocation.X)
+ .ToList();
+ dragToItem = row.FirstOrDefault(x => x.Allocation.Right >= cp.X) ?? row.LastOrDefault();
+ }
+ if (dragToItem == null)
+ {
+ return -1;
+ }
+ int dragToIndex = dragToItem.Index;
+ if (cp.X > (dragToItem.Allocation.X + dragToItem.Allocation.Width / 2))
+ {
+ dragToIndex++;
+ }
+ return dragToIndex;
+ }
+
+ private Point GetMousePosRelativeToFlowbox()
+ {
+ _flowBox.Window.GetOrigin(out var boxX, out var boxY);
+ var mousePos = Mouse.Position;
+ var cp = new Point((int) mousePos.X - boxX, (int) mousePos.Y - boxY);
+ return cp;
+ }
+
private class Entry
{
public required T Item { get; set; }
diff --git a/NAPS2.Lib.Gtk/EtoForms/Ui/GtkDesktopForm.cs b/NAPS2.Lib.Gtk/EtoForms/Ui/GtkDesktopForm.cs
index 4593b27dc2..b9ca358ff2 100644
--- a/NAPS2.Lib.Gtk/EtoForms/Ui/GtkDesktopForm.cs
+++ b/NAPS2.Lib.Gtk/EtoForms/Ui/GtkDesktopForm.cs
@@ -1,66 +1,82 @@
-using System.Threading;
using Eto.GtkSharp;
using Eto.GtkSharp.Forms.ToolBar;
using Gdk;
using Gtk;
using NAPS2.EtoForms.Desktop;
using NAPS2.EtoForms.Gtk;
-using NAPS2.EtoForms.Layout;
-using NAPS2.ImportExport.Images;
+using NAPS2.EtoForms.Notifications;
+using NAPS2.EtoForms.Widgets;
+using NAPS2.Scan;
using Command = Eto.Forms.Command;
namespace NAPS2.EtoForms.Ui;
public class GtkDesktopForm : DesktopForm
{
+ private readonly Dictionary _menuButtons = new();
private Toolbar _toolbar = null!;
private int _toolbarButtonCount;
private int _toolbarMenuToggleCount;
private int _toolbarPadding;
private CssProvider? _toolbarPaddingCssProvider;
+ private Toolbar _profilesToolbar = null!;
public GtkDesktopForm(
Naps2Config config,
DesktopKeyboardShortcuts keyboardShortcuts,
- INotificationManager notify,
+ NotificationManager notificationManager,
CultureHelper cultureHelper,
+ ColorScheme colorScheme,
IProfileManager profileManager,
UiImageList imageList,
- ImageTransfer imageTransfer,
ThumbnailController thumbnailController,
UiThumbnailProvider thumbnailProvider,
DesktopController desktopController,
IDesktopScanController desktopScanController,
ImageListActions imageListActions,
+ ImageListViewBehavior imageListViewBehavior,
DesktopFormProvider desktopFormProvider,
IDesktopSubFormController desktopSubFormController,
- DesktopCommands commands)
- : base(config, keyboardShortcuts, notify, cultureHelper, profileManager,
- imageList, imageTransfer, thumbnailController, thumbnailProvider, desktopController, desktopScanController,
- imageListActions, desktopFormProvider, desktopSubFormController, commands)
+ Lazy commands,
+ IDarkModeProvider darkModeProvider,
+ Sidebar sidebar,
+ IIconProvider iconProvider)
+ : base(config, keyboardShortcuts, notificationManager, cultureHelper, colorScheme, profileManager, imageList,
+ thumbnailController, thumbnailProvider, desktopController, desktopScanController, imageListActions,
+ imageListViewBehavior, desktopFormProvider, desktopSubFormController, commands, sidebar, iconProvider)
{
+ ((GtkDarkModeProvider) darkModeProvider).StyleContext =
+ Eto.Forms.Gtk3Helpers.ToNative(this).StyleContext;
var cssProvider = new CssProvider();
+ var bgColor = colorScheme.BackgroundColor.ToHex(false);
+ var fgColor = colorScheme.ForegroundColor.ToHex(false);
+ var sepColor = colorScheme.SeparatorColor.ToHex(false);
+ var brdColor = colorScheme.BorderColor.ToHex(false);
cssProvider.LoadFromData(@"
.desktop-toolbar-button * { min-width: 0; padding-left: 0; padding-right: 0; }
.desktop-toolbar .image-button { min-width: 50px; padding-left: 0; padding-right: 0; }
.desktop-toolbar .toggle { min-width: 0; padding-left: 0; padding-right: 0; }
- .desktop-toolbar { border-bottom: 1px solid #ddd; }
- .listview .frame { background-color: #fff; }
- .desktop-listview .listview-item image { border: 1px solid #000; }
+ .preview-toolbar-button * { min-width: 0; padding-left: 0; padding-right: 0; }
+ .preview-toolbar-button button { padding: 0 5px; }
+ toolbar { border-bottom: 1px solid " + sepColor + @"; }
+ .listview .frame { background-color: " + bgColor + @"; }
+ .listview .drop-before { border-radius: 0; border-left: 3px solid " + fgColor + @"; padding-left: 0; }
+ .listview .drop-after { border-radius: 0; border-right: 3px solid " + fgColor + @"; padding-right: 0; }
+ .desktop-listview .listview-item image { border: 1px solid " + brdColor + @"; }
.link { padding: 0; }
- .accessible-image-button { border: none; background: none; }
- .zoom-button { background: white; border: 1px solid; border-radius: 0; }
+ .donate-button { border: 1px solid #fbad5f; background: #feda96; }
+ .donate-button:hover { background: #eeca86; }
+ .zoom-button { background: " + bgColor + @"; border: 1px solid " + brdColor + @"; border-radius: 0; }
");
StyleContext.AddProviderForScreen(Gdk.Screen.Default, cssProvider, 800);
}
protected override void OnLoad(EventArgs e)
{
- // TODO: What's the best place to initialize this? It needs to happen from the UI event loop.
- Invoker.Current = new SyncContextInvoker(SynchronizationContext.Current!);
base.OnLoad(e);
var listView = (GtkListView) _listView;
listView.NativeControl.StyleContext.AddClass("desktop-listview");
+ PlaceProfilesToolbar();
}
protected override void OnSizeChanged(EventArgs e)
@@ -91,32 +107,70 @@ private void UpdateToolbarPadding()
");
}
- protected override LayoutElement GetMainContent()
+ protected override void ConfigureToolbars()
{
- return L.Column(
- Eto.Forms.Gtk3Helpers.ToEto(_toolbar),
- _listView.Control.Scale()
- ).Spacing(0);
+ _toolbar = ((ToolBarHandler) ToolBar.Handler).Control;
+ _toolbar.Style = ToolbarStyle.Both;
+ _toolbar.StyleContext.AddClass("desktop-toolbar");
+
+ _profilesToolbar = new Toolbar();
+ _profilesToolbar.Style = ToolbarStyle.BothHoriz;
}
- protected override void ConfigureToolbar()
+ public override void PlaceProfilesToolbar()
{
- // TODO: Zoom is behaving weirdly - full zoomout and images become invisible
- // TODO: Also getting "g_sequence_remove: assertion 'iter != NULL' failed" on zoom
- // TODO: Also zoom buttons (and listview) are 2px above the bottom for no apparent reason
+ if (Config.Get(c => c.ShowProfilesToolbar) && _profilesToolbar.Parent == null)
+ {
+ ((VBox) _toolbar.Parent).Add(_profilesToolbar);
+ _profilesToolbar.ShowAll();
+ LayoutController.Invalidate();
+ }
+ if (!Config.Get(c => c.ShowProfilesToolbar) && _profilesToolbar.Parent != null)
+ {
+ ((VBox) _toolbar.Parent).Remove(_profilesToolbar);
+ LayoutController.Invalidate();
+ }
+ }
- _toolbar = ((ToolBarHandler) ToolBar.Handler).Control;
- // Remove the toolbar from the container as we will manually layout it
- // TODO: Clean this up
- var parent = (Container) _toolbar.Parent;
- parent.Remove(_toolbar);
- _toolbar.Style = ToolbarStyle.Both;
- _toolbar.StyleContext.AddClass("desktop-toolbar");
+ protected override void UpdateProfilesToolbar()
+ {
+ var profiles = _profileManager.Profiles;
+ var extra = _profilesToolbar.NItems - profiles.Count;
+ var missing = profiles.Count - _profilesToolbar.NItems;
+ for (int i = 0; i < extra; i++)
+ {
+ _profilesToolbar.Remove(_profilesToolbar.Children.Last());
+ }
+ for (int i = 0; i < missing; i++)
+ {
+ var item = new ToolButton(Icons.control_play_blue_small.ToEtoImage().ToGtk(), "test")
+ {
+ Homogeneous = false,
+ IsImportant = true
+ };
+ item.Clicked += (_, _) => _desktopScanController.ScanWithProfile((ScanProfile) item.Data["naps2_profile"]!);
+ _profilesToolbar.Add(item);
+ }
+ for (int i = 0; i < profiles.Count; i++)
+ {
+ var profile = profiles[i];
+ var item = (ToolButton) _profilesToolbar.GetNthItem(i);
+ item.Data["naps2_profile"] = profile;
+ if (item.Label != profile.DisplayName)
+ {
+ item.Label = profile.DisplayName;
+ }
+ }
+ }
+
+ protected override void RecreateToolbarsAndMenus()
+ {
+ // Recreating toolbars doesn't work well on Gtk and isn't necessary anyway
}
protected override void CreateToolbarButton(Command command)
{
- var button = new ToolButton(command.Image.ToGtk(), command.ToolBarText)
+ var button = new ToolButton(GetCommandImage(command), command.ToolBarText)
{
Homogeneous = false,
Sensitive = command.Enabled
@@ -135,87 +189,71 @@ protected override void CreateToolbarSeparator()
protected override void CreateToolbarStackedButtons(Command command1, Command command2)
{
+ // Simply create a ToolItem with two buttons stacked on top of each other
var button1 = CreateToolButton(command1, Orientation.Horizontal);
var button2 = CreateToolButton(command2, Orientation.Horizontal);
var vbox = new Box(Orientation.Vertical, 0);
vbox.Add(button1);
vbox.Add(button2);
- AddCustomToolItem(vbox);
+ var toolItem = new ToolItem();
+ toolItem.Add(vbox);
+
+ // Handle the toolbar overflowing into a menu
+ toolItem.CreateMenuProxy += (o, args) =>
+ {
+ var menuItem1 = (ImageMenuItem) new Eto.Forms.ButtonMenuItem(command1).ControlObject;
+ var menuItem2 = (ImageMenuItem) new Eto.Forms.ButtonMenuItem(command2).ControlObject;
+ // Hack to show two menu items instead of one. Seems to work for now.
+ menuItem1.ParentSet += (_, _) => ((Container) menuItem1.Parent)?.Add(menuItem2);
+ toolItem.SetProxyMenuItem("", menuItem1);
+ };
+ // GTK ties the menu item's sensitivity to the ToolItem's sensitivity. We only need to check the first command
+ // since the second command's menu item is hacked in.
+ toolItem.Sensitive = command1.Enabled;
+ command1.EnabledChanged += (_, _) => toolItem.Sensitive = command1.Enabled;
+
+ _toolbar.Add(toolItem);
_toolbarButtonCount++;
}
- protected override void CreateToolbarButtonWithMenu(Command command, MenuProvider menu)
+ protected override void CreateToolbarButtonWithMenu(Command command, DesktopToolbarMenuType menuType,
+ MenuProvider menu)
{
- var button = new MenuToolButton(command.Image.ToGtk(), command.ToolBarText)
+ var button = new MenuToolButton(GetCommandImage(command), command.ToolBarText)
{
Homogeneous = false,
Sensitive = command.Enabled
};
button.Clicked += (_, _) => command.Execute();
command.EnabledChanged += (_, _) => button.Sensitive = command.Enabled;
- button.Menu = CreateMenuWidget(menu);
+ button.Menu = CreateMenuWidget(command, menu);
button.StyleContext.AddClass("desktop-toolbar-button");
_toolbar.Add(button);
_toolbarButtonCount++;
_toolbarMenuToggleCount++;
+ _menuButtons[menuType] = button;
}
protected override void CreateToolbarMenu(Command command, MenuProvider menu)
{
- var button = new ToolButton(command.Image.ToGtk(), command.ToolBarText)
+ var button = new ToolButton(GetCommandImage(command), command.ToolBarText)
{
Homogeneous = false,
Sensitive = command.Enabled
};
command.EnabledChanged += (_, _) => button.Sensitive = command.Enabled;
- var menuWidget = CreateMenuWidget(menu);
- var menuDelegate = GetMenuDelegate(menuWidget, button);
+ var menuDelegate = GetMenuDelegate(CreateMenuWidget(command, menu), button);
button.Clicked += menuDelegate;
button.StyleContext.AddClass("desktop-toolbar-button");
_toolbar.Add(button);
_toolbarButtonCount++;
}
- private Menu CreateMenuWidget(MenuProvider menu)
+ private Menu CreateMenuWidget(Command command, MenuProvider menu)
{
- var menuWidget = new Menu();
- menu.Handle(items =>
- {
- foreach (var child in menuWidget.Children)
- {
- menuWidget.Remove(child);
- }
- foreach (var item in items)
- {
- switch (item)
- {
- case MenuProvider.CommandItem commandItem:
- var menuItem = new MenuItem
- {
- Label = commandItem.Command.MenuText
- };
- menuItem.Sensitive = commandItem.Command.Enabled;
- commandItem.Command.EnabledChanged +=
- (_, _) => menuItem.Sensitive = commandItem.Command.Enabled;
- menuItem.Activated += (_, _) => commandItem.Command.Execute();
- menuWidget.Add(menuItem);
- break;
- case MenuProvider.SeparatorItem:
- menuWidget.Add(new SeparatorMenuItem());
- break;
- case MenuProvider.SubMenuItem subMenuItem:
- var subMenu = new MenuItem
- {
- Label = subMenuItem.Command.MenuText
- };
- subMenu.Submenu = CreateMenuWidget(subMenuItem.MenuProvider);
- menuWidget.Add(subMenu);
- break;
- }
- }
- menuWidget.ShowAll();
- });
- return menuWidget;
+ var subMenu = CreateSubMenu(command, menu);
+ var menuItem = (ImageMenuItem) subMenu.ControlObject;
+ return (Menu) menuItem.Submenu;
}
private EventHandler GetMenuDelegate(Menu menuWidget, Widget button)
@@ -223,18 +261,17 @@ private EventHandler GetMenuDelegate(Menu menuWidget, Widget button)
return (_, _) => menuWidget.PopupAtWidget(button, Gravity.SouthWest, Gravity.NorthWest, null);
}
- private void AddCustomToolItem(Widget item)
+ public override void ShowToolbarMenu(DesktopToolbarMenuType menuType)
{
- var toolItem = new ToolItem();
- toolItem.Add(item);
- _toolbar.Add(toolItem);
+ var button = _menuButtons.Get(menuType);
+ (button?.Menu as Menu)?.PopupAtWidget(button, Gravity.SouthWest, Gravity.NorthWest, null);
}
- private static Button CreateToolButton(Command command, Orientation orientation = Orientation.Vertical,
+ private Button CreateToolButton(Command command, Orientation orientation = Orientation.Vertical,
int spacing = 4)
{
var box = new Box(orientation, spacing);
- box.Add(command.Image.ToGtk());
+ box.Add(GetCommandImage(command));
var label = new Label(command.ToolBarText);
box.Add(label);
var button = new Button(box)
@@ -247,4 +284,11 @@ private static Button CreateToolButton(Command command, Orientation orientation
(_, _) => button.Sensitive = command.Enabled;
return button;
}
+
+ private Image GetCommandImage(Command command)
+ {
+ var scale = EtoPlatform.Current.GetScaleFactor(this);
+ var image = ((ActionCommand) command).GetIconImage(scale);
+ return image.ToGdk().ToScaledImage((int) scale);
+ }
}
\ No newline at end of file
diff --git a/NAPS2.Lib.Gtk/EtoForms/Ui/GtkPreviewForm.cs b/NAPS2.Lib.Gtk/EtoForms/Ui/GtkPreviewForm.cs
new file mode 100644
index 0000000000..96b3f5abcf
--- /dev/null
+++ b/NAPS2.Lib.Gtk/EtoForms/Ui/GtkPreviewForm.cs
@@ -0,0 +1,29 @@
+using Gtk;
+using NAPS2.EtoForms.Desktop;
+
+namespace NAPS2.EtoForms.Ui;
+
+public class GtkPreviewForm : PreviewForm
+{
+ public GtkPreviewForm(Naps2Config config, DesktopCommands desktopCommands, UiImageList imageList,
+ IIconProvider iconProvider, ColorScheme colorScheme) : base(config,
+ desktopCommands, imageList, iconProvider, colorScheme)
+ {
+ }
+
+ protected override void CreateToolbar()
+ {
+ base.CreateToolbar();
+ var toolBar = (Toolbar) ToolBar.ControlObject;
+ toolBar.IconSize = IconSize.SmallToolbar;
+ toolBar.Style = ToolbarStyle.Icons;
+ foreach (var item in toolBar.Children)
+ {
+ if (item is ToolItem toolItem)
+ {
+ toolItem.Homogeneous = false;
+ item.StyleContext.AddClass("preview-toolbar-button");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Lib.Gtk/ImportExport/GtkScannedImagePrinter.cs b/NAPS2.Lib.Gtk/ImportExport/GtkScannedImagePrinter.cs
new file mode 100644
index 0000000000..c74cbae1ca
--- /dev/null
+++ b/NAPS2.Lib.Gtk/ImportExport/GtkScannedImagePrinter.cs
@@ -0,0 +1,77 @@
+using Gtk;
+using NAPS2.Images.Gtk;
+
+namespace NAPS2.ImportExport;
+
+public class GtkScannedImagePrinter : IScannedImagePrinter
+{
+ public Task PromptToPrint(
+ Eto.Forms.Window parentWindow, IList images, IList selectedImages)
+ {
+ if (!images.Any())
+ {
+ return Task.FromResult(false);
+ }
+ var printOp = new PrintOperation
+ {
+ NPages = images.Count,
+ UseFullPage = true,
+ HasSelection = selectedImages.Count > 1,
+ SupportSelection = selectedImages.Count > 1
+ };
+ if (selectedImages.Count == 1)
+ {
+ printOp.CurrentPage = images.IndexOf(selectedImages[0]);
+ }
+ var printTarget = images;
+ printOp.BeginPrint += (_, args) =>
+ {
+ if (printOp.PrintSettings.PrintPages == PrintPages.Selection)
+ {
+ printTarget = selectedImages;
+ printOp.NPages = printTarget.Count;
+ }
+ };
+ printOp.DrawPage += (_, args) =>
+ {
+ var image = printTarget[args.PageNr].Render();
+ try
+ {
+ var ctx = args.Context;
+ var cairoCtx = ctx.CairoContext;
+
+ if (Math.Sign(image.Width - image.Height) != Math.Sign(ctx.Width - ctx.Height))
+ {
+ // Flip portrait/landscape to match output
+ image = image.PerformTransform(new RotationTransform(90));
+ }
+
+ // Fit the image into the output rect (centered) while maintaining its aspect ratio
+ var heightBound = image.Width / ctx.Width < image.Height / ctx.Height;
+ var targetWidth = heightBound ? image.Width * ctx.Height / image.Height : ctx.Width;
+ var targetHeight = heightBound ? ctx.Height : image.Height * ctx.Width / image.Width;
+ var targetX = (ctx.Width - targetWidth) / 2;
+ var targetY = (ctx.Height - targetHeight) / 2;
+ cairoCtx.Translate(targetX, targetY);
+ cairoCtx.Scale(targetWidth / image.Width, targetHeight / image.Height);
+
+ Gdk.CairoHelper.SetSourcePixbuf(cairoCtx, image.AsPixbuf(), 0, 0);
+ cairoCtx.Paint();
+ }
+ finally
+ {
+ image.Dispose();
+ }
+ };
+ printOp.EndPrint += (_, args) =>
+ {
+ Log.Event(EventType.Print, new EventParams
+ {
+ Name = MiscResources.Print,
+ Pages = printOp.NPagesToPrint
+ });
+ };
+ var result = printOp.Run(PrintOperationAction.PrintDialog, (Window) parentWindow.ControlObject);
+ return Task.FromResult(result == PrintOperationResult.Apply);
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Lib.Gtk/Modules/GtkImagesModule.cs b/NAPS2.Lib.Gtk/Modules/GtkImagesModule.cs
new file mode 100644
index 0000000000..2c95905464
--- /dev/null
+++ b/NAPS2.Lib.Gtk/Modules/GtkImagesModule.cs
@@ -0,0 +1,13 @@
+using Autofac;
+using NAPS2.Images.Gtk;
+
+namespace NAPS2.Modules;
+
+public class GtkImagesModule : Module
+{
+ protected override void Load(ContainerBuilder builder)
+ {
+ builder.RegisterType().As();
+ builder.RegisterType().AsSelf();
+ }
+}
diff --git a/NAPS2.Lib.Gtk/Modules/GtkModule.cs b/NAPS2.Lib.Gtk/Modules/GtkModule.cs
index 8d812d7382..33f4ead959 100644
--- a/NAPS2.Lib.Gtk/Modules/GtkModule.cs
+++ b/NAPS2.Lib.Gtk/Modules/GtkModule.cs
@@ -1,13 +1,7 @@
using Autofac;
-using NAPS2.EtoForms;
-using NAPS2.EtoForms.Desktop;
-using NAPS2.EtoForms.Gtk;
using NAPS2.EtoForms.Ui;
-using NAPS2.Images.Gtk;
using NAPS2.ImportExport;
-using NAPS2.ImportExport.Pdf;
-using NAPS2.Scan;
-using NAPS2.Update;
+using NAPS2.ImportExport.Email;
namespace NAPS2.Modules;
@@ -15,73 +9,14 @@ public class GtkModule : Module
{
protected override void Load(ContainerBuilder builder)
{
- // builder.RegisterType().As();
- builder.RegisterType().As();
- builder.RegisterType().As();
- builder.RegisterType().As();
- builder.RegisterType().As().SingleInstance();
- builder.RegisterType().As();
- builder.RegisterType().As();
- builder.RegisterType().As