// ===================================================================== // 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 }; }