【问题记录】- 谷歌浏览器 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=https://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"] = "http://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