Ben, 2026-08-28: four changes, then build it, sign it, and make it deployable
through Intune without alerts.
1. The row of icons is a fifth shorter. Every toolbar label mode comes down
together so the proportions between them are unchanged: 34 -> 27 for a
caption beside the icon, 52/56 -> 42/45 for a caption under it, with the
padding reduced to match. The row measures 48px on screen where it was 60.
2. Scan sits in the main bar. It is a solid white button on the band, to the
left of the window buttons, so a person can pull pages off a copier whatever
the toolbar is showing. Behind it, Services/ScanService.cs talks to the
copier through WIA - the imaging service that ships with Windows, reached
late-bound so a machine without it fails at a check instead of at load - and
Features/Scan/ScanDialog picks the copier, the feed, the colour, the detail
and the paper. Pages come back as images and take the same route into a
document as an imported photograph. "Make the words searchable" runs the
text recognition the application already carries.
3. The official MMD lockup, in white, because the band is red now.
4. The MMD design system locked on 2026-08-28: ONE merged top bar carrying the
mark, the name, the tabs and the tools; 4px corners; 24px window buttons;
the bevel on title bars only. Colours are NOT adjusted - the Red Plate
silver and ink set already in Themes/MMD.xaml is untouched.
Also fixed, both found by looking at the running application at 800x600:
- The default 1000x700 window does not fit an 800x600 plant screen, and a
window taller than the screen puts its own title bar - and therefore Scan,
the window buttons and the tabs - above the top edge where nobody can reach
them. MainWindow now starts maximised when the screen cannot hold the
default.
- The close button was invisible: CaptionCloseBrush defaults to the danger
red, which on a red band is red on red. The window buttons are white now,
and close inverts to a white plate with a red cross on hover.
Built and run on mmd-win-test-01 at 800x600 through keyboard and mouse only.
Signed with the internal MMD code-signing certificate and timestamped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
374 lines
14 KiB
C#
374 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
|
|
namespace MmdPdf.Services
|
|
{
|
|
/// <summary>Which side of the paper the copier reads, and from where.</summary>
|
|
internal enum ScanFeed
|
|
{
|
|
Flatbed,
|
|
FeederOneSide,
|
|
FeederBothSides,
|
|
}
|
|
|
|
/// <summary>How much ink the copier records.</summary>
|
|
internal enum ScanColour
|
|
{
|
|
BlackAndWhite,
|
|
Greyscale,
|
|
Colour,
|
|
}
|
|
|
|
/// <summary>One copier the machine can reach.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="IsAvailable"/> 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.
|
|
/// </summary>
|
|
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<string, (double W, double H)> PaperInches =
|
|
new Dictionary<string, (double, double)>(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<string> PaperSizes => PaperInches.Keys;
|
|
|
|
/// <summary>True when this machine has the Windows imaging service at all.</summary>
|
|
internal static bool IsAvailable => Type.GetTypeFromProgID("WIA.DeviceManager") != null;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal static List<ScannerInfo> ListScanners()
|
|
{
|
|
var found = new List<ScannerInfo>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="onPage">Called with the running page count as each sheet lands.</param>
|
|
internal static List<string> Acquire(
|
|
string deviceId,
|
|
ScanFeed feed,
|
|
ScanColour colour,
|
|
int dpi,
|
|
string paper,
|
|
int maxPages,
|
|
CancellationToken cancel,
|
|
Action<int>? 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<string>();
|
|
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; }
|
|
}
|
|
|
|
/// <summary>The copier saying "the tray is empty", which ends a run rather than failing it.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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) { }
|
|
}
|
|
}
|
|
}
|