由網頁呼叫本機端列印方式


※本篇會參考使用到 將 RDLC 報表不需預覽直接列印(Print RDLC Report without Preview) 部分程式實作方式,重覆部分不再說明。

現在,很多系統都是開發成為 WEB 型態(也稱雲端平台之類...)。

因此,只需要 Browser (瀏覽器) 即可進行系統操作。雖然有很大的便利程度,但是現實中還是會面臨到一些問題,像是 I/O控制等。這是因為受限於 瀏覽器的安全性不得直接讓程式碼介入系統控制,以免被駭客入侵等....

但這也造就許多問題 ...... 撇開其它不談,就以我們常見的列印來說好了。

也許大家都知道,瀏覽器也是可以列印的啊,不管是直接列印,或是輸出成PDF列印等等。

但所謂不在其位不知其苦,部分行業或是以目前台灣推行的電子發票的列印格式(奇特格式),基本上瀏覽器列印就做不到了。

目前,有接觸過幾家廠商在做這類的系統,遇到電子發票列印實的做法怎麼處理呢?

當然回歸老路囉........ IE + ActiveX ....... 說真的,我有點語塞了。

怎麼有種回歸到 IE 綁架的時代的 Feel 呢?

IE only 都會遇到 Windows 重大改版後導致 IE不支援繼續更新會始操作版面出現怪異現象 ,然後 ActiveX 有無法移植到其它瀏覽器(Chrome/FireFox/Safari/Opera...)的問題存在。

當然不是沒有辦法解決,只是台灣可能很多軟體公司面臨賺錢問題,不太願意花太多時間/金錢做研究是擺明事實(IT行業是個很燒錢的行業,由其它的RD部門更是如此),既有技術能快速上手開發賺錢,憑甚麼不做?

有天,在研究 MIT 開發的 Android App 開發(MIT app inventor) 介面得到了很棒的靈感(外國都已經行之有年了)

技術上,大致就像一開始的圖一般:

那個齒輪狀的東西就是一個本地(使用者電腦上的)一個程式或是服務,它提供了基本的 HTTP 通訊能力用來接收指令,並控制印表機。

通訊圖


所以,我有一台伺服器 www.myweb.com.tw 提供了作業頁面
localprint-test.html


<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=big5" />
<title>呼叫本地列印服務</title>
<script type="text/javascript" src="jquery-1.7.2.min.js"></script>
<script type="text/javascript">
$(document).ready(function(){
$('#print').click(function(){
var dt=$.ajax({type:"GET",
  url:"http://localhost:1300/printdata?doc=accup03",
  async:false,
  complete: function(e, xhr, settings){
  if(e.status==200){
  alert("列印已送出");
  }
  else
  {
  alert("列印服務無回應!");
  }
}

  });
});
})
</script>
</head>

<body>
本機必須啟動 SimpleHttpService1<br />
該程式會在本機啟用 port 1300 http 服務<br /><br />
<div>
<form id="f1" name="f1" >
<input id="print" name="print" type="button" value="列印資料" />
</form>
</div>
</body>
</html>


這裡的 Html使用了 JQuery 套件來做 Ajax 呼叫。

而當使用者按下 button 時就會觸發發出 http://localhost:1300/printdata?doc=accup03 請求。

然後等待請求是否回覆正確/錯誤。

伺服器內還有另一個檔案 accup03.txt 的文字檔,是用來列印的資料。
(內容隨便,當然也可以是其它類型檔案,但處理方式就要修改)


接下來就是本機的小型 Http 服務了,這程式是寫成 console 模式執行的,當然也可以寫成 Service的

program.cs


using System;
using System.Net;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using System.IO;
using System.Net.Http;
using Telerik.Reporting;

namespace SimpleHttpService1
{
    class Program
    {
        //列印記數用的頁數紀錄
        static private int m_currentPageIndex;
        //列印用串流資料儲存區
        static private IList<Stream> m_streams;

