diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs
--- a/crates/automation/src/workspace.rs
+++ b/crates/automation/src/workspace.rs
@@ -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"
diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs
--- a/crates/automation/src/workspace/tests.rs
+++ b/crates/automation/src/workspace/tests.rs
@@ -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, &params).is_ok(), "{params}");
+        assert!(authorize_desktop_engine_command(command, &params).is_ok(), "{params}");
+        assert!(authorize_engine_step(command, &params).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());
+}
diff --git a/crates/engine/src/cmd/wia_cmds.rs b/crates/engine/src/cmd/wia_cmds.rs
--- a/crates/engine/src/cmd/wia_cmds.rs
+++ b/crates/engine/src/cmd/wia_cmds.rs
@@ -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;
diff --git a/crates/engine/src/cmd/wia_cmds/tests.rs b/crates/engine/src/cmd/wia_cmds/tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/cmd/wia_cmds/tests.rs
@@ -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());
+}
diff --git a/crates/engine/src/cmd/wia_cmds/windows.ps1 b/crates/engine/src/cmd/wia_cmds/windows.ps1
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/cmd/wia_cmds/windows.ps1
@@ -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
+}
diff --git a/crates/engine/src/cmd/wia_cmds/windows.rs b/crates/engine/src/cmd/wia_cmds/windows.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/cmd/wia_cmds/windows.rs
@@ -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());
+    }
+}
diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs
--- a/crates/ui-egui/src/app/commands.rs
+++ b/crates/ui-egui/src/app/commands.rs
@@ -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, &params)
         } 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).
diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs
--- a/crates/ui-egui/src/dialogs/open_prompt.rs
+++ b/crates/ui-egui/src/dialogs/open_prompt.rs
@@ -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
diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs
--- a/crates/ui-egui/src/jobs_ui.rs
+++ b/crates/ui-egui/src/jobs_ui.rs
@@ -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;
diff --git a/crates/ui-egui/src/jobs_ui/wia_tests.rs b/crates/ui-egui/src/jobs_ui/wia_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/jobs_ui/wia_tests.rs
@@ -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);
+}
diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs
--- a/crates/ui-egui/src/menus/invoke.rs
+++ b/crates/ui-egui/src/menus/invoke.rs
@@ -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).
diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs
--- a/crates/ui-egui/src/menus/tests.rs
+++ b/crates/ui-egui/src/menus/tests.rs
@@ -528,3 +528,22 @@ fn proof_setup_presets_and_checkmarks() {
     let params = crate::filter_dialog::parse_spec(spec.params);
     assert!(matches!(&params[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());
+}
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -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 |
diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md
--- a/docs/hardware-parity.md
+++ b/docs/hardware-parity.md
@@ -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) |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -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 |
diff --git a/docs/wia-scanning.md b/docs/wia-scanning.md
new file mode 100644
--- /dev/null
+++ b/docs/wia-scanning.md
@@ -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
