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) { }
}
}
}