        static void Main(string[] args)
        {
            HttpListener listener = null;
            try
            {
                // 建立 HttpListener 來監聽指定埠
                // 此案設定 port:1300 監聽 TCP 活動,並且有路徑具名 printdata/
                listener = new HttpListener();
                listener.Prefixes.Add("http://*:1300/printdata/");
                listener.Start();

                Console.WriteLine("WebServer Start Successed.......");
                while (true)
                {

                    Console.WriteLine("waiting ...");
                    // 阻塞模式等待請求,當有使用者呼叫才會繼續
                    HttpListenerContext context = listener.GetContext();

                    // 將請求放入處理過程線程池(Thread Pool)
                    // 此處以另一個 Thread 處理 Request Context
                    System.Threading.ThreadPool.QueueUserWorkItem(ProcessHttpClient, context);
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }
            Console.ReadKey();
        }
        /// <summary>
        /// 客户請求處理
        /// </summary>
        /// <param name="obj">傳遞的 context</param>

        static void ProcessHttpClient(object obj)
        {
            string msg;
            byte[] buffer = System.Text.Encoding.UTF8.GetBytes(@"列印完成");
            // 取得傳入的 Context
            HttpListenerContext context = obj as HttpListenerContext;
            // 設定回覆代碼 200 OK / 回覆編碼 Utf-8 / 回覆類型 text/html
            context.Response.StatusCode = (int)HttpStatusCode.OK;
            context.Response.ContentEncoding = Encoding.UTF8;
            context.Response.ContentType = "text/html";

            // 由瀏覽器於從原本伺服器host位置,發到本機來請求服務會被 Same origin policy 檔住而視為 Fail
            // 所以回覆時必須加上下面這行 Header 才不會被瀏覽器視作不合法的跨網域請求(CORS)
            context.Response.Headers.Add("Access-Control-Allow-Origin", "*");

            msg = System.Text.Encoding.Default.GetString(buffer);
            // 這段可以用來檢查路徑(目前沒有實做,只是拿來觀察)
            string urlpath = context.Request.Url.LocalPath;
            Console.WriteLine("localpath:" + urlpath);

            try
            {
                // 取得參數指定的文件名稱
                string qryKey = context.Request.QueryString["doc"];
                if (qryKey != null)
                {
                    Console.WriteLine("Target Document :" + qryKey);
                }
                // 去外部取得資料檔案 (等待回覆後才會繼續)
                Task<string> html = GetURL(qryKey);
                html.Wait();
                // 轉換 Html 為結構化資料 DataModel
                List<DataModel> data = new List<DataModel>();
                var lines = html.Result.Split(new char[] { '\r' });
                int ix = 0;
                foreach (string line in lines)
                {
                    string line0 = line.Trim();
                    if (!string.IsNullOrEmpty(line0))
                    {
                        DataModel d1 = new DataModel();
                        ix++;
                        d1.no = ix;
                        d1.line = line0;
                        data.Add(d1);
                    }
                }
                // 送出列印
                RdlcPrint(data);
            }
            catch (Exception e)
            {
                Console.WriteLine("列印失敗:" + e.Message + "[" + e.InnerException.ToString() + "]");
                msg = "Print document is abord or failure!";
                context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
            }
            // 送出回覆訊息 Response Output
            try
            {
                using (StreamWriter writer = new StreamWriter(context.Response.OutputStream))
                {
                    writer.Write(msg);
                    writer.Close();
                }
            }
            catch (Exception e)
            {
                Console.WriteLine("Response Error:" + e.Message);
            }
            context.Response.Close();
        }
        /// <summary>
        /// 取得遠端伺服器上的資料
        /// </summary>
        /// <param name="docName">指定文件名稱</param>
        /// <returns>html內容</returns>

        static async Task<string> GetURL(string docName)
        {
            HttpClient client = new HttpClient();
            string html;
            var response = await client.GetAsync($"http://www.myweb.com.tw/documents/{docName}.txt");
            // 如果 httpstatus code 不是 200 時會直接丟出 expection
            response.EnsureSuccessStatusCode();
            // 取得回傳內容
            html = await response.Content.ReadAsStringAsync();
            response.Dispose();
            client.Dispose();
         
            return html;
        }
        /// <summary>
        /// 使用RDLC 直接列印
        /// </summary>
        /// <param name="data">結構化資料</param>

        static void RdlcPrint(List<DataModel> data)
        {
            Microsoft.Reporting.WinForms.LocalReport localReport = new Microsoft.Reporting.WinForms.LocalReport();
            localReport.ReportPath = "Report2.rdlc";
            localReport.DataSources.Add(new Microsoft.Reporting.WinForms.ReportDataSource("DataSet1", data));

            Export(localReport);
            Print();

            localReport.Dispose();
        }
        // 提供給 the report renderer 使用, 用來建立列印用的 image stream
        static Stream CreateStream(string name, string fileNameExtension, Encoding encoding, string mimeType, bool willSeek)
        {
            Stream stream = new MemoryStream();
            m_streams.Add(stream);
            return stream;
        }
        // 把 RDLC 輸出成列印報表使用的 EMF(Enhanced Metafile file) 格式 的
        static void Export(Microsoft.Reporting.WinForms.LocalReport report)
        {
            string deviceInfo =
              @"<DeviceInfo>
                <OutputFormat>EMF</OutputFormat>
                <PageWidth>8.27in</PageWidth>
                <PageHeight>11.69in</PageHeight>
                <MarginTop>0.25in</MarginTop>
                <MarginLeft>0.25in</MarginLeft>
                <MarginRight>0.25in</MarginRight>
                <MarginBottom>0.25in</MarginBottom>
            </DeviceInfo>"
;
            Microsoft.Reporting.WinForms.Warning[] warnings;
            m_streams = new List<Stream>();
            report.Render("Image", deviceInfo, CreateStream, out warnings);
            foreach (Stream stream in m_streams)
                stream.Position = 0;
        }
        // 提供給 PrintDocument 作業用的 PrintPageEvents
        static void PrintPage(object sender, System.Drawing.Printing.PrintPageEventArgs ev)
        {
            //建立影像 Meta 同時把 Image Stream 倒入
            System.Drawing.Imaging.Metafile pageImage = new System.Drawing.Imaging.Metafile(m_streams[m_currentPageIndex]);

            // 調整繪製方塊大小同等印表機可列印空間
            System.Drawing.Rectangle adjustedRect = new System.Drawing.Rectangle(
                ev.PageBounds.Left - (int)ev.PageSettings.HardMarginX,
                ev.PageBounds.Top - (int)ev.PageSettings.HardMarginY,
                ev.PageBounds.Width,
                ev.PageBounds.Height);

            // 報表背景以白色塗刷
            ev.Graphics.FillRectangle(System.Drawing.Brushes.White, adjustedRect);

            // 繪製報表內容
            ev.Graphics.DrawImage(pageImage, adjustedRect);

            // 準備到下一頁繪製. 確保將資料繪製完畢
            m_currentPageIndex++;
            ev.HasMorePages = (m_currentPageIndex < m_streams.Count);
        }
        // 列印 m_streams 內的文件
        static void Print(string printerName = null)
        {
            if (m_streams == null || m_streams.Count == 0)
                throw new Exception("Error: no stream to print.");
            System.Drawing.Printing.PrintDocument printDoc = new System.Drawing.Printing.PrintDocument();
            // 有指定印表機就設定為指定印表機,反之不設定(使用預設)
            if (!string.IsNullOrEmpty(printerName))
            {
                printDoc.PrinterSettings.PrinterName = printerName;
            }
            if (!printDoc.PrinterSettings.IsValid)
            {
                throw new Exception("Error: cannot find the printer [" + printerName + "] .");
            }
            else
            {
                printDoc.PrintPage += new System.Drawing.Printing.PrintPageEventHandler(PrintPage);
                m_currentPageIndex = 0;
                printDoc.Print();
            }
        }
    }
}


程式碼有使用到 DataModel.csReport2.cs 請參考
將 RDLC 報表不需預覽直接列印(Print RDLC Report without Preview)

DataModel.cs


namespace SimpleHttpService1
{
    class DataModel
    {
        public int no { get; set; }
        public string line { get; set; }
    }
}


Report2.rdlc



由於不需要太複雜的 HTTP 服務處理,所以基本上就 HttpListener  就可以完成。
再來透過 HttpClient 到指定網站索取資料,索取到的資料交由 RDLC 產生版面。
再透過 PrintDocument 去發送列印即可。
最後很重要的是, Response 的內容的 Header 必須帶有允許做『跨域資源共享CORS』的識別值,才能讓原本請求的瀏覽器不會被識別為錯誤。

關於CORS其實有很多定義的,由於Http服務器是自己寫的,對於請求也只使用簡單請求,因此也只會使用 Access-Control-Allow-Origin 字段的操作而已。

這樣寫的好處
1、可以直接控制特殊印表機,例如很多標籤機有提供 API 開發可以加快列印度或是各種條碼列印,而不需要自己手刻條碼程式碼。
2、不只可以控制印表機,也可控制電腦其它 輸出/輸入設備 等。
3、索取資料來源可以多樣化。

缺點
1、必須在本機安裝小型 HTTP 程式,這樣就跟不是 Work Anywhere 了
2、平台需要列印前必須先將它執行起來(如果是寫成 service 型態就不需要每次啟動了。


參考:
跨來源資源共用(CORS)-MDN
跨來源資源共用- 維基百科,自由的百科全書 - Wikipedia
跨域资源共享CORS 详解- 阮一峰的网络日志

留言

alexzhang寫道…
感謝分享,Thanks!!
archer寫道…
太感謝您了,真的是很高興,因為剛好有這方面的需求
archer寫道…

您好,看了您的這篇文章,讓我受益良多,我目前是用dotnet core 3.1 api + angular 9.0 開發,我是在前端用pdfMake套件產生電子發票pdf列印,所以console service 並不用再去後端撈資料來列印,請問這部份程式應該要怎麼改,可以幫我一下嗎?
WILDOX寫道…
Archer 您好:

如果不用去後端撈資料來列印,就是把
static async Task GetURL(string docName)
的內容改掉

換成你要怎麼去取得的資訊來源即可(Function 名稱也可以不用GetURL之類)

如果變更Function名稱,下面這段的呼叫也要一併改

Task html = GetURL(qryKey);
archer寫道…
您好:
我在程式碼這一段卡住了,是否可以請教您要如何解決,
在函式 Print() :
System.Drawing.Printing.PrintDocument pd = new System.Drawing.Printing.PrintDocument();
pd.PrinterSettings.PrinterName = printerName;
pd.PrinterSettings.PrintFileName = filepath;
pd.DocumentName = "file.pdf";
pd.PrintController = new StandardPrintController();
pd.PrintPage += new System.Drawing.Printing.PrintPageEventHandler(pd_PrintPage);
pd.Print();


我在事件 pd_PrintPage(object sender, PrintPageEventArgs ev)這裏卡住了,我不知道這段程式碼要如何寫, 是否可以請教您,因為試過很多方法都不行


WILDOX寫道…
Archer 您好:

您那段CODE是想要輸出成PDF嗎?
看起來很像....

PrintDocumwnt 沒有原生支援 PDF
所以您必須用其他方法產生PDF

您可以參考其他網站提供的解決方式:

https://www.mdeditor.tw/pl/2N0Y/zh-tw
http://renjin.blogspot.com/2009/01/printing-pdf-documents-using-c.html
https://www.coder.work/article/2958753

等等
archer寫道…
您好:
謝謝您的解答,我現在的解法就是用如下這段程式碼:
System.Diagnostics.ProcessStartInfo startInfo = new System.Diagnostics.ProcessStartInfo();
startInfo.CreateNoWindow = true;
startInfo.UseShellExecute = true;
startInfo.WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden;
startInfo.FileName = filepath; //.pdf filepath
startInfo.Verb = "Print";
startInfo.Arguments = String.Format("/h /t \"{0}\" \"{1}\"", filepath, printerName);
Console.WriteLine("Arguments: {0} ", startInfo.Arguments);
System.Diagnostics.Process process = new System.Diagnostics.Process();
process.StartInfo = startInfo;
process.Start();
process.WaitForExit();
系統要先要裝 adobe reader,並把 .pdf 預設打開程式設定為 adobe reader,
不過我就是很想用 PrintDocument 去直接列印 pdf,
但是我好像找不到可以用系統的PrintDocument直接列印pdf的範例,都好像是要透過第三方程式庫
匿名表示…
想請問HttpListener 是否能監聽遠端伺服器,本地監聽都正常,但遠端即會發生錯誤,內容無:指定網路名稱格式不正確。
WILDOX寫道…
作者已經移除這則留言。
WILDOX寫道…
HttpListener 必須要綁定 port,因此只能在本機,因為你不可能去綁定遠端主機的port吧,至於http://* 你可能會誤會只要有http://的主機都可以綁吧。

其實不是喔,那個是可以讓你指定本機的host name用的

因為本機呼叫可以使用:
http://127.0.0.1:1300
http://localhost:1300
http://192.168.0.1:1300 <本機在內網的IP>
http://169.157.123.85 <本機如果有掛在外網的IP,記得防火牆要開規則>
http://myhome.net.idv <如果您有申請網域的話>

當你使用http://localhost:1300做為綁定時,其他應用或是服務呼叫http://127.0.0.1:1300 或 http://192.168.0.1:1300 都是不能使用的
諸如此類

這個網誌中的熱門文章

【研究】列印的條碼為什麼很難刷(掃描)

C# 使用 Process.Start 執行外部程式

統一發票列印小程式