Browse / github.com/storytold/photocraft / patch
Implement Windows WIA flatbed scanner import
Implement Windows WIA flatbed scanner import File > Import > WIA Support currently reports an empty device list. This change discovers Windows scanners and imports a flatbed acquisition as a new, unsaved document, including when no document is open. Acquisition runs in a cancellable background job. A separate STA Windows PowerShell process owns WIA discovery/settings and the current Windows ImageScanner transfer API, keeping driver calls outside the editor. The Epson V500 returned E_ACCESSDENIED from legacy WIA Automation Item.Transfer despite working in Windows Scan; ImageScanner physically scanned successfully. Requests are strict JSO
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- gt1997-byte (imported from pull request #9799, open)
- Base
d42f9da94a421980d7130c63ca2953e47802cfbe- Size
- +1277 −30 in 16 files
- Review
- pending
- Shared
- 2026-10-28
- Id
a8bb3133d660d9f47e323acc
How it went
No other operator has reported using it yet.
Diff
raw · crates/automation/src/workspace.rs crates/automation/src/workspace/tests.rs crates/engine/src/cmd/wia_cmds.rs crates/engine/src/cmd/wia_cmds/tests.rs crates/engine/src/cmd/wia_cmds/windows.ps1 crates/engine/src/cmd/wia_cmds/windows.rs crates/ui-egui/src/app/commands.rs crates/ui-egui/src/dialogs/open_prompt.rs crates/ui-egui/src/jobs_ui.rs crates/ui-egui/src/jobs_ui/wia_tests.rs crates/ui-egui/src/menus/invoke.rs crates/ui-egui/src/menus/tests.rs docs/gaps.md docs/hardware-parity.md docs/target-app-parity.md docs/wia-scanning.md
@@ -259,6 +259,8 @@ fn file_error(operation: &str, path: &str, error: std::io::Error) -> AutomationE /// directory capabilities. Automation must use `doc.open`, `doc.save` and /// `doc.render` for file effects until those commands are migrated. pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), AutomationError> {+ // WIA accepts strict scan options, never caller file paths; the bridge uses private+ // temporary files and imports scanner bytes into an unsaved document. let safe_file_command = matches!( id, "file.new"@@ -266,6 +268,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati | "file.close" | "file.closeAll" | "file.closeOthers"+ | "file.import.wiaSupport" | "file.fileInfo" | "file.automate.fitImage" | "file.automate.conditionalModeChange"@@ -579,3 +579,36 @@ fn absolute_paths_are_checked_against_the_authority_s_own_root() { let none = AuthorizedWorkspace::default(); assert!(none.read(&target.to_string_lossy()).is_err()); }++#[test]+fn wia_options_are_authorized_but_caller_paths_and_other_file_commands_are_rejected() {+ let command = "file.import.wiaSupport";+ for params in [+ serde_json::json!({}),+ serde_json::json!({"action": "list"}),+ serde_json::json!({"action": "acquire"}),+ serde_json::json!({"action": "acquire", "deviceId": "test-scanner", "showUI": false, "dpi": 300, "mode": "gray"}),+ ] {+ // Only check authorization: these valid requests must not start scanner hardware+ // or native dialogs in the regression test.+ assert!(authorize_engine_command(command, ¶ms).is_ok(), "{params}");+ assert!(authorize_desktop_engine_command(command, ¶ms).is_ok(), "{params}");+ assert!(authorize_engine_step(command, ¶ms).is_ok(), "{params}");+ }+ let path = serde_json::json!({"path": "/outside/scanner.tif"});+ for denied in ["file.save", "file.import.wiaSupportExtra", "file.futureFilesystemCommand"] {+ assert!(authorize_engine_command(denied, &path).is_err(), "{denied}");+ }+ let mut headless = crate::Headless::new();+ for action in ["list", "acquire"] {+ let params = serde_json::json!({"action": action, "deviceId": "test-scanner", "showUI": false, "path": "/outside/scanner.tif"});+ match headless.command_run(command, params) {+ Err(AutomationError::Engine(photocraft_engine::EngineError::BadParams { cmd, msg })) => {+ assert_eq!(cmd, command);+ assert!(msg.contains("path"), "{msg}");+ }+ other => panic!("unexpected WIA path validation result: {other:?}"),+ }+ }+ assert!(headless.session.documents().is_empty());+}@@ -1,48 +1,181 @@-//! File › Import › WIA Support. Windows Image Acquisition is a Windows-only API for scanners and-//! cameras; Photoshop shows this item only on Windows. We register it cross-platform and report the-//! available acquisition devices — none off Windows, where the feature is unavailable. Actual WIA-//! device enumeration/transfer needs the Windows COM API plus hardware (not exercised here).+//! File › Import › WIA Support. Discovery is a query; acquisition uses the Windows scanner+//! dialogs (or an explicit device for automation) and opens a new, unsaved document.+//! The Windows Automation bridge runs in a separate process so driver calls can be cancelled+//! and the desktop remains responsive. No COM pointers cross the safe Rust engine boundary. +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::commands::CommandSpec;-use crate::{Result, Session};+use crate::{EngineError, Result, Session}; -fn wia(_s: &mut Session, _p: &Value) -> Result<Value> {+#[cfg(target_os = "windows")]+mod windows;++const COMMAND: &str = "file.import.wiaSupport";++#[derive(Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]+#[serde(rename_all = "lowercase")]+enum Action {+ #[default]+ List,+ Acquire,+}++#[derive(Clone, Copy, Default, Deserialize, Serialize)]+#[serde(rename_all = "lowercase")]+enum Mode {+ #[default]+ Color,+ Gray,+ Text,+}++fn show_ui() -> bool {+ true+}++#[derive(Clone, Deserialize, Serialize)]+#[serde(deny_unknown_fields)]+struct Request {+ #[serde(default)]+ action: Action,+ #[serde(default, rename = "deviceId")]+ device_id: Option<String>,+ #[serde(default)]+ dpi: Option<u32>,+ #[serde(default)]+ mode: Mode,+ #[serde(default = "show_ui", rename = "showUI")]+ show_ui: bool,+}++fn request(p: &Value) -> Result<Request> {+ let bad = |msg: String| EngineError::BadParams { cmd: COMMAND.into(), msg };+ let r: Request = serde_json::from_value(p.clone()).map_err(|e| bad(e.to_string()))?;+ if r.device_id.as_ref().is_some_and(|id| id.trim().is_empty() || id.len() > 4096 || id.contains('\0')) {+ return Err(bad("`deviceId` must be a nonempty WIA device ID (at most 4096 bytes)".into()));+ }+ if r.dpi.is_some_and(|dpi| !(1..=12800).contains(&dpi)) {+ return Err(bad("`dpi` must be an integer from 1 to 12800; the driver must support it".into()));+ }+ if r.action == Action::Acquire && !r.show_ui && r.device_id.is_none() {+ return Err(bad("`showUI: false` requires an explicit `deviceId` from device discovery".into()));+ }+ Ok(r)+}++#[cfg(any(target_os = "windows", test))]+struct Scan {+ bytes: Vec<u8>,+ extension: String,+ dpi: Option<f32>,+}++#[cfg(any(target_os = "windows", test))]+struct Acquisition {+ report: Value,+ image: Option<Scan>,+}++#[cfg(any(target_os = "windows", test))]+struct Prepared {+ report: Value,+ imported: Option<photocraft_io::ImportResult>,+}++#[cfg(any(target_os = "windows", test))]+fn prepare(acquired: Acquisition, ctx: &crate::jobs::JobCtx) -> Result<Prepared> {+ ctx.check()?;+ let imported = match acquired.image {+ None => None,+ Some(scan) => {+ crate::allocation::checkpoint("importing scanner image")?;+ let name = format!("Scan.{}", scan.extension);+ let mut imported = ctx.stage(0.8, 1.0, "Opening scan", |ctl| photocraft_io::import_with(&name, &scan.bytes, ctl)).map_err(|e| match e {+ photocraft_io::IoError::Cancelled => EngineError::Cancelled,+ e => EngineError::Other(format!("cannot open WIA scan: {e}")),+ })?;+ imported.document.name = "Scan".into();+ if let Some(dpi) = scan.dpi.filter(|dpi| dpi.is_finite() && *dpi > 0.0 && *dpi <= 12800.0) {+ imported.document.resolution_dpi = dpi;+ }+ Some(imported)+ }+ };+ ctx.check()?;+ Ok(Prepared { report: acquired.report, imported })+}++#[cfg(any(target_os = "windows", test))]+fn apply(s: &mut Session, prepared: Prepared) -> Result<Value> {+ let mut report = prepared.report;+ let object = report.as_object_mut().ok_or_else(|| EngineError::Other("invalid WIA acquisition report".into()))?;+ if let Some(imported) = prepared.imported {+ if let Some(question) = s.color.open_question(&imported.document) {+ let held = s.hold_open(imported.document, false);+ object.insert("held".into(), json!(held));+ object.insert("color".into(), question);+ object.insert("warnings".into(), json!(imported.warnings));+ return Ok(report);+ }+ let (index, color) = s.open_document(imported.document, None);+ let document = s.documents().get(index).ok_or(EngineError::NoDocument)?.doc.id;+ // A scan has no durable source file. Closing it must offer to save the acquired pixels.+ if let Some(st) = s.active_mut() {+ st.saved_revision = 0;+ }+ object.insert("document".into(), json!(index));+ object.insert("docId".into(), json!(document.0));+ object.insert("color".into(), color);+ object.insert("warnings".into(), json!(imported.warnings));+ object.insert("acquired".into(), json!(true));+ }+ Ok(report)+}++#[cfg(any(target_os = "windows", test))]+fn run_with(s: &mut Session, request: Request, backend: impl FnOnce(&Request, &crate::jobs::JobCtx) -> Result<Acquisition> + Send + 'static) -> Result<Value> {+ crate::jobs::run(+ s,+ "WIA scanner",+ false,+ move |ctx| {+ ctx.progress(0.0, "Connecting to scanner");+ prepare(backend(&request, ctx)?, ctx)+ },+ apply,+ )+}++fn wia(s: &mut Session, p: &Value) -> Result<Value> {+ let request = request(p)?; #[cfg(target_os = "windows")] {- // A real implementation enumerates WIA devices via COM (IWiaDevMgr) and transfers an image- // into a new document. No COM binding is wired yet, so report zero devices.- Ok(json!({"available": true, "devices": [], "note": "WIA device enumeration not yet wired"}))+ run_with(s, request, windows::run) } #[cfg(not(target_os = "windows"))] {- Ok(json!({"available": false, "devices": [], "note": "WIA scanner/camera import is available on Windows only"}))+ let _ = s;+ if request.action == Action::Acquire {+ return Err(EngineError::Other("WIA scanner import is available on Windows only".into()));+ }+ Ok(json!({"available": false, "devices": [], "acquired": false, "cancelled": false, "note": "WIA scanner import is available on Windows only"})) } } pub fn specs() -> Vec<CommandSpec> { vec![CommandSpec {- id: "file.import.wiaSupport",+ id: COMMAND, label: "WIA Support…", menu: &["File", "Import"], shortcut: None,- params: r#"{} → {available, devices, note}: acquire from a scanner/camera (Windows only)"#,+ params: r#"{"action":"list|acquire"="list","deviceId":string?,"showUI":bool=true,"dpi":integer?,"mode":"color|gray|text"="color"} → {available, devices, acquired, cancelled, document?}: discover WIA scanners or acquire into a new document (Windows only); headless acquisition requires deviceId"#, enabled: |_s| Ok(()), journal: false,- run: |s, p| wia(s, p),+ run: wia, }] } #[cfg(test)]-mod tests {- use super::*;-- #[test]- fn reports_availability() {- let mut s = Session::new();- let r = s.execute("file.import.wiaSupport", json!({})).unwrap();- assert!(r["devices"].is_array());- assert_eq!(r["available"], cfg!(target_os = "windows"));- }-}+mod tests;new file mode 100644@@ -0,0 +1,168 @@+use super::*;+use crate::jobs::JobCtx;+use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType};++fn scan(sample: SampleType, layout: ChannelLayout) -> Acquisition {+ let mut image = Image::new(3, 2, layout, sample).unwrap();+ if layout == ChannelLayout::Rgb {+ image.icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().as_ref().clone());+ }+ image.meta.dpi = Some((150.0, 150.0));+ Acquisition {+ report: json!({"available": true, "devices": [{"id": "v500", "name": "EPSON V500"}], "acquired": true, "cancelled": false}),+ image: Some(Scan {+ bytes: photocraft_codecs::encode(&image, Format::Tiff, &EncodeOptions::default()).unwrap(),+ extension: "tif".into(),+ dpi: Some(300.0),+ }),+ }+}++#[test]+fn discovery_is_the_safe_default_and_params_are_validated_before_driver_calls() {+ assert!(request(&json!({})).unwrap().action == Action::List);+ assert!(request(&json!({"action": "acquire"})).unwrap().show_ui);+ let literal = "scanner's \"name\" $HOME $(exit) `exit` \\ ü";+ let r = request(&json!({"action": "acquire", "deviceId": literal, "showUI": false, "dpi": 300})).unwrap();+ assert_eq!(r.device_id.as_deref(), Some(literal));+ assert_eq!(serde_json::to_value(r).unwrap()["deviceId"], literal);+ for p in [+ Value::Null,+ json!([]),+ json!({"action": "unknown"}),+ json!({"action": "acquire", "showUI": false}),+ json!({"showUI": "false"}),+ json!({"deviceId": " "}),+ json!({"deviceId": "a\u{0}b"}),+ json!({"deviceId": "x".repeat(4097)}),+ json!({"dpi": 0}),+ json!({"dpi": -1}),+ json!({"dpi": 300.5}),+ json!({"dpi": 12801}),+ json!({"dpi": u64::MAX}),+ json!({"mode": "rgb"}),+ ] {+ let mut s = Session::new();+ assert!(matches!(s.execute(COMMAND, p), Err(EngineError::BadParams { .. })));+ assert!(s.documents().is_empty());+ }+}++#[test]+fn scanner_import_preserves_depth_model_profile_and_uses_actual_dpi() {+ for sample in [SampleType::U8, SampleType::U16] {+ for layout in [ChannelLayout::Rgb, ChannelLayout::Gray] {+ let mut s = Session::new();+ s.execute("file.new", json!({"width": 8, "height": 9})).unwrap();+ let previous = s.active().unwrap().doc.id;+ let result = run_with(&mut s, request(&json!({"action": "acquire"})).unwrap(), move |_, _| Ok(scan(sample, layout))).unwrap();+ assert_eq!(result["document"], 1);+ assert_eq!(result["docId"], s.active().unwrap().doc.id.0);+ assert_eq!(result["acquired"], true);+ assert_eq!(s.documents()[0].doc.id, previous);+ assert_eq!(s.documents().len(), 2);+ let st = s.active().unwrap();+ assert!(st.path.is_none(), "temporary scan files must never become save destinations");+ assert!(st.is_dirty(), "closing a scan must offer to save it");+ assert_eq!(st.doc.name, "Scan");+ assert_eq!((st.doc.size.width, st.doc.size.height), (3, 2));+ assert_eq!(st.doc.depth.bits(), if sample == SampleType::U16 { 16 } else { 8 });+ assert_eq!(st.doc.mode, if layout == ChannelLayout::Gray { photocraft_color::ColorMode::Grayscale } else { photocraft_color::ColorMode::Rgb });+ assert_eq!(st.doc.resolution_dpi, 300.0);+ if layout == ChannelLayout::Rgb {+ assert_eq!(result["color"]["action"], "kept");+ assert!(st.doc.icc_profile.is_some());+ }+ }+ }+}++#[test]+fn scanner_profile_question_holds_unmodified_pixels_until_the_answer() {+ let mut s = Session::new();+ s.execute("file.new", json!({"width": 2, "height": 2})).unwrap();+ let previous = s.active().unwrap().doc.id;+ s.execute("edit.colorSettings", json!({"askOnMissing": true})).unwrap();+ let prepared = prepare(scan(SampleType::U16, ChannelLayout::Gray), &JobCtx::new()).unwrap();+ let result = apply(&mut s, prepared).unwrap();+ assert_eq!(result["acquired"], true);+ assert_eq!(result["color"]["missing"], true);+ assert!(result.get("document").is_none());+ assert_eq!(s.documents().len(), 1);+ assert_eq!(s.active().unwrap().doc.id, previous);+ let (scan, read_only) = s.take_held_open(result["held"].as_u64().unwrap()).unwrap();+ assert!(!read_only);+ assert!(scan.icc_profile.is_none());+ assert_eq!(scan.resolution_dpi, 300.0);+ assert_eq!(scan.depth.bits(), 16);+}++#[test]+fn cancellation_discovery_and_driver_errors_leave_the_document_unchanged() {+ let mut s = Session::new();+ s.execute("file.new", json!({"width": 8, "height": 9})).unwrap();+ let before = s.active().unwrap().doc.id;+ for cancelled in [false, true] {+ let result = run_with(&mut s, request(&json!({})).unwrap(), move |_, _| {+ Ok(Acquisition { report: json!({"available": true, "devices": [], "acquired": false, "cancelled": cancelled}), image: None })+ })+ .unwrap();+ assert_eq!(result["cancelled"], cancelled);+ assert!(result.get("document").is_none());+ }+ assert!(run_with(&mut s, request(&json!({})).unwrap(), |_, _| Err(EngineError::Other("scanner busy".into()))).is_err());+ let mut corrupt = scan(SampleType::U8, ChannelLayout::Rgb);+ corrupt.image.as_mut().unwrap().bytes = vec![0, 1, 2];+ assert!(run_with(&mut s, request(&json!({})).unwrap(), move |_, _| Ok(corrupt)).is_err());+ assert_eq!(s.documents().len(), 1);+ assert_eq!(s.active().unwrap().doc.id, before);+}++#[test]+fn cancelled_work_is_never_applied() {+ let mut s = Session::new();+ let ctx = JobCtx::new();+ ctx.cancel();+ s.set_inline_job_ctx(Some(ctx));+ assert!(matches!(+ run_with(&mut s, request(&json!({"action": "acquire"})).unwrap(), |_, _| Ok(scan(SampleType::U16, ChannelLayout::Rgb))),+ Err(EngineError::Cancelled)+ ));+ assert!(s.documents().is_empty());+}++#[test]+fn invalid_driver_dpi_does_not_replace_image_metadata() {+ for dpi in [None, Some(0.0), Some(f32::NAN), Some(f32::INFINITY), Some(12801.0)] {+ let mut acquired = scan(SampleType::U8, ChannelLayout::Rgb);+ acquired.image.as_mut().unwrap().dpi = dpi;+ let prepared = prepare(acquired, &JobCtx::new()).unwrap();+ assert_eq!(prepared.imported.unwrap().document.resolution_dpi, 150.0);+ }+}++#[cfg(target_os = "windows")]+#[test]+#[ignore = "requires the Windows WIA service, but never scans or opens dialogs"]+fn windows_device_discovery_smoke() {+ let mut s = Session::new();+ let result = s.execute(COMMAND, json!({})).unwrap();+ assert_eq!(result["available"], true);+ assert!(result["devices"].is_array());+ assert_eq!(result["acquired"], false);+ assert!(s.documents().is_empty());+ // An impossible explicit ID verifies the bridge error path without connecting hardware.+ assert!(s.execute(COMMAND, json!({"action": "acquire", "deviceId": "photocraft-test-nonexistent-device", "showUI": false})).is_err());+ assert!(s.documents().is_empty());+}++#[cfg(not(target_os = "windows"))]+#[test]+fn other_platforms_report_unavailability_and_reject_acquisition() {+ let mut s = Session::new();+ let result = s.execute(COMMAND, json!({})).unwrap();+ assert_eq!(result["available"], false);+ assert_eq!(result["devices"], json!([]));+ assert!(s.execute(COMMAND, json!({"action": "acquire"})).is_err());+ assert!(s.documents().is_empty());+}new file mode 100644@@ -0,0 +1,264 @@+# WIA discovery/settings plus Windows' current scanner API for acquisition. Legacy Automation+# Item.Transfer can return E_ACCESSDENIED on working drivers (including the Epson V500).+# The Rust caller runs this static+# script in an STA process; request JSON and the private output directory arrive as data.+# WIA documentation: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/wiaaut/+$ErrorActionPreference = 'Stop'+Set-StrictMode -Version 2.0++function Get-WiaProperty($Properties, [int] $Id) {+ # Properties.Item(number) uses a collection position, not a WIA PropertyID.+ foreach ($property in $Properties) {+ if ([int] $property.PropertyID -eq $Id) { return $property }+ }+ return $null+}++function Get-WiaValue($Properties, [int] $Id) {+ $property = Get-WiaProperty $Properties $Id+ if ($null -ne $property) { return $property.Value }+ return $null+}++function Initialize-ScannerApi {+ Add-Type -AssemblyName System.Runtime.WindowsRuntime+ $null = [Windows.Devices.Scanners.ImageScanner,Windows.Devices.Scanners,ContentType=WindowsRuntime]+ $null = [Windows.Devices.Scanners.ImageScannerScanResult,Windows.Devices.Scanners,ContentType=WindowsRuntime]+ $null = [Windows.Storage.StorageFolder,Windows.Storage,ContentType=WindowsRuntime]+}++function Await-Scanner($Operation, [Type]$ResultType, [Type]$ProgressType = $null) {+ $count = 1+ $name = 'IAsyncOperation`1'+ if ($null -ne $ProgressType) { $count = 2; $name = 'IAsyncOperationWithProgress`2' }+ $method = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object {+ $_.Name -eq 'AsTask' -and $_.IsGenericMethod -and+ $_.GetGenericArguments().Length -eq $count -and $_.GetParameters().Length -eq 1 -and+ $_.GetParameters()[0].ParameterType.Name -eq $name+ } | Select-Object -First 1+ if ($null -ne $ProgressType) { $generic = $method.MakeGenericMethod($ResultType, $ProgressType) }+ else { $generic = $method.MakeGenericMethod($ResultType) }+ $task = $generic.Invoke($null, @($Operation))+ $task.GetAwaiter().GetResult()+}++function Select-ScannerFormat($Configuration) {+ foreach ($name in @('Tiff', 'Png', 'DeviceIndependentBitmap', 'Jpeg')) {+ $format = [Enum]::Parse([Windows.Devices.Scanners.ImageScannerFormat], $name)+ if ($Configuration.IsFormatSupported($format)) { return $format }+ }+ throw 'The scanner supports no TIFF, PNG, BMP, or JPEG transfer format.'+}++function Write-Result($Result) {+ $path = [System.IO.Path]::Combine($env:PHOTOCRAFT_WIA_OUTPUT_DIR, 'result.json')+ $json = ConvertTo-Json -InputObject $Result -Depth 8 -Compress+ [System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding($false)))+}++function Cancel-Acquisition($Result) {+ $Result.cancelled = $true+ Write-Result $Result+}++$result = [ordered]@{ available = $true; devices = @(); acquired = $false; cancelled = $false }+$stage = 'initializing WIA'+try {+ $request = ConvertFrom-Json -InputObject $env:PHOTOCRAFT_WIA_REQUEST+ $stage = 'creating the WIA device manager'+ $manager = New-Object -ComObject WIA.DeviceManager+ $scannerInfos = @()+ $devices = @()+ $stage = 'enumerating WIA scanners'+ foreach ($info in $manager.DeviceInfos) {+ if ([int] $info.Type -ne 1) { continue } # ScannerDeviceType+ $id = [string] $info.DeviceID+ $name = [string] (Get-WiaValue $info.Properties 7) # WIA_DIP_DEV_NAME+ if ([string]::IsNullOrWhiteSpace($name)) { $name = $id }+ $devices += [ordered]@{+ id = $id+ name = $name+ manufacturer = [string] (Get-WiaValue $info.Properties 3)+ description = [string] (Get-WiaValue $info.Properties 4)+ type = 'scanner'+ }+ $scannerInfos += $info+ }+ $result.devices = @($devices)+ if ($request.action -eq 'list') {+ Write-Result $result+ exit 0+ }+ if ($devices.Count -eq 0) {+ throw 'No WIA scanner was found. Install the scanner Windows WIA driver (a TWAIN-only driver is insufficient), turn on the scanner, connect USB, and check the Windows Image Acquisition (WIA) service.'+ }+ $intent = switch ($request.mode) {+ 'color' { 1 } # ColorIntent+ 'gray' { 2 } # GrayscaleIntent+ 'text' { 4 } # TextIntent+ default { throw 'Unknown WIA color mode.' }+ }+ $dialog = $null+ if ($request.showUI) { $dialog = New-Object -ComObject WIA.CommonDialog }+ $device = $null+ $selected = $null+ if (![string]::IsNullOrWhiteSpace([string] $request.deviceId)) {+ foreach ($info in $scannerInfos) {+ if ([string]::Equals([string] $info.DeviceID, [string] $request.deviceId, [System.StringComparison]::Ordinal)) {+ $selected = $info+ if ($request.showUI) {+ $stage = 'connecting to the scanner'+ $device = $info.Connect()+ }+ break+ }+ }+ if ($null -eq $selected) { throw 'The selected WIA scanner is unavailable. List devices again, check USB, and ensure no other application is using the scanner.' }+ } elseif ($request.showUI) {+ $stage = 'selecting a scanner'+ $device = $dialog.ShowSelectDevice(1, $true, $false)+ if ($null -eq $device) { Cancel-Acquisition $result; exit 0 }+ } else {+ throw 'A deviceId is required when showUI is false.'+ }+ $item = $null+ $items = $null+ $dpiX = $request.dpi+ $dpiY = $request.dpi+ $colorMode = $request.mode+ $region = $null+ if ($request.showUI) {+ # SingleSelect, UseCommonUI, CancelError. Quality bias does not override chosen settings.+ $stage = 'opening scanner settings'+ $items = $dialog.ShowSelectItems($device, $intent, 131072, $true, $true, $false)+ if ($null -eq $items -or $items.Count -eq 0) { Cancel-Acquisition $result; exit 0 }+ $item = $items.Item(1)+ $deviceId = [string]$device.DeviceID+ $deviceName = [string](Get-WiaValue $device.Properties 7)+ $pnpId = [string](Get-WiaValue $device.Properties 16)+ $uiDpiX = [double](Get-WiaValue $item.Properties 6147)+ $uiDpiY = [double](Get-WiaValue $item.Properties 6148)+ if ($null -eq $dpiX) { $dpiX = $uiDpiX; $dpiY = $uiDpiY }+ $colorMode = switch ([int](Get-WiaValue $item.Properties 4103)) {+ 0 { 'text' }; 1 { 'text' }; 2 { 'gray' }; default { 'color' }+ }+ # WIA's selected rectangle is in pixels; the current API uses inches. Capture the+ # physical area before applying any DPI override so changing resolution cannot crop it.+ if ($uiDpiX -gt 0 -and $uiDpiY -gt 0) {+ $region = @(+ ([double](Get-WiaValue $item.Properties 6149) / $uiDpiX),+ ([double](Get-WiaValue $item.Properties 6150) / $uiDpiY),+ ([double](Get-WiaValue $item.Properties 6151) / $uiDpiX),+ ([double](Get-WiaValue $item.Properties 6152) / $uiDpiY)+ )+ }+ } else {+ $deviceId = [string]$selected.DeviceID+ $deviceName = [string](Get-WiaValue $selected.Properties 7)+ $pnpId = [string](Get-WiaValue $selected.Properties 16)+ }+ if ([string]::IsNullOrWhiteSpace($pnpId)) { throw 'The WIA driver did not expose its scanner PnP ID.' }+ # Release the settings connection before the scan API opens the same hardware.+ foreach ($object in @($item, $items, $device, $dialog)) {+ if ($null -ne $object -and [Runtime.InteropServices.Marshal]::IsComObject($object)) {+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($object)+ }+ }+ $item = $null; $items = $null; $device = $null; $dialog = $null+ $selected = $null; $info = $null; $scannerInfos = @(); $manager = $null+ [GC]::Collect()+ [GC]::WaitForPendingFinalizers()+ $stage = 'opening the Windows scanner API'+ Initialize-ScannerApi+ $scanner = Await-Scanner ([Windows.Devices.Scanners.ImageScanner]::FromIdAsync($pnpId)) ([Windows.Devices.Scanners.ImageScanner])+ $source = [Windows.Devices.Scanners.ImageScannerScanSource]::Flatbed+ if (!$scanner.IsScanSourceSupported($source)) { throw 'This import currently requires a flatbed WIA scanner.' }+ $configuration = $scanner.FlatbedConfiguration+ $stage = 'configuring the scan'+ $mode = switch ($colorMode) {+ 'gray' { [Windows.Devices.Scanners.ImageScannerColorMode]::Grayscale }+ 'text' { [Windows.Devices.Scanners.ImageScannerColorMode]::Monochrome }+ default { [Windows.Devices.Scanners.ImageScannerColorMode]::Color }+ }+ if (!$configuration.IsColorModeSupported($mode)) { throw "The scanner does not support $colorMode mode." }+ $stage = 'setting the scan color mode'+ if ($configuration.ColorMode -ne $mode) { $configuration.ColorMode = $mode }+ if ($null -ne $dpiX) {+ $stage = 'setting the scan resolution'+ $resolution = $configuration.DesiredResolution+ $resolution.DpiX = $dpiX+ $resolution.DpiY = $dpiY+ $configuration.DesiredResolution = $resolution+ $actual = $configuration.ActualResolution+ if ($actual.DpiX -ne $dpiX -or $actual.DpiY -ne $dpiY) { throw "The scanner did not accept $dpiX x $dpiY DPI. Choose a supported resolution." }+ }+ if ($null -ne $region -and $region[2] -gt 0 -and $region[3] -gt 0) {+ $stage = 'setting the selected scan area'+ $rectangle = $configuration.SelectedScanRegion+ # WIA extents are integer pixels and can round the platen edge outward; clamp to the+ # current API's physical bed dimensions (e.g. 11.7 vs 11.6929 inches on the V500).+ $maximum = $configuration.MaxScanArea+ $rectangle.X = [Math]::Max(0, [Math]::Min($region[0], $maximum.Width))+ $rectangle.Y = [Math]::Max(0, [Math]::Min($region[1], $maximum.Height))+ $rectangle.Width = [Math]::Min($region[2], $maximum.Width - $rectangle.X)+ $rectangle.Height = [Math]::Min($region[3], $maximum.Height - $rectangle.Y)+ if ($configuration.AutoCroppingMode -ne [Windows.Devices.Scanners.ImageScannerAutoCroppingMode]::Disabled) {+ $configuration.AutoCroppingMode = [Windows.Devices.Scanners.ImageScannerAutoCroppingMode]::Disabled+ }+ $configuration.SelectedScanRegion = $rectangle+ }+ $stage = 'selecting a supported scanner image format'+ $configuration.Format = Select-ScannerFormat $configuration+ $folder = Await-Scanner ([Windows.Storage.StorageFolder]::GetFolderFromPathAsync($env:PHOTOCRAFT_WIA_OUTPUT_DIR)) ([Windows.Storage.StorageFolder])+ $stage = 'transferring the scanned image'+ $scanned = Await-Scanner ($scanner.ScanFilesToFolderAsync($source, $folder)) ([Windows.Devices.Scanners.ImageScannerScanResult]) ([UInt32])+ $files = @($scanned.ScannedFiles)+ if ($files.Count -ne 1) { throw 'The flatbed scanner returned no image or more than one image.' }+ $file = $files[0]+ $stage = 'reading the acquired image format'+ $extension = ([string]$file.FileType).TrimStart('.').ToLowerInvariant()+ if ($extension -eq 'dib') { $extension = 'bmp' }+ if (@('bmp', 'png', 'tif', 'tiff', 'jpg', 'jpeg') -notcontains $extension) {+ throw "The WIA scanner returned unsupported image format '$extension'. Select TIFF, BMP, PNG, or JPEG in the scanner settings."+ }+ $imagePath = [System.IO.Path]::Combine($env:PHOTOCRAFT_WIA_OUTPUT_DIR, 'scan.bin')+ $stage = 'saving the acquired image'+ [System.IO.File]::Move($file.Path, $imagePath)+ $stage = 'reading the acquired image metadata'+ $result.acquired = $true+ $result.deviceId = $deviceId+ $result.deviceName = $deviceName+ $result.extension = $extension+ $result.format = [string]$configuration.Format+ $result.dpi = $configuration.ActualResolution.DpiX+ $result.dpiY = $configuration.ActualResolution.DpiY+ $stage = 'writing the acquisition result'+ Write-Result $result+ exit 0+} catch {+ # PowerShell wraps COM errors; walk to the inner exception to retain the driver HRESULT.+ $exception = $_.Exception+ while ($null -ne $exception.InnerException) { $exception = $exception.InnerException }+ $hrBytes = [BitConverter]::GetBytes([int] $exception.HResult)+ $hr = [BitConverter]::ToUInt32($hrBytes, 0)+ $hresult = '0x{0:X8}' -f $hr+ # Dialog cancellation normally returns null with CancelError=false. Also recognize generic+ # Win32 cancellation and the cancellation HRESULT used by some WIA Automation drivers.+ if ($hresult -eq '0x80210064' -or $hresult -eq '0x800704C7') {+ Cancel-Acquisition $result+ exit 0+ }+ $hint = switch ($hresult) {+ '0x80210006' { 'Close other scanner applications and try again.' } # WIA_ERROR_BUSY+ '0x80210005' { 'Check the USB connection and turn on the scanner.' } # WIA_ERROR_OFFLINE+ '0x8021000A' { 'Check the scanner USB connection and restart the Windows Image Acquisition (WIA) service.' }+ '0x8021000B' { 'The scanner does not support this WIA command. Try the scanner settings dialog or install its full WIA driver.' }+ '0x8021000E' { 'The scanner driver failed. Install or update its Windows WIA driver.' }+ '0x8021000F' { 'The scanner driver returned an invalid response. Install or update its Windows WIA driver.' }+ '0x80040154' { 'Windows WIA Automation is unavailable. Check the Windows Image Acquisition (WIA) service and repair the Windows scanner components.' }+ default { 'Check the Windows WIA driver, USB connection, Windows Image Acquisition (WIA) service, and whether another application is using the scanner.' }+ }+ $result.error = [ordered]@{ message = "${stage}: $($exception.Message) $hint"; hresult = $hresult }+ Write-Result $result+ exit 1+}new file mode 100644@@ -0,0 +1,268 @@+//! A separate STA PowerShell process owns the WIA COM objects and modal driver dialogs. This+//! keeps COM/driver failures outside the editor, and cancellation can stop a blocked transfer.++use std::fs::File;+use std::io::Read;+use std::os::windows::process::CommandExt;+use std::path::Path;+use std::process::{Child, Command, ExitStatus, Stdio};+use std::time::{Duration, Instant};++use serde_json::Value;++use super::{Acquisition, Action, Request, Scan};+use crate::jobs::JobCtx;+use crate::{EngineError, Result};++const BRIDGE: &str = include_str!("windows.ps1");+const MAX_REPORT_BYTES: u64 = 1 << 20;+const MAX_IMAGE_BYTES: u64 = 256 << 20;+const POLL_INTERVAL: Duration = Duration::from_millis(100);+const LIST_TIMEOUT: Duration = Duration::from_secs(30);+const ACQUIRE_TIMEOUT: Duration = Duration::from_secs(30 * 60);++pub(super) fn run(request: &Request, ctx: &JobCtx) -> Result<Acquisition> {+ ctx.check()?;+ let dir = tempfile::Builder::new().prefix("photocraft-wia-").tempdir().map_err(|e| failure(format!("cannot create the WIA temporary directory: {e}")))?;+ let script = dir.path().join("wia.ps1");+ std::fs::write(&script, BRIDGE).map_err(|e| failure(format!("cannot prepare the WIA bridge: {e}")))?;+ let request_json = serde_json::to_string(request).map_err(|e| failure(format!("cannot encode the WIA request: {e}")))?;+ let mut command = powershell()?;+ command+ .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Sta", "-ExecutionPolicy", "Bypass", "-File"])+ .arg(&script)+ // Request values are data, never interpolated into executable PowerShell code.+ .env("PHOTOCRAFT_WIA_REQUEST", request_json)+ .env("PHOTOCRAFT_WIA_OUTPUT_DIR", dir.path())+ .stdin(Stdio::null())+ .stdout(Stdio::null())+ .stderr(Stdio::null())+ // Hides the console window; WIA/driver dialogs can still open normally.+ .creation_flags(0x0800_0000);+ let mut child =+ command.spawn().map_err(|e| failure(format!("cannot start Windows PowerShell for WIA: {e}; ensure Windows PowerShell 5.1 is installed")))?;+ let listing = matches!(&request.action, Action::List);+ ctx.progress(0.05, if listing { "Looking for WIA scanners" } else { "Waiting for scanner and WIA settings" });+ let status = wait_for_bridge(&mut child, ctx, if listing { LIST_TIMEOUT } else { ACQUIRE_TIMEOUT })?;+ ctx.check()?;+ let report_path = dir.path().join("result.json");+ if !report_path.is_file() {+ return Err(failure(format!(+ "the WIA bridge ended without a result ({status}); check that Windows PowerShell and the Windows Image Acquisition (WIA) service are available"+ )));+ }+ let report_bytes = read_bounded(&report_path, MAX_REPORT_BYTES)?;+ let report = serde_json::from_slice(&report_bytes).map_err(|e| failure(format!("the WIA bridge returned an invalid result: {e}")))?;+ let (extension, dpi) = validate_report(&report, listing, status.success())?;+ let image = if let Some(extension) = extension {+ ctx.progress(0.8, "Reading the scanned image");+ let bytes = read_bounded(&dir.path().join("scan.bin"), MAX_IMAGE_BYTES)?;+ if bytes.is_empty() {+ return Err(failure("the scanner returned an empty image"));+ }+ Some(Scan { bytes, extension, dpi })+ } else {+ None+ };+ ctx.check()?;+ Ok(Acquisition { report, image })+}++fn failure(message: impl Into<String>) -> EngineError {+ EngineError::Other(format!("WIA: {}", message.into()))+}++fn powershell() -> Result<Command> {+ // Use the Windows installation, rather than searching an arbitrary working directory/PATH.+ let root = std::env::var_os("SystemRoot").ok_or_else(|| failure("Windows SystemRoot is unavailable; Windows PowerShell cannot be located"))?;+ Ok(Command::new(Path::new(&root).join("System32/WindowsPowerShell/v1.0/powershell.exe")))+}++fn stop_bridge(child: &mut Child) {+ // `kill` may fail if the child has just exited. Always reap it before dropping its tempdir.+ let _ = child.kill();+ let _ = child.wait();+}++fn wait_for_bridge(child: &mut Child, ctx: &JobCtx, timeout: Duration) -> Result<ExitStatus> {+ let started = Instant::now();+ loop {+ if ctx.cancelled() {+ stop_bridge(child);+ return Err(EngineError::Cancelled);+ }+ match child.try_wait() {+ Ok(Some(status)) => return Ok(status),+ Ok(None) => {}+ Err(e) => {+ stop_bridge(child);+ return Err(failure(format!("cannot monitor the WIA bridge: {e}")));+ }+ }+ if started.elapsed() >= timeout {+ stop_bridge(child);+ return Err(failure("the scanner did not finish in time; check the USB connection, close other scanner applications, and try again"));+ }+ std::thread::sleep(POLL_INTERVAL);+ }+}++fn read_bounded(path: &Path, max: u64) -> Result<Vec<u8>> {+ let file = File::open(path).map_err(|e| failure(format!("cannot read the scanner result: {e}")))?;+ let length = file.metadata().map_err(|e| failure(format!("cannot inspect the scanner result: {e}")))?.len();+ if length > max {+ return Err(failure(format!("the scanner result exceeds the {} MiB limit; scan a smaller area or use a lower resolution", max >> 20)));+ }+ // Limit the read as well as the metadata check, so a growing file cannot bypass the cap.+ let mut reader = file.take(max.saturating_add(1));+ let capacity = usize::try_from(length).map_err(|_| failure("the scanner result is too large for this system"))?;+ let mut bytes = Vec::new();+ bytes.try_reserve_exact(capacity).map_err(|e| failure(format!("not enough memory to read the scanner result: {e}")))?;+ reader.read_to_end(&mut bytes).map_err(|e| failure(format!("cannot read the scanner result: {e}")))?;+ if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max {+ return Err(failure("the scanner result grew beyond the allowed size"));+ }+ Ok(bytes)+}++/// The bridge is a separate process: validate its result before trusting a format or importing.+fn validate_report(report: &Value, listing: bool, process_ok: bool) -> Result<(Option<String>, Option<f32>)> {+ if let Some(error) = report.get("error").filter(|v| !v.is_null()) {+ let message = error.get("message").and_then(Value::as_str).unwrap_or("the WIA driver failed");+ let hresult = error.get("hresult").and_then(Value::as_str).unwrap_or("unknown HRESULT");+ return Err(failure(format!("{message} ({hresult})")));+ }+ if !process_ok {+ return Err(failure("the WIA bridge failed; check the Windows Image Acquisition (WIA) service and scanner driver"));+ }+ let available = report.get("available").and_then(Value::as_bool);+ let acquired = report.get("acquired").and_then(Value::as_bool);+ let cancelled = report.get("cancelled").and_then(Value::as_bool);+ if available != Some(true) || !report.get("devices").is_some_and(Value::is_array) || acquired.is_none() || cancelled.is_none() {+ return Err(failure("the WIA bridge returned an incomplete result"));+ }+ if cancelled == Some(true) && acquired == Some(true) {+ return Err(failure("the WIA bridge returned contradictory acquisition and cancellation flags"));+ }+ if acquired != Some(true) {+ if listing || cancelled == Some(true) {+ return Ok((None, None));+ }+ return Err(failure("the scanner returned no image; check the WIA driver, USB connection and Windows Image Acquisition service"));+ }+ if listing {+ return Err(failure("the WIA bridge acquired an image during device enumeration"));+ }+ let extension = report.get("extension").and_then(Value::as_str).unwrap_or("").to_ascii_lowercase();+ if !matches!(extension.as_str(), "bmp" | "png" | "tif" | "tiff" | "jpg" | "jpeg") {+ return Err(failure(format!("the scanner returned an unsupported image format `{extension}`; select TIFF, BMP, PNG or JPEG in the scanner settings")));+ }+ let dpi = match report.get("dpi") {+ None | Some(Value::Null) => None,+ Some(value) => {+ let n =+ value.as_f64().filter(|n| n.is_finite() && *n > 0.0 && *n <= 65535.0).ok_or_else(|| failure("the scanner returned an invalid resolution"))?;+ Some(n as f32)+ }+ };+ Ok((Some(extension), dpi))+}++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;++ #[test]+ fn validates_actual_format_resolution_and_cancellation() {+ let mut report = json!({"available": true, "devices": [], "acquired": true, "cancelled": false, "extension": "TIFF", "dpi": 600});+ assert_eq!(validate_report(&report, false, true).unwrap(), (Some("tiff".into()), Some(600.0)));+ report["extension"] = json!("../image.ps1");+ assert!(validate_report(&report, false, true).is_err());+ report["extension"] = json!("bmp");+ report["dpi"] = json!(-1);+ assert!(validate_report(&report, false, true).is_err());+ assert_eq!(validate_report(&json!({"available": true, "devices": [], "acquired": false, "cancelled": true}), false, true).unwrap(), (None, None));+ assert!(validate_report(&json!({}), false, true).is_err());+ }++ #[test]+ fn enumeration_cannot_claim_an_acquisition() {+ let report = json!({"available": true, "devices": [], "acquired": false, "cancelled": false});+ assert!(validate_report(&report, true, true).is_ok());+ assert!(validate_report(&report, false, true).is_err());+ assert!(validate_report(&report, true, false).is_err());+ let acquired = json!({"available": true, "devices": [], "acquired": true, "cancelled": false, "extension": "tiff"});+ assert!(validate_report(&acquired, true, true).is_err());+ }++ #[test]+ fn driver_errors_keep_hresult_and_advice() {+ let error = validate_report(&json!({"error": {"message": "Close other scanner applications", "hresult": "0x80210006"}}), false, false).unwrap_err();+ let message = error.to_string();+ assert!(message.contains("0x80210006"));+ assert!(message.contains("Close other scanner applications"));+ }++ #[test]+ fn result_file_read_is_bounded() {+ let dir = tempfile::tempdir().unwrap();+ let path = dir.path().join("image");+ std::fs::write(&path, b"image").unwrap();+ assert_eq!(read_bounded(&path, 5).unwrap(), b"image");+ assert!(read_bounded(&path, 4).is_err());+ }++ #[test]+ fn bridge_uses_property_ids_and_supported_formats() {+ // Exercise the same PowerShell helpers with deliberately shuffled synthetic properties.+ // The main COM/acquisition block is excluded, so no hardware is contacted by this test.+ let helpers = BRIDGE.split_once("$result = [ordered]").unwrap().0;+ let fixture = r#"+$x = [pscustomobject]@{ PropertyID = 6147; Value = 300 }+$y = [pscustomobject]@{ PropertyID = 6148; Value = 600 }+$other = [pscustomobject]@{ PropertyID = 100; Value = 123 }+$properties = @($y, $other, $x)+if ((Get-WiaValue $properties 6147) -ne 300 -or (Get-WiaValue $properties 6148) -ne 600) { throw 'Property ID lookup failed.' }+Initialize-ScannerApi+$v500 = [pscustomobject]@{ Supported = @('DeviceIndependentBitmap') }+$v500 | Add-Member ScriptMethod IsFormatSupported { param($format) return $this.Supported -contains [string]$format }+if ([string](Select-ScannerFormat $v500) -ne 'DeviceIndependentBitmap') { throw 'The BMP-only V500 was given an unsupported format.' }+$v500.Supported = @('DeviceIndependentBitmap', 'Tiff')+if ([string](Select-ScannerFormat $v500) -ne 'Tiff') { throw 'Supported TIFF was not preferred.' }+$v500.Supported = @()+$rejected = $false+try { Select-ScannerFormat $v500 } catch { $rejected = $true }+if (!$rejected) { throw 'An unsupported transfer format was accepted.' }+"#;+ let dir = tempfile::tempdir().unwrap();+ let script = dir.path().join("helpers.ps1");+ std::fs::write(&script, format!("{helpers}\n{fixture}")).unwrap();+ let output = powershell()+ .unwrap()+ .args(["-NoProfile", "-NonInteractive", "-Sta", "-ExecutionPolicy", "Bypass", "-File"])+ .arg(script)+ .creation_flags(0x0800_0000)+ .output()+ .unwrap();+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));+ }++ #[test]+ fn cancellation_reaps_the_bridge_process() {+ let mut command = powershell().unwrap();+ let mut child = command+ .args(["-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 60"])+ .creation_flags(0x0800_0000)+ .stdout(Stdio::null())+ .stderr(Stdio::null())+ .spawn()+ .unwrap();+ let ctx = JobCtx::new();+ ctx.cancel();+ let started = Instant::now();+ assert!(matches!(wait_for_bridge(&mut child, &ctx, LIST_TIMEOUT), Err(EngineError::Cancelled)));+ assert!(started.elapsed() < Duration::from_secs(5));+ assert!(child.try_wait().unwrap().is_some());+ }+}@@ -107,13 +107,20 @@ impl PhotocraftApp { id, "layer.vectorMask.add" | "layer.vectorMask.fromPath" | "layer.vectorMask.revealAll" | "layer.vectorMask.hideAll" | "layer.vectorMask.currentPath" ) && on_active_layer;- let r = if id == "actions.play" {+ let mut r = if id == "actions.play" { actions::play(self, ¶ms) } else if photocraft_engine::actions_cmds::shell_view_command(id) { actions::run_view(self, id, params) } else { jobs_ui::run(self, id, params) };+ if id == "file.import.wiaSupport"+ && let Ok(result) = &r+ && result.get("pending").and_then(Value::as_bool) != Some(true)+ && let Err(err) = jobs_ui::finish_wia(self, result)+ {+ r = Err(err);+ } let creates_adjustment_or_fill = id.starts_with("layer.newAdjustmentLayer.") || id.starts_with("layer.newFillLayer."); if r.is_ok() && ADDS_LAYER_MASK.contains(&id) { // Adding an explicit mask targets it, as in Photoshop (#2166).@@ -254,6 +254,10 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result<Val let document = app.session.documents().get(index).map(|st| st.doc.id).ok_or("the opened document is missing")?; if let Some(st) = app.session.active_mut() { st.source_read_only = read_only;+ // In-memory imports, including scans, have no durable source to return to.+ if path.is_none() {+ st.saved_revision = 0;+ } } let index = match slot { Some(slot) => app@@ -175,6 +175,9 @@ pub struct JobsUi { pub focus: Option<JobId>, /// The job the latest [`run`] started (the control channel waits on it). pub(crate) last_started: Option<JobId>,+ /// Scanner opens started by automation or with script events disabled keep that policy+ /// when their result arrives after the command's temporary suppression has ended.+ wia_without_script_events: Vec<JobId>, } /// Run command `id`: in the background when background jobs are on and the command is a job@@ -188,6 +191,9 @@ pub fn run(app: &mut PhotocraftApp, id: &str, params: Value) -> Result<Value, St Started::Done(v) => Ok(v), Started::Job(job) => { app.jobs.last_started = Some(job);+ if id == "file.import.wiaSupport" && (app.automation_input || !app.session.prefs().script_events.enabled) {+ app.jobs.wia_without_script_events.push(job);+ } // The status bar's progress readout names the job; clear any old message. app.ui.status.clear(); app.ui.status_error = false;@@ -447,6 +453,15 @@ pub fn cancel(app: &mut PhotocraftApp, id: JobId) -> bool { } fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) {+ let suppress_wia_events = app+ .jobs+ .wia_without_script_events+ .iter()+ .position(|job| *job == e.id)+ .map(|i| {+ app.jobs.wia_without_script_events.remove(i);+ })+ .is_some(); // Preferences ▸ General ▸ Beep When Done (off by default): a finished operation beeps, like // Photoshop's cue. Cancelling is the user's own action and stays silent. if matches!(e.outcome, JobOutcome::Done(_))@@ -510,6 +525,19 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { return; } match e.outcome {+ JobOutcome::Done(ref result) if e.command == "file.import.wiaSupport" => {+ let events_enabled = app.session.prefs().script_events.enabled;+ if suppress_wia_events && events_enabled {+ app.session.edit_prefs(|prefs| prefs.script_events.enabled = false);+ }+ let finished = finish_wia(app, result);+ if suppress_wia_events && events_enabled {+ app.session.edit_prefs(|prefs| prefs.script_events.enabled = true);+ }+ if let Err(err) = finished {+ app.open_failed("Scan", &err);+ }+ } JobOutcome::Done(v) if e.command == "brush.presets.importAbr" => { let n = v.get("count").and_then(Value::as_u64).unwrap_or(0); let group = v.get("group").and_then(Value::as_str).unwrap_or_default();@@ -544,6 +572,34 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { } } +/// Finish a scanner result through the same colour-policy prompts, warnings and Open Document+/// hooks as a file open. Discovery has no shell effects; scans have no recent-file location.+pub(crate) fn finish_wia(app: &mut PhotocraftApp, result: &Value) -> Result<(), String> {+ if result.get("acquired").and_then(Value::as_bool) == Some(true) {+ let suppress_events = app.automation_input && app.session.prefs().script_events.enabled;+ if suppress_events {+ app.session.edit_prefs(|prefs| prefs.script_events.enabled = false);+ }+ let finished = finish_open(app, "Scan", None, result, None);+ if suppress_events {+ app.session.edit_prefs(|prefs| prefs.script_events.enabled = true);+ }+ if finished.is_ok() {+ // A previously focused opening tab or Home overlay must give way to this scan.+ // Other opens remain running and keep their tabs.+ app.jobs.focus = None;+ app.ui.chrome.home = None;+ }+ finished+ } else {+ if result.get("cancelled").and_then(Value::as_bool) == Some(true) {+ app.ui.status = crate::i18n::fmt(tl!("Cancelled {label}"), &[("label", "WIA scanner")]);+ app.ui.status_error = false;+ }+ Ok(())+ }+}+ /// What [`crate::PhotocraftApp::open_bytes`] does after decoding, for a background open's result /// (`{document, docId, warnings, color}` or `{held, …}`). fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Value, slot: Option<usize>) -> Result<(), String> {@@ -889,3 +945,6 @@ pub fn bytes(b: &[u8]) -> OpenSource { #[cfg(test)] mod tests;++#[cfg(test)]+mod wia_tests;new file mode 100644@@ -0,0 +1,131 @@+use super::*;+use photocraft_color::{ColorMode, SampleType};+use photocraft_doc::Document;+use photocraft_engine::Session;+use photocraft_engine::automate_cmds::ScriptBinding;+use photocraft_geom::Size;++fn scan_app() -> (PhotocraftApp, Value) {+ let mut session = Session::new();+ session.execute("file.new", json!({"width": 8, "height": 9, "name": "Previous"})).unwrap();+ session.execute("edit.colorSettings", json!({"askOnMissing": false})).unwrap();+ session.edit_prefs(|prefs| {+ prefs.script_events.enabled = true;+ prefs.script_events.bindings = vec![ScriptBinding { event: "openDocument".into(), script: None, steps: Some(json!([])), name: "Open scan".into() }];+ });+ let mut scan = Document::new("Scan", Size::new(3, 2), ColorMode::Grayscale, SampleType::U16);+ scan.resolution_dpi = 300.0;+ let (index, color) = session.open_document(scan, None);+ session.active_mut().unwrap().saved_revision = 0;+ let report = json!({"available": true, "devices": [], "acquired": true, "cancelled": false,+ "document": index, "docId": session.active().unwrap().doc.id.0, "color": color, "warnings": ["Scanner import note"]});+ (PhotocraftApp::new(session, crate::Services::default()), report)+}++#[test]+fn acquired_scan_shows_warnings_without_a_save_or_recent_path() {+ let (mut app, report) = scan_app();+ let previous = app.session.documents()[0].doc.id;+ let opening = JobId(41);+ app.jobs.opens.push(OpenTab { job: opening, name: "Other image".into(), path: None, slot: None, order: None });+ app.jobs.focus = Some(opening);+ app.ui.chrome.home = Some(app.session.documents().len());+ finish_wia(&mut app, &report).unwrap();+ assert_eq!(app.session.documents().len(), 2);+ assert_eq!(app.session.documents()[0].doc.id, previous);+ let active = app.session.active().unwrap();+ assert_eq!(active.doc.name, "Scan");+ assert_eq!(active.doc.depth, SampleType::U16);+ assert_eq!(active.doc.mode, ColorMode::Grayscale);+ assert_eq!(active.doc.resolution_dpi, 300.0);+ assert!(active.path.is_none());+ assert!(active.is_dirty());+ assert_eq!(app.ui.views.len(), 2);+ assert_eq!(app.session.active_index(), Some(1));+ assert!(app.jobs.focus.is_none());+ assert!(app.ui.chrome.home.is_none());+ assert_eq!(app.jobs.opens.len(), 1, "other background opening tabs remain available");+ assert!(app.session.prefs().file_handling.recent_files.is_empty());+ assert!(app.ui.dialogs.is_empty());+ assert!(app.ui.notices.iter().any(|notice| notice.lines.iter().any(|line| line == "Scanner import note")));+ assert_eq!(app.session.file_menu.event_log.len(), 1, "one Open Document hook for the completed scan");+}++#[test]+fn background_scan_completion_keeps_captured_automation_script_suppression() {+ for automation in [false, true] {+ let (mut app, report) = scan_app();+ let job = JobId(37);+ if automation {+ app.jobs.wia_without_script_events.push(job);+ }+ // The control request's flag and preferences have already been restored by the+ // time its background result arrives.+ app.automation_input = false;+ on_event(+ &mut app,+ &egui::Context::default(),+ JobEvent { id: job, command: "file.import.wiaSupport".into(), label: "WIA scanner".into(), document: None, outcome: JobOutcome::Done(report) },+ );+ assert_eq!(app.session.file_menu.event_log.len(), usize::from(!automation));+ assert!(app.session.prefs().script_events.enabled, "restore the current preference after completion");+ assert!(app.jobs.wia_without_script_events.is_empty());+ assert!(app.ui.dialogs.is_empty());+ assert!(app.session.active().unwrap().is_dirty());+ }+}++#[test]+fn synchronous_automation_scan_does_not_fire_user_script_hooks() {+ let (mut app, report) = scan_app();+ app.automation_input = true;+ finish_wia(&mut app, &report).unwrap();+ assert!(app.session.file_menu.event_log.is_empty());+ assert!(app.session.prefs().script_events.enabled);+}++#[test]+fn scanner_cancellation_and_discovery_do_not_open_or_change_documents() {+ let mut app = PhotocraftApp::new(Session::new(), crate::Services::default());+ app.run("file.new", json!({"width": 8, "height": 9})).unwrap();+ let before = app.session.active().unwrap().doc.id;+ app.ui.status = "Ready".into();+ finish_wia(&mut app, &json!({"available": true, "devices": [], "acquired": false, "cancelled": false})).unwrap();+ assert_eq!(app.ui.status, "Ready", "discovery has no shell effects");+ finish_wia(&mut app, &json!({"available": true, "devices": [], "acquired": false, "cancelled": true})).unwrap();+ assert!(app.ui.status.contains("Cancelled"));+ assert!(!app.ui.status_error);+ assert_eq!(app.session.documents().len(), 1);+ assert_eq!(app.session.active().unwrap().doc.id, before);+ assert!(app.ui.dialogs.is_empty());+ assert!(app.ui.notices.is_empty());+}++#[test]+fn scan_stays_unsaved_after_the_standard_missing_profile_prompt() {+ let mut app = PhotocraftApp::new(Session::new(), crate::Services::default());+ app.run("file.new", json!({"width": 2, "height": 2})).unwrap();+ let previous = app.session.active().unwrap().doc.id;+ app.session.execute("edit.colorSettings", json!({"askOnMissing": true})).unwrap();+ let mut image = photocraft_codecs::Image::new(3, 2, photocraft_codecs::ChannelLayout::Gray, photocraft_codecs::SampleType::U16).unwrap();+ image.meta.dpi = Some((300.0, 300.0));+ let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).unwrap();+ let mut report = match app.session.start_open("Scan.tif", OpenSource::Bytes(Arc::new(bytes))).unwrap() {+ Started::Done(result) => result,+ Started::Job(job) => app.session.wait_job(job).unwrap(),+ };+ report["acquired"] = json!(true);+ finish_wia(&mut app, &report).unwrap();+ assert_eq!(app.session.documents().len(), 1);+ assert_eq!(app.session.active().unwrap().doc.id, previous);+ let prompt = app.ui.dialogs.first().unwrap();+ assert_eq!(prompt.fields["__prefsui"], "mismatch");+ assert_eq!(prompt.fields["missing"], true);+ crate::open_prompt::answer(&mut app, Some("preserve")).unwrap();+ assert_eq!(app.session.documents().len(), 2);+ let scan = app.session.active().unwrap();+ assert!(scan.path.is_none());+ assert!(scan.is_dirty(), "accepting the profile prompt must still offer to save the scan");+ assert_eq!(scan.doc.depth, SampleType::U16);+ assert_eq!(scan.doc.resolution_dpi, 300.0);+}@@ -203,6 +203,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: "file.import.videoFramesToLayers" if params.get("path").and_then(Value::as_str).is_none() => { app.ask_folder(|app, path| app.run("file.import.videoFramesToLayers", json!({"path": path})).map_err(|e| e.to_string())) }+ "file.import.wiaSupport" if params.as_object().is_some_and(|p| p.is_empty()) => {+ // The menu opens scanner settings; an empty engine request only discovers devices.+ app.run(id, json!({"action": "acquire"}))+ } "file.save" => { // Writes back only to a layered file that holds the whole document; anything else // goes through Save As, so the original is never overwritten with less (#3806).@@ -528,3 +528,22 @@ fn proof_setup_presets_and_checkmarks() { let params = crate::filter_dialog::parse_spec(spec.params); assert!(matches!(¶ms[0].kind, crate::filter_dialog::Kind::Choice { options, .. } if options[0] == "srgb" && !options.iter().any(|v| v.contains('/')))); }++#[test]+fn wia_menu_requests_acquisition_without_a_document() {+ let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());+ let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));+ let capture = seen.clone();+ app.automation_input = true;+ app.services.automation_command = Some(Box::new(move |id, params| {+ capture.borrow_mut().push((id.to_string(), params.clone()));+ if params.get("action") == Some(&json!("acquire")) { Err("test intercepted before scanner access".into()) } else { Ok(()) }+ }));+ let item = menu_items(&app).into_iter().find(|item| item.id == "file.import.wiaSupport").unwrap();+ assert!(item.enabled);+ assert_eq!(item.path, ["File", "Import"]);+ let error = invoke(&mut app, &egui::Context::default(), &item.id, json!({})).unwrap_err();+ assert_eq!(error, "test intercepted before scanner access");+ assert!(seen.borrow().iter().any(|(id, params)| id == "file.import.wiaSupport" && *params == json!({"action": "acquire"})));+ assert!(app.session.documents().is_empty());+}@@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (G9: 12 of 178 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Windows WIA flatbed acquisition implemented and Epson V500 menu scan confirmed; G9: 12 of 178 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that@@ -351,6 +351,12 @@ what an agent can't finish alone. ## Hardware gaps ### G12. Pen tablets and display hardware+- **Scanner progress (2026-10-26):** the WIA placeholder now discovers scanners and imports+ flatbed scans into new unsaved documents through cancellable desktop jobs. Epson V500+ acquisition was physically verified at 300 dpi via automation and 100 dpi through the menu;+ the user confirmed the scanner runs and the image opens. Transfer cancellation/retry,+ other driver modes and film/slide capabilities remain unverified; feeder/TWAIN are absent.+ [API and hardware checklist](wia-scanning.md). - **Missing:** tilt and rotation on Windows (pressure only, via WM_POINTER; no WinTab); native Wayland tablet-v2 (#79; the pen does not click on GNOME 50, #2622, KDE #2297); eraser end on flipped Wacom pens (#2285); HDR/EDR output (Photoshop has "Precise Color Management for HDR@@ -390,6 +396,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Windows WIA flatbed scanner discovery/settings and acquisition implemented; Epson V500 physical 300 dpi automation and 100 dpi menu scans imported successfully; remaining driver checks documented; estimates unchanged | | 2026-10-25 | minor | G9: the count of preferences that do nothing is the scorecard's again, 12 of 178 (it still said 24 of 171, from 2026-10-15); no estimate changed | | 2026-10-25 | minor | G6: Art History Brush landed as partial (TOOL-213-20, #1610), so no toolbar tool is missing; no percentage or estimate changed | | 2026-10-23 | minor | G4: hard-round built-in presets use 5% spacing (#8842), guarded by large-stroke outline and pressure-opacity pixel regressions at 8/16/32-bit; percentages and estimates unchanged |@@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Camera Raw's preview develops its per-pixel stages on the GPU) · **Target:** Adobe Photoshop 2026+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Windows WIA flatbed acquisition implemented and Epson V500 menu scan confirmed; Camera Raw's preview develops its per-pixel stages on the GPU) · **Target:** Adobe Photoshop 2026 Each hardware feature Photoshop 2026 (27.11.0) uses, per platform, against PhotoCraft. Photoshop side: its bundle (Metal and OpenCL paths, "Precise Color Management for HDR Display" and EDR@@ -56,12 +56,13 @@ HDR display to verify. | Touch Bar | yes (Intel Macs) | no | missing (low value) | | Touch screens (Windows) | yes | basic pointer only | partial | | 3D mouse, MIDI, Surface Dial | Surface Dial on Windows | no | missing (low value) |-| Scanners (WIA / TWAIN) | WIA menu (Windows) | menu item live; no driver integration | partial |+| Scanners (WIA / TWAIN) | WIA menu (Windows) | WIA discovery/settings plus Windows ImageScanner flatbed acquisition; cancellable desktop jobs import new unsaved documents; Epson V500 physical scans verified through automation and the menu; transfer cancellation and film/slide checks pending; feeder/TWAIN absent ([test guide](wia-scanning.md)) | partial | ## Revision history | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Windows WIA flatbed scanner discovery/settings and acquisition implemented; Epson V500 physical 300 dpi automation and 100 dpi menu scans imported successfully; remaining driver checks documented; estimates unchanged | | 2026-10-10 | major | First version | | 2026-10-17 | minor | GPU filters: Camera Raw's preview, per-pixel stages (#6916 step 1) | | 2026-10-19 | minor | Graphics Adapter choice on Linux (#1885) |@@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-25 · **Change:** minor (12 of 178 preferences do nothing, from the scorecard; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (Windows WIA flatbed acquisition implemented and Epson V500 menu scan confirmed; 12 of 178 preferences do nothing, from the scorecard; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md);@@ -80,7 +80,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | File formats and fidelity | 15% | 60% | 300–500 | PSD oracle on Photoshop-authored files 133/256 (52%); io corpus 146/170 (86%); psd-tools 236/309 (76%); round trips ~100% of corpus; but users report Photoshop refusing our files (#1281, #2469; a text-layer case, #2374, was fixed 2026-10-10); no Photoshop PDF, EPS, JPEG 2000, JPEG XL, AVIF read, CR3 | [file-format-parity.md](file-format-parity.md) | | Performance | 10% | 25% | 200–350 | 3 of 25 budgeted scenarios meet budget (baseline 2026-10-05); 150-layer nudge crashes the GPU path (P12); layer move on 15000×10000 16-bit 2.7 s p50; users report brush and cursor lag (#2619, #2566, #1994) | [scorecard.md](scorecard.md#performance) | | Stability | 10% | 45% | 150–250 | Never-crash lints on 28/28 crates, `panic_hunt` in the gate, atomic saves; but field reports of freezes (#2518 Type tool 60 s on macOS), GPU panics (#2020, #1215), no-GPU start failures (#2519, #2412) | [gaps.md](gaps.md) |-| Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) |+| Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only; Windows flatbed WIA import verified on Epson V500 (automation and menu) | [hardware-parity.md](hardware-parity.md) | | Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi and Arabic absent, Vietnamese catalog added 2026-10-12 (#2636, coverage not re-measured); no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | | Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; Windows ARM64 is packaged and install-checked by `.github/workflows/windows-arm64.yml` (DIST-220-2); Help › Check for Updates plus an opt-out check once per launch (DIST-220-1 partial, no periodic recheck) | [scorecard.md](scorecard.md#distribution) | | Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 112 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) |@@ -574,6 +574,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Windows WIA flatbed scanner discovery/settings and acquisition implemented; Epson V500 physical 300 dpi automation and 100 dpi menu scans imported successfully; remaining driver checks documented; estimates unchanged | | 2026-10-25 | minor | UI / UX and Workspace rows: 12 of 178 preferences do nothing, the scorecard's count. Camera Raw row: the Calibration panel landed on 2026-10-13 (#5128); what is missing is per-camera colour for non-DNG cameras ([gaps.md](gaps.md) G14). Percentages and estimates unchanged | | 2026-10-23 | minor | Colour management and modes: PSD spot plates are stored as Photoshop stores them (ink 0 = solid), and PSDs from earlier PhotoCraft versions keep their ink (#7415 split, part 4); estimates unchanged | | 2026-10-23 | minor | Colour management and modes: spot plates now save and open in TIFF (#7415); estimates unchanged |new file mode 100644@@ -0,0 +1,145 @@+# Scanning with Windows Image Acquisition++PhotoCraft imports scans through Windows Image Acquisition (WIA) on native Windows builds.+Install the scanner manufacturer's Windows driver, connect and power on the scanner, and choose+**File > Import > WIA Support…**. Select the scanner and scan settings in the Windows dialog;+the acquired image opens as a new, unsaved PhotoCraft document. Save it with Save As.++Scanning runs in a desktop background job, so the PhotoCraft window remains responsive. Cancel+in the scanner dialog or cancel the PhotoCraft job to stop acquisition. Existing documents are+not replaced. Cancellation and driver errors do not open a partially acquired document.++The native backend uses Windows PowerShell, WIA discovery/settings dialogs, and the current+Windows `ImageScanner` API for flatbed acquisition on Windows 10/11. It requires no additional+runtime installation; the scanner's WIA driver is still required. WIA+acquisition is unavailable on macOS, Linux and the web build. TWAIN is not implemented.++## Command and automation++The engine command is `file.import.wiaSupport`. Calling it with `{}` or `{"action":"list"}`+enumerates WIA scanners without opening a scan dialog. The result includes `available` and a+`devices` array; each device has `id`, `name`, `manufacturer`, `description` and `type`.+An empty list means Windows did not expose a scanner to WIA. Device IDs are opaque strings:+use the exact `id` returned by enumeration rather than a display name or list position.++The menu invokes `{"action":"acquire"}`. Automation can request the same interactive scan or+acquire from an explicit device without scanner dialogs:++```json+{+ "action": "acquire",+ "deviceId": "<exact id from devices>",+ "showUI": false,+ "dpi": 300,+ "mode": "color"+}+```++| Parameter | Accepted values | Default and behaviour |+|---|---|---|+| `action` | `list`, `acquire` | `list`; enumeration does not scan |+| `deviceId` | Exact enumerated scanner ID | Interactive acquisition lets the user select a scanner; acquisition without dialogs requires an explicit ID |+| `showUI` | Boolean | `true`; `false` transfers without scanner settings or progress dialogs |+| `dpi` | Integer, 1–12800 | Optional; omission uses the driver or dialog resolution |+| `mode` | `color`, `gray`, `text` | `color`; requests the corresponding WIA acquisition intent |++The DPI bounds validate the request, not the scanner's capabilities. A driver can accept only a+list or range of resolutions, or have a read-only resolution. Unsupported values return an+error rather than silently changing the requested resolution. When `dpi` is provided with+`showUI:true`, PhotoCraft applies it after the settings dialog and before transfer. The returned+scan metadata reports the resolution supplied by the driver. [Microsoft documents WIA resolution+constraints](https://learn.microsoft.com/en-us/windows-hardware/drivers/image/wia-ips-xres).++Interactive cancellation is a normal result with `acquired:false` and `cancelled:true`. Successful+acquisition reports `acquired:true` and scan/document metadata. No scanner, an unavailable selected+device, an unsupported setting, a busy driver or a failed transfer produces an actionable error.+Non-Windows enumeration reports `available:false` and an empty `devices` array.++In the desktop control channel, pass `wait:false` for scans that may take longer than a request+timeout, then poll `jobs.list` and cancel with `jobs.cancel {"job":<job id>}`:++```json+{"id":1,"method":"engine.execute","params":{"command":"file.import.wiaSupport","params":{"action":"list"}}}+{"id":2,"method":"engine.execute","params":{"command":"file.import.wiaSupport","params":{"action":"acquire","deviceId":"<exact id>","showUI":false,"dpi":300,"mode":"color"},"wait":false}}+{"id":3,"method":"jobs.list","params":{}}+{"id":4,"method":"jobs.cancel","params":{"job":1}}+```++Use the job ID returned by request 2 in request 4. Direct `Session::execute` calls are synchronous;+desktop dispatch uses the job infrastructure. Cancelling a running job stops the WIA helper+process and discards its output. See [background jobs](control-protocol.md#background-jobs-210)+for the control/MCP lifecycle.++## Image fidelity and driver behaviour++PhotoCraft selects a format the scanner supports, preferring TIFF, PNG, BMP, then JPEG. The+V500 tested here exposes BMP only. Acquisition uses `ImageScanner.ScanFilesToFolderAsync`:+the legacy WIA Automation `Item.Transfer` returned access denied on this working scanner,+including when requesting BMP. The+scan goes through the existing image importer, retaining the bit depth and embedded colour+profile that it supports. Temporary acquisition files are cleaned up; they do not become the+document's save path. Metadata or colour profiles that the driver omits cannot be recovered.+[Microsoft documents the current scanner API](https://learn.microsoft.com/en-us/uwp/api/windows.devices.scanners.imagescanner).++PhotoCraft converts the dialog's pixel rectangle to inches before applying a DPI override and+clamps it to the reported platen dimensions. Verify the same physical area at different+resolutions with each driver; PhotoCraft does not assume a fixed platen size.+[Microsoft requires drivers to update scan coordinates and extents after resolution changes](https://learn.microsoft.com/en-us/windows-hardware/drivers/image/wia-ips-xextent).++Close Epson Scan or other scanner software before testing if WIA reports a busy or locked device.+For an offline or communication error, check power and USB, then enumerate again. Missing devices+usually require checking the installed WIA driver and Windows Image Acquisition service.+[Microsoft's WIA error reference](https://learn.microsoft.com/en-us/previous-versions/windows/desktop/wia/-wia-error-codes)+explains the device status codes included in error diagnostics.++## Epson Perfection V500 validation++Use the driver appropriate to the exact V500 model and Windows version from+[Epson's V500 Photo support page](https://epson.com/Support/Scanners/Perfection-Series/Epson-Perfection-V500-Photo/s/SPT_B11B189011).+The V500's original documentation states that WIA exposes fewer scanner features than Epson+Scan/TWAIN. Film and slide scanning, DIGITAL ICE and the full optical-resolution range must be+verified separately; this implementation does not promise those features.+[Epson's V500 guide](https://files.support.epson.com/pdf/prv5ph/prv5phug.pdf).++Test the following on a physical V500, recording Windows version, driver version, device ID,+requested/returned DPI, returned format, dimensions, bit depth and any HRESULT:++1. Power on and connect USB; confirm enumeration contains the V500. If another scanner is+ present, acquire by the V500's exact ID and verify that the intended scanner runs.+2. Put a photo on the platen and scan through the menu at 300 dpi. Confirm preview/settings,+ transfer, document dimensions, orientation, colour and Save As all work.+3. Scan the same physical area at 300 and 600 dpi. Check that dimensions approximately double+ while the selected area stays the same. Verify `color`, `gray` and `text` requests.+4. Acquire at 300 dpi with `showUI:false` and the exact device ID. Confirm no scanner dialogs+ appear, and compare scan area, DPI and image fidelity with the interactive scan.+5. Cancel the settings dialog and an active PhotoCraft acquisition job. Confirm the job+ ends, the helper exits, no document is added and an immediate retry can scan.+6. Power off or disconnect the scanner; test enumeration and acquisition with the earlier ID.+ Keep an existing document open and verify it survives each error.+7. Request a resolution the driver rejects, then retry a supported resolution. Test busy-device+ handling with other scanner software using the device.+8. Check supported higher resolutions, film/slide modes and any driver-supplied 16-bit image or+ embedded ICC profile only after the basic platen workflow passes.++Validation status, 2026-10-12: connected Epson Perfection V500 discovery and a physical 300 dpi+colour scan succeeded through the current Windows scanner API: 2550 × 3510 pixels, 24-bit BMP,+26,858,574 bytes. The release desktop app completed acquisition and opened the scan as a new+unsaved document. The selected-area configuration also passed a no-transfer driver check after+avoiding redundant Epson property writes. The user also confirmed the rebuilt menu/settings+workflow physically scanned and opened its image: 850 × 1170 pixels at 100 dpi, RGB, 8-bit per+channel; the desktop job completed successfully with no import warnings. Scanner regression+tests (11), UI scanner tests (5), engine tests, 124 automation tests, formatting, layering and+wasm checks passed. Strict Clippy found existing `collapsible_match` warnings in untouched code;+the focused check passed with that lint excluded. The broader adversarial command test timed out+on printing; a parallel crop GPU test crashed and passed when retried serially. Native dialog+settings passed the menu test. Cancellation during physical transfer, retry and film/slide capabilities still need+hardware validation. Feeder/TWAIN acquisition is not implemented.++Upstream integration validation (main `d42f9da94`): all 19 focused scanner/UI/automation tests+passed, including profile-prompt admission and keeping a scan unsaved after the prompt. Full+engine (2,257) and automation (89) library tests passed. UI library tests passed 4,069 tests;+26 failed because the local font setup lacks CJK/Arabic glyphs, and 18 were ignored. Formatting,+layering, wasm and the adversarial command check passed. Strict Clippy found pre-existing+nonminimal-boolean, manual-range and collapsible-match findings in untouched code. The physical+transfer helper is unchanged from the V500-tested portable build; the upstream integration adds+stable document identity and the current queued colour-profile prompt flow.\ No newline at end of file