using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Runtime.InteropServices; using System.Threading; namespace MmdPdf.Services { /// Which side of the paper the copier reads, and from where. internal enum ScanFeed { Flatbed, FeederOneSide, FeederBothSides, } /// How much ink the copier records. internal enum ScanColour { BlackAndWhite, Greyscale, Colour, } /// One copier the machine can reach. internal sealed class ScannerInfo { internal ScannerInfo(string deviceId, string name) { DeviceId = deviceId; Name = name; } internal string DeviceId { get; } internal string Name { get; } public override string ToString() => Name; } /// /// Pulls pages off a network copier or a desk scanner through WIA, the imaging service that /// ships with Windows. Nothing is installed for this: the COM objects are created late-bound /// by ProgID, so a machine with no imaging stack fails at instead of /// at load time, and the application still starts. /// /// The page images land in a folder of their own under the user's temp directory and are /// handed back as file paths; turning them into a document is PdfImport's job, the same way /// an imported photograph is handled. /// internal static class ScanService { // WIA item property ids. These are the documented constants; the COM interface takes // them as integers, so they are written here once rather than repeated as magic numbers. private const int WIA_IPS_CUR_INTENT = 6146; private const int WIA_IPS_XRES = 6147; private const int WIA_IPS_YRES = 6148; private const int WIA_IPS_XPOS = 6149; private const int WIA_IPS_YPOS = 6150; private const int WIA_IPS_XEXTENT = 6151; private const int WIA_IPS_YEXTENT = 6152; private const int WIA_IPA_DATATYPE = 4103; private const int WIA_IPA_DEPTH = 4104; // Device properties that drive the sheet feeder. private const int WIA_DPS_DOCUMENT_HANDLING_SELECT = 3088; private const int WIA_DPS_DOCUMENT_HANDLING_STATUS = 3087; private const int WIA_DPS_PAGES = 3096; private const int FEEDER = 0x001; private const int FLATBED = 0x002; private const int DUPLEX = 0x004; private const int FEED_READY = 0x001; // Intent values: what the copier should optimise for. private const int INTENT_COLOUR = 0x00000001; private const int INTENT_GREYSCALE = 0x00000002; private const int INTENT_TEXT = 0x00000004; private const string FORMAT_PNG = "{B96B3CAF-0728-11D3-9D7B-0000F81EF32E}"; // Paper the copier is asked for, in inches. A4 is the MMD standard; Letter is here // because the Dubai office buys it locally. private static readonly Dictionary PaperInches = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["A4 portrait"] = (8.27, 11.69), ["A4 landscape"] = (11.69, 8.27), ["A5 portrait"] = (5.83, 8.27), ["Letter portrait"] = (8.5, 11.0), ["Legal portrait"] = (8.5, 14.0), }; internal static IEnumerable PaperSizes => PaperInches.Keys; /// True when this machine has the Windows imaging service at all. internal static bool IsAvailable => Type.GetTypeFromProgID("WIA.DeviceManager") != null; /// /// Every copier and scanner the machine can currently reach. An empty list is a normal /// answer - it means nothing is switched on - and never an exception the caller has to /// catch. /// internal static List ListScanners() { var found = new List(); var progId = Type.GetTypeFromProgID("WIA.DeviceManager"); if (progId == null) return found; object? manager = null; try { manager = Activator.CreateInstance(progId); dynamic dm = manager!; dynamic infos = dm.DeviceInfos; int count = (int)infos.Count; for (int i = 1; i <= count; i++) // WIA collections are 1-based { dynamic info = infos[i]; try { // 1 == ScannerDeviceType. Cameras and video devices are skipped. if ((int)info.Type != 1) continue; string name = ReadProperty(info.Properties, "Name") ?? "Copier"; found.Add(new ScannerInfo((string)info.DeviceID, name)); } finally { Release(info); } } } catch (COMException) { /* imaging service not running: nothing to offer */ } catch (InvalidCastException) { } finally { Release(manager); } return found; } /// /// Reads pages and writes each one as a PNG into its own folder. Returns the files in /// page order. Cancelling stops after the page in progress - a copier cannot be /// interrupted mid-sheet. /// /// Called with the running page count as each sheet lands. internal static List Acquire( string deviceId, ScanFeed feed, ScanColour colour, int dpi, string paper, int maxPages, CancellationToken cancel, Action? onPage = null) { if (string.IsNullOrWhiteSpace(deviceId)) throw new ArgumentException("No copier chosen.", nameof(deviceId)); if (dpi < 75 || dpi > 1200) throw new ArgumentOutOfRangeException(nameof(dpi)); var progId = Type.GetTypeFromProgID("WIA.DeviceManager") ?? throw new InvalidOperationException("This machine has no imaging service."); string folder = Path.Combine(Path.GetTempPath(), "MmdPdf.Scan." + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(folder); var pages = new List(); object? manager = null; object? device = null; try { manager = Activator.CreateInstance(progId); dynamic dm = manager!; dynamic infos = dm.DeviceInfos; dynamic? chosen = null; int count = (int)infos.Count; for (int i = 1; i <= count; i++) { dynamic info = infos[i]; if (string.Equals((string)info.DeviceID, deviceId, StringComparison.Ordinal)) { chosen = info; break; } Release(info); } if (chosen == null) throw new InvalidOperationException("That copier is no longer reachable."); device = chosen.Connect(); dynamic dev = device!; ConfigureFeeder(dev, feed, maxPages); dynamic item = dev.Items[1]; try { ConfigurePage(item, colour, dpi, paper); for (int page = 1; page <= maxPages; page++) { cancel.ThrowIfCancellationRequested(); string path = Path.Combine(folder, "page-" + page.ToString("000", CultureInfo.InvariantCulture) + ".png"); try { dynamic image = item.Transfer(FORMAT_PNG); try { image.SaveFile(path); pages.Add(path); onPage?.Invoke(pages.Count); } finally { Release(image); } } catch (COMException ex) when (IsOutOfPaper(ex)) { break; // the feeder emptied: a normal end, not a failure } if (feed == ScanFeed.Flatbed) break; // one sheet on the glass if (!FeederHasPaper(dev)) break; } } finally { Release(item); } } finally { Release(device); Release(manager); } if (pages.Count == 0) { try { Directory.Delete(folder, true); } catch (IOException) { } catch (UnauthorizedAccessException) { } } return pages; } private static void ConfigureFeeder(dynamic device, ScanFeed feed, int maxPages) { int handling = feed == ScanFeed.Flatbed ? FLATBED : FEEDER; if (feed == ScanFeed.FeederBothSides) handling |= DUPLEX; TrySetProperty(device.Properties, WIA_DPS_DOCUMENT_HANDLING_SELECT, handling); if (feed != ScanFeed.Flatbed) TrySetProperty(device.Properties, WIA_DPS_PAGES, maxPages); } private static void ConfigurePage(dynamic item, ScanColour colour, int dpi, string paper) { int intent = colour switch { ScanColour.Colour => INTENT_COLOUR, ScanColour.Greyscale => INTENT_GREYSCALE, _ => INTENT_TEXT, }; int depth = colour switch { ScanColour.Colour => 24, ScanColour.Greyscale => 8, _ => 1, }; int dataType = colour switch { ScanColour.Colour => 3, // WIA_DATA_COLOR ScanColour.Greyscale => 2, // WIA_DATA_GRAYSCALE _ => 0, // WIA_DATA_THRESHOLD }; TrySetProperty(item.Properties, WIA_IPS_CUR_INTENT, intent); TrySetProperty(item.Properties, WIA_IPA_DATATYPE, dataType); TrySetProperty(item.Properties, WIA_IPA_DEPTH, depth); TrySetProperty(item.Properties, WIA_IPS_XRES, dpi); TrySetProperty(item.Properties, WIA_IPS_YRES, dpi); var size = PaperInches.TryGetValue(paper ?? string.Empty, out var found) ? found : PaperInches["A4 portrait"]; TrySetProperty(item.Properties, WIA_IPS_XPOS, 0); TrySetProperty(item.Properties, WIA_IPS_YPOS, 0); TrySetProperty(item.Properties, WIA_IPS_XEXTENT, (int)Math.Round(size.W * dpi)); TrySetProperty(item.Properties, WIA_IPS_YEXTENT, (int)Math.Round(size.H * dpi)); } private static bool FeederHasPaper(dynamic device) { try { object? raw = ReadPropertyValue(device.Properties, WIA_DPS_DOCUMENT_HANDLING_STATUS); if (raw == null) return false; return ((int)Convert.ToInt32(raw, CultureInfo.InvariantCulture) & FEED_READY) == FEED_READY; } catch (COMException) { return false; } catch (InvalidCastException) { return false; } } /// The copier saying "the tray is empty", which ends a run rather than failing it. private static bool IsOutOfPaper(COMException ex) { const int WIA_ERROR_PAPER_EMPTY = unchecked((int)0x80210003); const int WIA_ERROR_PAPER_JAM = unchecked((int)0x80210002); return ex.ErrorCode == WIA_ERROR_PAPER_EMPTY || ex.ErrorCode == WIA_ERROR_PAPER_JAM; } private static string? ReadProperty(dynamic properties, string name) { try { int count = (int)properties.Count; for (int i = 1; i <= count; i++) { dynamic p = properties[i]; try { if (string.Equals((string)p.Name, name, StringComparison.OrdinalIgnoreCase)) return Convert.ToString(p.get_Value(), CultureInfo.InvariantCulture); } finally { Release(p); } } } catch (COMException) { } catch (InvalidCastException) { } return null; } private static object? ReadPropertyValue(dynamic properties, int propertyId) { try { int count = (int)properties.Count; for (int i = 1; i <= count; i++) { dynamic p = properties[i]; try { if ((int)p.PropertyID == propertyId) return p.get_Value(); } finally { Release(p); } } } catch (COMException) { } catch (InvalidCastException) { } return null; } /// /// Sets one property if the copier has it. Copiers differ wildly in what they expose, so a /// missing or read-only property is ignored rather than failing the run - the copier then /// uses its own default for that setting. /// private static void TrySetProperty(dynamic properties, int propertyId, int value) { try { int count = (int)properties.Count; for (int i = 1; i <= count; i++) { dynamic p = properties[i]; try { if ((int)p.PropertyID != propertyId) continue; if ((bool)p.IsReadOnly) return; p.set_Value(value); return; } finally { Release(p); } } } catch (COMException) { } catch (InvalidCastException) { } } private static void Release(object? comObject) { if (comObject == null) return; try { if (Marshal.IsComObject(comObject)) Marshal.ReleaseComObject(comObject); } catch (ArgumentException) { } catch (InvalidComObjectException) { } } } }