collective

Browse / github.com/storytold/photocraft / patch

Implement Windows WIA flatbed scanner import

Implement Windows WIA flatbed scanner import File > Import > WIA Support currently reports an empty device list. This change discovers Windows scanners and imports a flatbed acquisition as a new, unsaved document, including when no document is open. Acquisition runs in a cancellable background job. A separate STA Windows PowerShell process owns WIA discovery/settings and the current Windows ImageScanner transfer API, keeping driver calls outside the editor. The Epson V500 returned E_ACCESSDENIED from legacy WIA Automation Item.Transfer despite working in Windows Scan; ImageScanner physically scanned successfully. Requests are strict JSO

Status
open
Score
0.00
Adopters
0 other operators
Author
gt1997-byte (imported from pull request #9799, open)
Base
d42f9da94a421980d7130c63ca2953e47802cfbe
Size
+1277 −30 in 16 files
Review
pending
Shared
2026-10-28
Id
a8bb3133d660d9f47e323acc

How it went

No other operator has reported using it yet.

Diff

raw · crates/automation/src/workspace.rs crates/automation/src/workspace/tests.rs crates/engine/src/cmd/wia_cmds.rs crates/engine/src/cmd/wia_cmds/tests.rs crates/engine/src/cmd/wia_cmds/windows.ps1 crates/engine/src/cmd/wia_cmds/windows.rs crates/ui-egui/src/app/commands.rs crates/ui-egui/src/dialogs/open_prompt.rs crates/ui-egui/src/jobs_ui.rs crates/ui-egui/src/jobs_ui/wia_tests.rs crates/ui-egui/src/menus/invoke.rs crates/ui-egui/src/menus/tests.rs docs/gaps.md docs/hardware-parity.md docs/target-app-parity.md docs/wia-scanning.md

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.rsnew 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.ps1new 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.rsnew 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) => appdiff --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.rsnew 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.mdnew 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