// =====================================================================
// RETAIL PHARMACY PLATFORM — Counter Helper
// RawPrinter.cs : direct-to-spooler printing
//
// This class is the reason the slip is instant.
//
// The normal .NET printing path (System.Drawing.Printing, PrintDocument)
// renders a page through GDI and hands a bitmap to the driver. On an
// Epson LX-300 that turns a 300-byte text slip into a multi-megabyte
// graphics job and takes five to ten seconds. Chemists describe that as
// "the new software is slow" and they are right.
//
// Here we open the printer, declare the datatype as "RAW", and write our
// ESC/P or ESC/POS bytes straight to the spooler. No rendering, no
// driver dialog, no page setup. The printer's own firmware does the
// work, exactly as it does for the incumbent.
//
// There is no managed API for this. Win32 winspool is the only route.
// =====================================================================
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO; // IOException — a short spooler write
using System.Runtime.InteropServices;
namespace CounterHelper;
public static class RawPrinter
{
// ---- structures -------------------------------------------------
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private class DOCINFOW
{
[MarshalAs(UnmanagedType.LPWStr)] public string? pDocName;
[MarshalAs(UnmanagedType.LPWStr)] public string? pOutputFile;
// "RAW" is the whole point of this file. Anything else and the
// spooler will try to interpret the bytes.
[MarshalAs(UnmanagedType.LPWStr)] public string? pDataType;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct PRINTER_INFO_4
{
[MarshalAs(UnmanagedType.LPWStr)] public string pPrinterName;
[MarshalAs(UnmanagedType.LPWStr)] public string pServerName;
public uint Attributes;
}
// ---- imports ----------------------------------------------------
[DllImport("winspool.drv", EntryPoint = "OpenPrinterW", SetLastError = true,
CharSet = CharSet.Unicode, ExactSpelling = true)]
private static extern bool OpenPrinter(string pPrinterName, out IntPtr phPrinter, IntPtr pDefault);
[DllImport("winspool.drv", EntryPoint = "ClosePrinter", SetLastError = true, ExactSpelling = true)]
private static extern bool ClosePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", EntryPoint = "StartDocPrinterW", SetLastError = true,
CharSet = CharSet.Unicode, ExactSpelling = true)]
private static extern int StartDocPrinter(IntPtr hPrinter, int level,
[In, MarshalAs(UnmanagedType.LPStruct)] DOCINFOW di);
[DllImport("winspool.drv", EntryPoint = "EndDocPrinter", SetLastError = true, ExactSpelling = true)]
private static extern bool EndDocPrinter(IntPtr hPrinter);
[DllImport("winspool.drv", EntryPoint = "StartPagePrinter", SetLastError = true, ExactSpelling = true)]
private static extern bool StartPagePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", EntryPoint = "EndPagePrinter", SetLastError = true, ExactSpelling = true)]
private static extern bool EndPagePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", EntryPoint = "WritePrinter", SetLastError = true, ExactSpelling = true)]
private static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten);
[DllImport("winspool.drv", EntryPoint = "EnumPrintersW", SetLastError = true,
CharSet = CharSet.Unicode)]
private static extern bool EnumPrinters(uint Flags, string? Name, uint Level, IntPtr pPrinterEnum,
uint cbBuf, out uint pcbNeeded, out uint pcReturned);
private const uint PRINTER_ENUM_LOCAL = 0x00000002;
private const uint PRINTER_ENUM_CONNECTIONS = 0x00000004;
// ---- api --------------------------------------------------------
///
/// Send raw bytes to a printer queue. Returns bytes written.
/// Throws Win32Exception on any spooler failure so the caller can
/// spool and retry rather than losing the job.
///
public static int Send(string printerName, byte[] data, string docName = "Counter slip")
{
if (string.IsNullOrWhiteSpace(printerName))
throw new ArgumentException("printer name is required", nameof(printerName));
if (data is null || data.Length == 0)
throw new ArgumentException("nothing to print", nameof(data));
if (!OpenPrinter(printerName, out IntPtr hPrinter, IntPtr.Zero))
throw new Win32Exception(Marshal.GetLastWin32Error(),
$"OpenPrinter failed for '{printerName}'");
IntPtr buf = IntPtr.Zero;
bool docStarted = false, pageStarted = false;
try
{
var di = new DOCINFOW
{
pDocName = docName,
pOutputFile = null,
pDataType = "RAW"
};
if (StartDocPrinter(hPrinter, 1, di) == 0)
throw new Win32Exception(Marshal.GetLastWin32Error(), "StartDocPrinter failed");
docStarted = true;
if (!StartPagePrinter(hPrinter))
throw new Win32Exception(Marshal.GetLastWin32Error(), "StartPagePrinter failed");
pageStarted = true;
buf = Marshal.AllocHGlobal(data.Length);
Marshal.Copy(data, 0, buf, data.Length);
if (!WritePrinter(hPrinter, buf, data.Length, out int written))
throw new Win32Exception(Marshal.GetLastWin32Error(), "WritePrinter failed");
// A short write means the spooler accepted part of the job.
// Treat it as a failure: half a bill is worse than none.
if (written != data.Length)
throw new IOException($"short write to spooler: {written} of {data.Length} bytes");
return written;
}
finally
{
// Unwind in the reverse order the spooler expects. Skipping
// this on the error path leaves an orphaned job that blocks
// the queue until someone restarts the spooler service.
if (buf != IntPtr.Zero) Marshal.FreeHGlobal(buf);
if (pageStarted) EndPagePrinter(hPrinter);
if (docStarted) EndDocPrinter(hPrinter);
ClosePrinter(hPrinter);
}
}
/// Installed printer queues, local and connected.
public static List List()
{
var found = new List();
const uint flags = PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS;
const uint level = 4; // PRINTER_INFO_4 — name only, and fast
EnumPrinters(flags, null, level, IntPtr.Zero, 0, out uint needed, out _);
if (needed == 0) return found;
IntPtr buffer = Marshal.AllocHGlobal((int)needed);
try
{
if (!EnumPrinters(flags, null, level, buffer, needed, out _, out uint returned))
throw new Win32Exception(Marshal.GetLastWin32Error(), "EnumPrinters failed");
int size = Marshal.SizeOf();
for (int i = 0; i < returned; i++)
{
var info = Marshal.PtrToStructure(buffer + i * size);
if (!string.IsNullOrWhiteSpace(info.pPrinterName))
found.Add(info.pPrinterName);
}
}
finally { Marshal.FreeHGlobal(buffer); }
return found;
}
// ---- built-in test pattern --------------------------------------
///
/// Alignment pattern for the setup wizard. Deliberately prints a
/// column ruler: the commonest install fault is a template whose
/// width does not match the carriage, and this makes that visible
/// in one sheet instead of one confused phone call.
///
public static byte[] TestPattern(string deviceType, int columns)
{
var sb = new System.Text.StringBuilder();
bool thermal = deviceType.Equals("THERMAL", StringComparison.OrdinalIgnoreCase);
var init = thermal
? new byte[] { 0x1B, 0x40, 0x1B, 0x74, 0x00, 0x1B, 0x21, 0x01 } // reset, CP437, small font
: new byte[] { 0x1B, 0x40, 0x1B, 0x33, 0x18, 0x0F }; // reset, 24/216" spacing, condensed
string nl = thermal ? "\n" : "\r\n";
sb.Append("COUNTER HELPER - PRINT TEST").Append(nl);
sb.Append(new string('=', columns)).Append(nl);
sb.Append($"Device : {deviceType}").Append(nl);
sb.Append($"Columns: {columns}").Append(nl);
sb.Append($"Time : {DateTime.Now:dd/MM/yyyy HH:mm:ss}").Append(nl);
sb.Append(new string('-', columns)).Append(nl);
// Column ruler: tens markers above, units below.
var tens = new System.Text.StringBuilder();
var ones = new System.Text.StringBuilder();
for (int i = 1; i <= columns; i++)
{
tens.Append(i % 10 == 0 ? (char)('0' + (i / 10) % 10) : ' ');
ones.Append((char)('0' + i % 10));
}
sb.Append(tens).Append(nl).Append(ones).Append(nl);
sb.Append(new string('-', columns)).Append(nl);
sb.Append("If the ruler above wraps onto a second line, the").Append(nl);
sb.Append("template is too wide for this printer. Choose a").Append(nl);
sb.Append("narrower profile in Settings.").Append(nl);
sb.Append(new string('=', columns)).Append(nl);
var body = System.Text.Encoding.ASCII.GetBytes(sb.ToString());
var tail = thermal
? new byte[] { 0x1B, 0x64, 0x03, 0x1D, 0x56, 0x42, 0x00 } // feed 3, partial cut
: new byte[] { 0x1B, 0x40, 0x0C }; // reset, form feed
var outBytes = new byte[init.Length + body.Length + tail.Length];
Buffer.BlockCopy(init, 0, outBytes, 0, init.Length);
Buffer.BlockCopy(body, 0, outBytes, init.Length, body.Length);
Buffer.BlockCopy(tail, 0, outBytes, init.Length + body.Length, tail.Length);
return outBytes;
}
/// Cash drawer kick. Identical on ESC/P and ESC/POS.
public static byte[] DrawerKick() => new byte[] { 0x1B, 0x70, 0x00, 0x19, 0xFA };
}