【問題記錄】- 谷歌瀏覽器 Html生成PDF

起因:

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

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

  

解決方案:

 谷歌瀏覽器提供了將html直接打印成pdf並保存成文件方法,然後再將pdf進行靜默打印。

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

public static class ChromeFinder
{
    #region 獲取應用程序目錄
    private static void GetApplicationDirectories(ICollection<string> directories)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            const string subDirectory = "Google\\Chrome\\Application";
            directories.Add(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), subDirectory));
            directories.Add(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), subDirectory));
        }
        else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            directories.Add("/usr/local/sbin");
            directories.Add("/usr/local/bin");
            directories.Add("/usr/sbin");
            directories.Add("/usr/bin");
            directories.Add("/sbin");
            directories.Add("/bin");
            directories.Add("/opt/google/chrome");
        }
        else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
            throw new Exception("Finding Chrome on MacOS is currently not supported, please contact the programmer.");
    }
    #endregion
    #region 獲取當前程序目錄
    private static string GetAppPath()
    {
        var appPath = AppDomain.CurrentDomain.BaseDirectory;
        if (appPath.EndsWith(Path.DirectorySeparatorChar.ToString()))
            return appPath;
        return appPath + Path.DirectorySeparatorChar;
    }
    #endregion
    #region 查找
    /// <summary>
    /// 嘗試查找谷歌程序
    /// </summary>
    /// <returns></returns>
    public static string Find()
    {
        // 對於Windows,我們首先檢查註冊表。這是最安全的方法,也考慮了非默認安裝位置。請注意,Chrome x64當前(2019年2月)也安裝在程序文件(x86)中,並使用相同的註冊表項!
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            var key = Registry.GetValue(@"HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Google Chrome","InstallLocation", string.Empty);
            if (key != null)
            {
                var path = Path.Combine(key.ToString(), "chrome.exe");
                if (File.Exists(path)) return path;
            }
        }
        // 收集常用的可執行文件名
        var exeNames = new List<string>();

        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            exeNames.Add("chrome.exe");
        else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            exeNames.Add("google-chrome");
            exeNames.Add("chrome");
            exeNames.Add("chromium");
            exeNames.Add("chromium-browser");
        }
        else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
        {
            exeNames.Add("Google Chrome.app/Contents/MacOS/Google Chrome");
            exeNames.Add("Chromium.app/Contents/MacOS/Chromium");
        }
        //檢查運行目錄
        var currentPath = GetAppPath();
        foreach (var exeName in exeNames)
        {
            var path = Path.Combine(currentPath, exeName);
            if (File.Exists(path)) return path;
        }
        //在通用軟件安裝目錄中查找谷歌程序文件
        var directories = new List<string>();
        GetApplicationDirectories(directories);
        foreach (var exeName in exeNames)
        {
            foreach (var directory in directories)
            {
                var path = Path.Combine(directory, exeName);
                if (File.Exists(path)) return path;
            }
        }
        return null;
    }
    #endregion
}

 1、命令方式: 

  通過命令方式啟動谷歌進程,傳入網頁地址、pdf保存位置等信息,將html轉換成pdf:

/// <summary>
/// 運行cmd命令
/// </summary>
/// <param name="command"></param>
private void RunCMD(string command)
{
    Process p = new Process();
    p.StartInfo.FileName = "cmd.exe";
    p.StartInfo.UseShellExecute = false;    //是否使用操作系統shell啟動
    p.StartInfo.RedirectStandardInput = true;//接受來自調用程序的輸入信息
    p.StartInfo.RedirectStandardOutput = true;//由調用程序獲取輸出信息
    p.StartInfo.RedirectStandardError = true;//重定向標準錯誤輸出
    p.StartInfo.CreateNoWindow = true;//不顯示程序窗口
    p.Start();//啟動程序
    //向cmd窗口發送輸入信息
    p.StandardInput.WriteLine(command + "&exit");
    p.StandardInput.AutoFlush = true;
    //p.StandardInput.WriteLine("exit");
    //向標準輸入寫入要執行的命令。這裡使用&是批處理命令的符號,表示前面一個命令不管是否執行成功都執行後面(exit)命令,如果不執行exit命令,後面調用ReadToEnd()方法會假死
    //同類的符號還有&&和||前者表示必須前一個命令執行成功才會執行後面的命令,後者表示必須前一個命令執行失敗才會執行後面的命令
    //獲取cmd窗口的輸出信息
    p.StandardOutput.ReadToEnd();
    p.WaitForExit();//等待程序執行完退出進程
    p.Close();
}

