公司承接了一个项目,要做集中打印服务和后期处理流程,现在正在结构分析和技术验证。

项目需求是:客户端通过OA系统上传打印请求到服务器,服务器提交PDF文档打印,打印完成后做后期处理。。。项目的后期处理部分要做什么不是本文涉及的内容,这里仅涉及打印。

首先是系统选型,选择范围是android、linux和windows:

  1.  android,似乎默认不支持打印机,搞起来很麻烦,放弃;
  2.  linux,有打印机驱动,开发语言可选java或C#,不过linux开发没涉及过,估计坑儿不少,搁置;
  3.  windows,有打印机驱动,开发语言用C#,系统支持PrintService,似乎还可以,就是它了。

好久没做C#开发了,语法都忘了,有点郁闷。

先上网搜索了很多关于C#打印的文章和代码,初步确定了开发方案:

  1. 开发一个SDK,就是一个C#类库;
  2. 使用Spire.Pdf载入Pdf文件,设置打印参数,并启动打印,Spire.Pdf的官网下载:https://www.e-iceblue.com/Download/download-pdf-for-net-now.html
  3. 使用Windows打印服务监测打印进度。

第一步,启动打印

导入Spire.Pdf,设置参数,开始打印。所有步骤都很顺利,测试一次通过。以下是核心代码:

public class PrintOptions
        {
            /// <summary>
            /// 指定打印机,缺省使用系统默认打印机
            /// </summary>
            public string PrinterName { get; set; }

            /// <summary>
            /// 指定纸类型,缺省使用打印机默认纸类型
            /// </summary>
            public string PaperName { get; set; } 

            /// <summary>
            /// 是否双面打印,缺省单面打印
            /// </summary>
            public bool Duplex { get; set; }

            /// <summary>
            /// 是否横向打印,缺省竖向打印
            /// </summary>
            public bool Landscape { get; set; }

            /// <summary>
            /// 回调程序异常
            /// </summary>
            public Action<Exception> OnException;

            /// <summary>
            /// 回调打印错误,当回调参数id<0时意思是打印任务被放弃
            /// </summary>
            public Action<int, PrintJobStatus> OnError;

            /// <summary>
            /// 回调处理进度和打印进度,具体是哪种进度取决于最后一次<see cref="OnChanged"/>的status参数
            /// </summary>
            public Action<int, int> OnProgress;

            /// <summary>
            /// 回调状态改变,正在处理,正在打印,打印完成,无纸,错误。。。
            /// </summary>
            public Action<int, PrintJobStatus> OnChanged;
        }

        private void PrintInternal(string pdfFilename, PrintOptions options)
        {
            using (var doc = new PdfDocument())
            {
                doc.LoadFromFile(pdfFilename);

                // 不弹出打印对话框
                doc.PrintSettings.PrintController = new StandardPrintController();

                if (!string.IsNullOrEmpty(options.PrinterName))
                {
                    doc.PrintSettings.PrinterName = options.PrinterName;
                }
                if (!string.IsNullOrEmpty(options.PaperName))
                {
                    var ps = GetPaperSize(options.PaperName);
                    if (ps != null) doc.PrintSettings.PaperSize = ps;
                }
                if (options.Duplex && doc.PrintSettings.CanDuplex)
                {
                    doc.PrintSettings.Duplex = Duplex.Horizontal;
                }
                if (options.Landscape)
                {
                    doc.PrintSettings.Landscape = options.Landscape;
                }
                doc.Print();
            }
        }

        private PaperSize GetPaperSize(string paperName)
        {
            using (var doc = new PrintDocument())
            {
                foreach (PaperSize ps in doc.PrinterSettings.PaperSizes)
                {
                    if (ps.PaperName.Equals(paperName, StringComparison.OrdinalIgnoreCase)) return ps;
                }
            }
            return null;
        }

代码说明:

这段代码的核心是PrintInternal,它实现的功能无非就是调用Spire.PDF,设置参数,开始打印。

备注:PrintOptions的OnException,OnError,OnProgress和OnChanged是回调接口,我不喜欢delegate+event的方法定义回调(要写太多代码),还是Action简单。

第二步,监测打印进度。

这才遇到了麻烦。

一开始我选择了System.Printing.PrintQueue来做,心想这毕竟是人家官方实现或包装的完善方案。然而,不幸的事还是发生了(或者不能说不幸仅仅是一点点小麻烦)。

第一个麻烦是,Spire.Pdf的打印似乎是同步的,在启动Print()方法后,我在Windows系统的"设备与打印机"里的打印任务监测窗口中能立刻看到打印任务,但是直到打印完成Print()方法才完成,我的程序才能继续执行。就是说直到打印完成我才能继续检测打印进度,这可能是由于我测试代码使用的是虚拟打印机,打印速度跟打印队列处理速度一样快。我的解决办法是:在开始打印前,启动一个子线程并在其中检测打印进度。

