起因:

 由於專案需要實現將網頁靜默列印效果,那麼直接使用瀏覽器列印功能無法達到靜默列印效果。

 瀏覽器列印都會彈出預覽介面(如下圖),無法達到靜默列印。

  

解決方案:

 谷歌瀏覽器提供了將html直接列印成pdf並儲存成檔案方法,然後再將pdf進行靜默列印。

 在呼叫谷歌命令前,需要獲取當前谷歌安裝位置:

  1. public static class ChromeFinder
  2. {
  3. #region 獲取應用程式目錄
  4. private static void GetApplicationDirectories(ICollection<string> directories)
  5. {
  6. if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
  7. {
  8. const string subDirectory = "Google\\Chrome\\Application";
  9. directories.Add(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), subDirectory));
  10. directories.Add(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), subDirectory));
  11. }
  12. else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
  13. {
  14. directories.Add("/usr/local/sbin");
  15. directories.Add("/usr/local/bin");
  16. directories.Add("/usr/sbin");
  17. directories.Add("/usr/bin");
  18. directories.Add("/sbin");
  19. directories.Add("/bin");
  20. directories.Add("/opt/google/chrome");
  21. }
  22. else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
  23. throw new Exception("Finding Chrome on MacOS is currently not supported, please contact the programmer.");
  24. }
  25. #endregion
  26. #region 獲取當前程式目錄
  27. private static string GetAppPath()
  28. {
  29. var appPath = AppDomain.CurrentDomain.BaseDirectory;
  30. if (appPath.EndsWith(Path.DirectorySeparatorChar.ToString()))
  31. return appPath;
  32. return appPath + Path.DirectorySeparatorChar;
  33. }
  34. #endregion
  35. #region 查詢
  36. /// <summary>
  37. /// 嘗試查詢谷歌程式
  38. /// </summary>
  39. /// <returns></returns>
  40. public static string Find()
  41. {
  42. // 對於Windows,我們首先檢查登錄檔。這是最安全的方法,也考慮了非預設安裝位置。請注意,Chrome x64當前(2019年2月)也安裝在程式檔案(x86)中,並使用相同的登錄檔項!
  43. if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
  44. {
  45. var key = Registry.GetValue(@"HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Google Chrome","InstallLocation", string.Empty);
  46. if (key != null)
  47. {
  48. var path = Path.Combine(key.ToString(), "chrome.exe");
  49. if (File.Exists(path)) return path;
  50. }
  51. }
  52. // 收集常用的可執行檔名
  53. var exeNames = new List<string>();
  54.  
  55. if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
  56. exeNames.Add("chrome.exe");
  57. else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
  58. {
  59. exeNames.Add("google-chrome");
  60. exeNames.Add("chrome");
  61. exeNames.Add("chromium");
  62. exeNames.Add("chromium-browser");
  63. }
  64. else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
  65. {
  66. exeNames.Add("Google Chrome.app/Contents/MacOS/Google Chrome");
  67. exeNames.Add("Chromium.app/Contents/MacOS/Chromium");
  68. }
  69. //檢查執行目錄
  70. var currentPath = GetAppPath();
  71. foreach (var exeName in exeNames)
  72. {
  73. var path = Path.Combine(currentPath, exeName);
  74. if (File.Exists(path)) return path;
  75. }
  76. //在通用軟體安裝目錄中查詢谷歌程式檔案
  77. var directories = new List<string>();
  78. GetApplicationDirectories(directories);
  79. foreach (var exeName in exeNames)
  80. {
  81. foreach (var directory in directories)
  82. {
  83. var path = Path.Combine(directory, exeName);
  84. if (File.Exists(path)) return path;
  85. }
  86. }
  87. return null;
  88. }
  89. #endregion
  90. }

 1、命令方式:

  通過命令方式啟動谷歌程序,傳入網頁地址、pdf儲存位置等資訊,將html轉換成pdf:

  1. /// <summary>
  2. /// 執行cmd命令
  3. /// </summary>
  4. /// <param name="command"></param>
  5. private void RunCMD(string command)
  6. {
  7. Process p = new Process();
  8. p.StartInfo.FileName = "cmd.exe";
  9. p.StartInfo.UseShellExecute = false; //是否使用作業系統shell啟動
  10. p.StartInfo.RedirectStandardInput = true;//接受來自呼叫程式的輸入資訊
  11. p.StartInfo.RedirectStandardOutput = true;//由呼叫程式獲取輸出資訊
  12. p.StartInfo.RedirectStandardError = true;//重定向標準錯誤輸出
  13. p.StartInfo.CreateNoWindow = true;//不顯示程式視窗
  14. p.Start();//啟動程式
  15. //向cmd視窗傳送輸入資訊
  16. p.StandardInput.WriteLine(command + "&exit");
  17. p.StandardInput.AutoFlush = true;
  18. //p.StandardInput.WriteLine("exit");
  19. //向標準輸入寫入要執行的命令。這裡使用&是批處理命令的符號,表示前面一個命令不管是否執行成功都執行後面(exit)命令,如果不執行exit命令,後面呼叫ReadToEnd()方法會假死
  20. //同類的符號還有&&和||前者表示必須前一個命令執行成功才會執行後面的命令,後者表示必須前一個命令執行失敗才會執行後面的命令
  21. //獲取cmd視窗的輸出資訊
  22. p.StandardOutput.ReadToEnd();
  23. p.WaitForExit();//等待程式執行完退出程序
  24. p.Close();
  25. }
  26.  
  27. public void GetPdf(string url, List<string> args = null)
  28. {
  29. var chromeExePath = ChromeFinder.Find();
  30. if (string.IsNullOrEmpty(chromeExePath))
  31. {
  32. MessageBox.Show("獲取谷歌瀏覽器地址失敗");
  33. return;
  34. }
  35. var outpath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tmppdf");
  36. if (!Directory.Exists(outpath))
  37. {
  38. Directory.CreateDirectory(outpath);
  39. }
  40. outpath = Path.Combine(outpath, DateTime.Now.Ticks + ".pdf");
  41. if (args == null)
  42. {
  43. args = new List<string>();
  44. args.Add("--start-in-incognito");//隱身模式
  45. args.Add("--headless");//無介面模式
  46. args.Add("--disable-gpu");//禁用gpu加速
  47. args.Add("--print-to-pdf-no-header");//列印生成pdf無頁首頁尾
  48. args.Add($"--print-to-pdf=\"{outpath}\" \"{url}\"");//列印生成pdf到指定目錄
  49. }
  50. string command = $"\"{chromeExePath}\"";
  51. if (args != null && args.Count > 0)
  52. {
  53. foreach (var item in args)
  54. {
  55. command += $" {item} ";
  56. }
  57. }
  58. Stopwatch sw = new Stopwatch();
  59. sw.Start();
  60. RunCMD(command);
  61. sw.Stop();
  62. MessageBox.Show(sw.ElapsedMilliseconds + "ms");
  63. }

  其中最主要的命令引數包含:

  a)  --headless:無介面

  b) --print-to-pdf-no-header :列印生成pdf不包含頁首頁尾

  c) --print-to-pdf:將頁面列印成pdf,引數值為輸出地址

  存在問題:

    • 通過該方式會生成多個谷歌程序(多達5個),並且頻繁的建立程序在效能較差時,會導致生成pdf較慢
    • 在某些情況下,谷歌建立的程序:未能完全退出,導致後續生成pdf未執行。

      異常程序引數類似:--type=crashpad-handler "--user-data-dir=xxx" /prefetch:7 --monitor-self-annotation=ptype=crashpad-handler "--database=xx" "--metrics-dir=xx" --url=https://clients2.google.com/cr/report --annotation=channel= --annotation=plat=Win64 --annotation=prod=Chrome

  那麼,有沒有方式能達到重用谷歌程序,並且能生成pdf操作呢? 那就需要使用第二種方式。

 2、Chrome DevTools Protocol 方式

  該方式主要步驟:

  • 建立一個無介面谷歌程序
  1. #region 啟動谷歌瀏覽器程序
  2. /// <summary>
  3. /// 啟動谷歌程序,如已啟動則不啟動
  4. /// </summary>
  5. /// <exception cref="ChromeException"></exception>
  6. private void StartChromeHeadless()
  7. {
  8. if (IsChromeRunning)
  9. {
  10. return;
  11. }
  12.  
  13. var workingDirectory = Path.GetDirectoryName(_chromeExeFileName);
  14. _chromeProcess = new Process();
  15. var processStartInfo = new ProcessStartInfo
  16. {
  17. FileName = _chromeExeFileName,
  18. Arguments = string.Join(" ", DefaultChromeArguments),
  19. CreateNoWindow = true,
  20. };
  21. _chromeProcess.ErrorDataReceived += _chromeProcess_ErrorDataReceived;
  22. _chromeProcess.EnableRaisingEvents = true;
  23. processStartInfo.UseShellExecute = false;
  24. processStartInfo.RedirectStandardError = true;
  25. _chromeProcess.StartInfo = processStartInfo;
  26. _chromeProcess.Exited += _chromeProcess_Exited;
  27. try
  28. {
  29. _chromeProcess.Start();
  30. }
  31. catch (Exception exception)
  32. {
  33. throw;
  34. }
  35. _chromeWaitEvent = new ManualResetEvent(false);
  36. _chromeProcess.BeginErrorReadLine();
  37. if (_conversionTimeout.HasValue)
  38. {
  39. if (!_chromeWaitEvent.WaitOne(_conversionTimeout.Value))
  40. throw new Exception($"超過{_conversionTimeout.Value}ms,無法連線到Chrome開發工具");
  41. }
  42. _chromeWaitEvent.WaitOne();
  43. _chromeProcess.ErrorDataReceived -= _chromeProcess_ErrorDataReceived;
  44. _chromeProcess.Exited -= _chromeProcess_Exited;
  45. }
  46. /// <summary>
  47. /// 退出事件
  48. /// </summary>
  49. /// <param name="sender"></param>
  50. /// <param name="e"></param>
  51. private void _chromeProcess_Exited(object sender, EventArgs e)
  52. {
  53. try
  54. {
  55. if (_chromeProcess == null) return;
  56. var exception = Marshal.GetExceptionForHR(_chromeProcess.ExitCode);
  57. throw new Exception($"Chrome意外退出, {exception}");
  58. }
  59. catch (Exception exception)
  60. {
  61. _chromeEventException = exception;
  62. _chromeWaitEvent.Set();
  63. }
  64. }/// <summary>
  65. /// 當Chrome將資料傳送到錯誤輸出時引發
  66. /// </summary>
  67. /// <param name="sender"></param>
  68. /// <param name="args"></param>
  69. private void _chromeProcess_ErrorDataReceived(object sender, DataReceivedEventArgs args)
  70. {
  71. try
  72. {
  73. if (args.Data == null || string.IsNullOrEmpty(args.Data) || args.Data.StartsWith("[")) return;
  74. if (!args.Data.StartsWith("DevTools listening on")) return;
  75. // DevTools listening on ws://127.0.0.1:50160/devtools/browser/53add595-f351-4622-ab0a-5a4a100b3eae
  76. var uri = new Uri(args.Data.Replace("DevTools listening on ", string.Empty));
  77. ConnectToDevProtocol(uri);
  78. _chromeProcess.ErrorDataReceived -= _chromeProcess_ErrorDataReceived;
  79. _chromeWaitEvent.Set();
  80. }
  81. catch (Exception exception)
  82. {
  83. _chromeEventException = exception;
  84. _chromeWaitEvent.Set();
  85. }
  86. }
  87. #endregion
  • 從程序輸出資訊中獲取瀏覽器ws連線地址,並建立ws連線;向谷歌瀏覽器程序傳送ws訊息:開啟一個選項卡
  1. WebSocket4Net.WebSocket _browserSocket = null;
  2. /// <summary>
  3. /// 建立連線
  4. /// </summary>
  5. /// <param name="uri"></param>
  6. private void ConnectToDevProtocol(Uri uri)
  7. {
  8. //建立socket連線
  9. //瀏覽器連線:ws://127.0.0.1:50160/devtools/browser/53add595-f351-4622-ab0a-5a4a100b3eae
  10. _browserSocket = new WebSocket4Net.WebSocket(uri.ToString());
  11. _browserSocket.MessageReceived += WebSocket_MessageReceived;
  12. JObject jObject = new JObject();
       jObject["id"] = 1;
       jObject["method"] = "Target.createTarget";
  13. jObject["params"] = new JObject();
  14. jObject["params"]["url"] = "about:blank";
  15. _browserSocket.Send(jObject.ToString());
  16. //建立頁卡Socket連線
  17. //頁卡連線:ws://127.0.0.1:50160/devtools/browser/53add595-f351-4622-ab0a-5a4a100b3eae
  18. var pageUrl = $"{uri.Scheme}://{uri.Host}:{uri.Port}/devtools/page/頁卡id";
  19. }
  • 根據devtools協議向當前頁卡建立ws連線

    1. WebSocket4Net.WebSocket _pageSocket = null;
    2. private void WebSocket_MessageReceived(object sender, WebSocket4Net.MessageReceivedEventArgs e)
    3. {
    4. string msg = e.Message;
    5. var pars = JObject.Parse(msg);
    6. string id = pars["id"].ToString();
    7. switch (id)
    8. {
    9. case "1":
    10. var pageUrl = $"{_browserUrl.Scheme}://{_browserUrl.Host}:{_browserUrl.Port}/devtools/page/{pars["result"]["targetId"].ToString()}";
    11. _pageSocket = new WebSocket4Net.WebSocket(pageUrl);
    12. _pageSocket.MessageReceived += _pageSocket_MessageReceived;
    13. _pageSocket.Open();
    14. break;
    15. }
    16. }
  • 向頁卡傳送命令,跳轉到需要生成pdf的頁面
  1. //傳送重新整理命令
  2. JObject jObject = new JObject();
  3. jObject["method"] = "Page.navigate"; //方法
  4. jObject["id"] = "2"; //id
  5. jObject["params"] = new JObject(); //引數
  6. jObject["params"]["url"] = "http://www.baidu.com";
  7. _pageSocket.Send(jObject.ToString());
  • 最後項該頁卡傳送命令生成pdf  

    1. //傳送重新整理命令
    2. jObject = new JObject();
    3. jObject["method"] = "Page.printToPDF"; //方法
    4. jObject["id"] = "3"; //id
    5. jObject["params"] = new JObject(); //引數列印引數設定
    6. jObject["params"]["landscape"] = false;
    7. jObject["params"]["displayHeaderFooter"] = false;
    8. jObject["params"]["printBackground"] = false;
    9. _pageSocket.Send(jObject.ToString());

      

  命令支援的詳細內容,詳細檢視DevTools協議內容

參考:

 DevTools協議: Chrome DevTools Protocol - Page domain

谷歌引數說明:List of Chromium Command Line Switches « Peter Beverloo