public void GetPdf(string url, List<string> args = null)
{
    var chromeExePath = ChromeFinder.Find();
    if (string.IsNullOrEmpty(chromeExePath))
    {
        MessageBox.Show("獲取谷歌瀏覽器地址失敗");
        return;
    }
    var outpath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tmppdf");
    if (!Directory.Exists(outpath))
    {
        Directory.CreateDirectory(outpath);
    }
    outpath = Path.Combine(outpath, DateTime.Now.Ticks + ".pdf");
    if (args == null)
    {
        args = new List<string>();
        args.Add("--start-in-incognito");//隱身模式
        args.Add("--headless");//無界面模式
        args.Add("--disable-gpu");//禁用gpu加速
        args.Add("--print-to-pdf-no-header");//打印生成pdf無頁眉頁腳
        args.Add($"--print-to-pdf=\"{outpath}\" \"{url}\"");//打印生成pdf到指定目錄
    }
    string command = $"\"{chromeExePath}\"";
    if (args != null && args.Count > 0)
    {
        foreach (var item in args)
        {
            command += $" {item} ";
        }
    }
    Stopwatch sw = new Stopwatch();
    sw.Start();
    RunCMD(command);
    sw.Stop();
    MessageBox.Show(sw.ElapsedMilliseconds + "ms");
}

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

  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=//clients2.google.com/cr/report –annotation=channel= –annotation=plat=Win64 –annotation=prod=Chrome

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

 2、Chrome DevTools Protocol 方式

  該方式主要步驟:

  • 創建一個無界面谷歌進程
#region 啟動谷歌瀏覽器進程
/// <summary>
/// 啟動谷歌進程,如已啟動則不啟動
/// </summary>
/// <exception cref="ChromeException"></exception>
private void StartChromeHeadless()
{
    if (IsChromeRunning)
    {
        return;
    }

    var workingDirectory = Path.GetDirectoryName(_chromeExeFileName);
    _chromeProcess = new Process();
    var processStartInfo = new ProcessStartInfo
    {
        FileName = _chromeExeFileName,
        Arguments = string.Join(" ", DefaultChromeArguments),
        CreateNoWindow = true,
    };
    _chromeProcess.ErrorDataReceived += _chromeProcess_ErrorDataReceived;
    _chromeProcess.EnableRaisingEvents = true;
    processStartInfo.UseShellExecute = false;
    processStartInfo.RedirectStandardError = true;
    _chromeProcess.StartInfo = processStartInfo;
    _chromeProcess.Exited += _chromeProcess_Exited;
    try
    {
        _chromeProcess.Start();
    }
    catch (Exception exception)
    {
        throw;
    }
    _chromeWaitEvent = new ManualResetEvent(false);
    _chromeProcess.BeginErrorReadLine();
    if (_conversionTimeout.HasValue)
    {
        if (!_chromeWaitEvent.WaitOne(_conversionTimeout.Value))
            throw new Exception($"超過{_conversionTimeout.Value}ms,無法連接到Chrome開發工具");
    }
    _chromeWaitEvent.WaitOne();
    _chromeProcess.ErrorDataReceived -= _chromeProcess_ErrorDataReceived;
    _chromeProcess.Exited -= _chromeProcess_Exited;
}
/// <summary>
/// 退出事件
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void _chromeProcess_Exited(object sender, EventArgs e)
{
    try
    {
        if (_chromeProcess == null) return;
        var exception = Marshal.GetExceptionForHR(_chromeProcess.ExitCode);
        throw new Exception($"Chrome意外退出, {exception}");
    }
    catch (Exception exception)
    {
        _chromeEventException = exception;
        _chromeWaitEvent.Set();
    }
}/// <summary>
/// 當Chrome將數據發送到錯誤輸出時引發
/// </summary>
/// <param name="sender"></param>
/// <param name="args"></param>
private void _chromeProcess_ErrorDataReceived(object sender, DataReceivedEventArgs args)
{
    try
    {
        if (args.Data == null || string.IsNullOrEmpty(args.Data) || args.Data.StartsWith("[")) return;
        if (!args.Data.StartsWith("DevTools listening on")) return;
        // DevTools listening on ws://127.0.0.1:50160/devtools/browser/53add595-f351-4622-ab0a-5a4a100b3eae
        var uri = new Uri(args.Data.Replace("DevTools listening on ", string.Empty));
        ConnectToDevProtocol(uri);
        _chromeProcess.ErrorDataReceived -= _chromeProcess_ErrorDataReceived;
        _chromeWaitEvent.Set();
    }
    catch (Exception exception)
    {
        _chromeEventException = exception;
        _chromeWaitEvent.Set();
    }
}
#endregion
  • 從進程輸出信息中獲取瀏覽器ws連接地址,並創建ws連接;向谷歌瀏覽器進程發送ws消息:打開一個選項卡