第二个麻烦是,Spire.Pdf启动的打印任务,在System.Printing.PrintQueue里生成的PrintSystemJobInfo对象,启动的大部分方法和属性,都要求在与Print()方法运行在同一个线程了,否则(至少80%可能,还不是100%)会报异常:调用线程无法访问此对象,因为另一个线程拥有该对象。我的解决办法是:使用WMI代替PrintQueue和PrintSystemJobInfo。
    
首先通过万能的百度,查询到System.Printing.PrintQueue和PrintSystemJobInfo貌似是Win32_PrintJob的包装,所以我就用WMI模仿并实现了PrintSystemJobInfo的大部分功能,这样整个开发过程就能重新回到MSDN的轨道上来。(对于我这样的C#程序员,MSDN及其延伸资料还是非常样有用的)

以下是我用WMI实现的代替PrintSystemJobInfo的数据对象:

public class PrintJobInfo
        {
            public string Document { get; set; }

            public uint JobId { get; set; }

            public string JobStatus { get; set; } // e.g. 正在后台打印 | 正在打印

            public uint PagesPrinted { get; set; }

            public string PaperSize { get; set; } // e.g. A4 210 x 297 mm

            public uint Priority { get; set; } // e.g.1

            public uint TotalPages { get; set; }

            public string TimeSubmitted { get; set; }

            public string Status { get; set; }

            public uint StatusMask { get; set; }

            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsInError"/>
            /// </summary>
            public bool IsInError { get { return (StatusMask & (int)PrintJobStatus.Error) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsPrinted"/>
            /// </summary>
            public bool IsPrinted { get { return (StatusMask & (int)PrintJobStatus.Printed) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsPaused"/>
            /// </summary>
            public bool IsPaused { get { return (StatusMask & (int)PrintJobStatus.Paused) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsDeleting"/>
            /// </summary>
            public bool IsDeleting { get { return (StatusMask & (int)PrintJobStatus.Deleting) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsCompleted"/>
            /// </summary>
            public bool IsCompleted { get { return (StatusMask & (int)PrintJobStatus.Completed) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsRestarted"/>
            /// </summary>
            public bool IsRestarted { get { return (StatusMask & (int)PrintJobStatus.Restarted) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsSpooling"/>
            /// </summary>
            public bool IsSpooling { get { return (StatusMask & (int)PrintJobStatus.Spooling) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsPrinting"/>
            /// </summary>
            public bool IsPrinting { get { return (StatusMask & (int)PrintJobStatus.Printing) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsRetained"/>
            /// </summary>
            public bool IsRetained { get { return (StatusMask & (int)PrintJobStatus.Retained) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsBlocked"/>
            /// </summary>
            public bool IsBlocked { get { return (StatusMask & (int)PrintJobStatus.Blocked) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsDeleted"/>
            /// </summary>
            public bool IsDeleted { get { return (StatusMask & (int)PrintJobStatus.Deleted) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsPaperOut"/>
            /// </summary>
            public bool IsPaperOut { get { return (StatusMask & (int)PrintJobStatus.PaperOut) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsOffline"/>
            /// </summary>
            public bool IsOffline { get { return (StatusMask & (int)PrintJobStatus.Offline) > 0; } }
            /// <summary>
            /// <seealso cref="PrintSystemJobInfo.IsUserInterventionRequired"/>
            /// </summary>
            public bool IsUserInterventionRequired { get { return (StatusMask & (int)PrintJobStatus.UserIntervention) > 0; } }

            public void Cancel()
            {
                var query = "SELECT * FROM Win32_PrintJob WHERE JobId=" + JobId;
                using (var searcher = new ManagementObjectSearcher(query))
                {
                    var enumerator = searcher.Get().GetEnumerator();
                    if (enumerator.MoveNext())
                    {
                        ((ManagementObject)enumerator.Current).Delete();
                    }
                }
            }


            public void Refresh()
            {
                var newJob = Get((int)JobId);
                if (newJob != null)
                {
                    JobStatus = newJob.JobStatus;
                    TotalPages = newJob.TotalPages;
                    Status = newJob.Status;
                    StatusMask = newJob.StatusMask;
                } else
                {
                    JobStatus = "";
                    TotalPages = 0;
                    Status = "";
                    StatusMask = 0;
                }
            }

            public override string ToString()
            {
                return $"JobId: {JobId}, Document:{Document}, Status:{Status}, JobStatus:{JobStatus}, Pages: {TotalPages}";
            }

            /// <summary>
            /// 获取特定的任务信息
            /// </summary>
            /// <param name="jobId"></param>
            /// <returns></returns>
            public static PrintJobInfo Get(int jobId)
            {
                var query = "SELECT * FROM Win32_PrintJob WHERE JobId=" + jobId;
                using (var searcher = new ManagementObjectSearcher(query))
                {
                    var collection = searcher.Get();
                    if (collection.Count > 0)
                    {
                        foreach (ManagementObject item in collection)
                        {
                            return Parse(item);
                        }
                    }
                }
                return null;
            }

            /// <summary>
            /// 获取全部任务信息
            /// </summary>
            /// <returns></returns>
            public static IEnumerable<PrintJobInfo> GetAll()
            {
                var result = new List<PrintJobInfo>();
                var query = "SELECT * FROM Win32_PrintJob";
                using (var searcher = new ManagementObjectSearcher(query))
                {
                    var collection = searcher.Get();
                    if (collection.Count > 0)
                    {
                        foreach (ManagementObject item in collection)
                        {
                            result.Add(Parse(item));
                        }
                    }
                }
                return result;
            }

            /// <summary>
            /// 获取最新的任务信息,新任务的JobId > lastJobId <br/>
            /// 如果lastJobId为null,那么取第一个任务信息
            /// </summary>
            /// <param name="lastJobId"></param>
            /// <returns></returns>
            public static PrintJobInfo GetNext(uint? lastJobId)
            {
                var query = "SELECT * FROM Win32_PrintJob";
                if (lastJobId != null) query += " WHERE JobId > " + lastJobId;
                using (var searcher = new ManagementObjectSearcher(query))
                {
                    var collection = searcher.Get();
                    if (collection.Count > 0)
                    {
                        foreach (ManagementObject item in collection)
                        {
                            return Parse(item);
                        }
                    }
                }
                return null;
            }

            private static PrintJobInfo Parse(ManagementObject data)
            {
                var result = new PrintJobInfo
                {
                    Document = (string)data.Properties["Document"].Value,
                    JobId = (uint)data.Properties["JobId"].Value,
                    JobStatus = (string)data.Properties["JobStatus"].Value,
                    PagesPrinted = (uint)data.Properties["PagesPrinted"].Value,
                    PaperSize = (string)data.Properties["PaperSize"].Value,
                    Priority = (uint)data.Properties["Priority"].Value,
                    TotalPages = (uint)data.Properties["TotalPages"].Value,
                    TimeSubmitted = (string)data.Properties["TimeSubmitted"].Value,
                    Status = (string)data.Properties["Status"].Value,
                    StatusMask = (uint)data.Properties["StatusMask"].Value
                };
                return result;
            }
        }

代码说明:

这个PrintJobInfo对象的成员只要分为5部分:

  1. WMI成员部分,命名同WMI中的属性名称完全相同,仅保留了经测试有效果的属性;
  2. 模仿PrintSystemJobInfo中有关打印状态的全部属性;
  3. 打印过程干涉方法,仅实现了Refresh和Cancel,其他的比如Pause、Resume、Restart等方法在我的项目里没用,所以就不麻烦了。
  4. 打印任务查询方法,这跟PrintSystemJobInfo不一样,这里实现了 Get、GetAll、GetNext三个方法来从打印队列里搜索打印任务。
  5. 剩下的Parse方法就是解析WMI数据为PrintJobInfo对象,ToString方法仅用于调试。

我使用虚拟打印机Microsoft Print to PDF测试,能正确并及时的监测到Windows打印池的任务进度。

我的测试代码:

PDFPrinter printer = new PDFPrinter();
            var fileName = "D:\\test.pdf";
            printer.Print(fileName, new PDFPrinter.PrintOptions()
            {
                PrinterName = "Microsoft Print to PDF",
                OnChanged = (id, status) =>
                  {
                      Console.WriteLine($"id:{id}, status: {status}");
                  },
                OnError = (id, status) =>
                {
                    Console.WriteLine($"id:{id}, status: {status}");
                },
                OnProgress = (id, pageNumber) =>
                {
                    Console.WriteLine($"id:{id}, pageNumber: {pageNumber}");
                },
                OnException = (ex) =>
                  {
                      Console.WriteLine($"Exception: {ex.Message}");
                  }
            });

            Console.WriteLine("Press any key to exit...");
            Console.ReadKey();

输出结果:

id:50, status: Spooling
id:50, pageNumber: 1
id:50, pageNumber: 2
id:50, pageNumber: 3
id:50, pageNumber: 4
id:50, pageNumber: 5
id:50, pageNumber: 6
id:50, pageNumber: 7
id:50, pageNumber: 8
id:50, pageNumber: 9
Press any key to exit...
id:50, status: Printing
id:50, status: Completed

在测试中只能见到Spooling、Printing和Completed这三个状态,并能实时得到Spooling的进度,这个进度数值同Windows的设备与打印机里的打印任务监视窗口中完全一致。

以上是全部的内容。文中包含了全部的代码,就不另外添加附件了。