WebSocket4Net.WebSocket _browserSocket = null;
/// <summary>
/// 創建連接
/// </summary>
/// <param name="uri"></param>
private void ConnectToDevProtocol(Uri uri)
{
    //創建socket連接
    //瀏覽器連接:ws://127.0.0.1:50160/devtools/browser/53add595-f351-4622-ab0a-5a4a100b3eae
    _browserSocket = new WebSocket4Net.WebSocket(uri.ToString());
    _browserSocket.MessageReceived += WebSocket_MessageReceived;
    JObject jObject = new JObject();
   jObject["id"] =
1;
   jObject[
"method"] = "Target.createTarget"; jObject["params"] = new JObject(); jObject["params"]["url"] = "about:blank"; _browserSocket.Send(jObject.ToString()); //創建頁卡Socket連接 //頁卡連接:ws://127.0.0.1:50160/devtools/browser/53add595-f351-4622-ab0a-5a4a100b3eae var pageUrl = $"{uri.Scheme}://{uri.Host}:{uri.Port}/devtools/page/頁卡id"; }
  • 根據devtools協議向當前頁卡創建ws連接
    WebSocket4Net.WebSocket _pageSocket = null;
    private void WebSocket_MessageReceived(object sender, WebSocket4Net.MessageReceivedEventArgs e)
    {
        string msg = e.Message;
        var pars = JObject.Parse(msg);
        string id = pars["id"].ToString();
        switch (id)
        {
            case "1":
                var pageUrl = $"{_browserUrl.Scheme}://{_browserUrl.Host}:{_browserUrl.Port}/devtools/page/{pars["result"]["targetId"].ToString()}";
                _pageSocket = new WebSocket4Net.WebSocket(pageUrl);
                _pageSocket.MessageReceived += _pageSocket_MessageReceived;
                _pageSocket.Open();
                break;
        }
    }
  • 向頁卡發送命令,跳轉到需要生成pdf的頁面
//發送刷新命令
JObject jObject = new JObject();
jObject["method"] = "Page.navigate"; //方法
jObject["id"] = "2"; //id
jObject["params"] = new JObject(); //參數
jObject["params"]["url"] = "//www.baidu.com";
_pageSocket.Send(jObject.ToString());
  • 最後項該頁卡發送命令生成pdf  
    //發送刷新命令
    jObject = new JObject();
    jObject["method"] = "Page.printToPDF"; //方法
    jObject["id"] = "3"; //id
    jObject["params"] = new JObject(); //參數打印參數設置
    jObject["params"]["landscape"] = false;
    jObject["params"]["displayHeaderFooter"] = false;
    jObject["params"]["printBackground"] = false;
    _pageSocket.Send(jObject.ToString());

      

  命令支持的詳細內容,詳細查看DevTools協議內容

參考:

 DevTools協議: Chrome DevTools Protocol – Page domain

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