1

Entirely on the backend, with no console, no context, no session (part of an agent call that runs every few seconds); I need a way to convert either a small snippet of HTML, or an entire HTML document into an image (bitmap or otherwise) and then convert that into a base64 string so I can render the img into an email template.

The HTML itself is dynamic and the data within changes every time it is needed.

  • I have tried using different libraries like Aspose (https://products.aspose.com/html/net/) but it's not free and is quite slow at generation. Even for small snippets of HTML
  • I have tried using a default Webbrowser method. And this mostly works but does not render any CSS to go with the HTML. Inline or otherwise.

What is the simplest, quickest, easiest, way to render HTML with inline CSS into an image/bitmap/bitmapimage. Any external libraries/Nuget packages MUST be entirely free. The image then needs to be converted to a Base64 string. Auto cropping/auto sizing would also be a huge benefit to any answers.

So far this is the fastest and best I can do, but it fails at rendering the CSS for the HTML:

public static class UserDataExtensions
    {
        public static string SignBase64(this string base64, string mediaType, string charSet)
        {
            return "data:" + mediaType + ";charset=" + charSet + ";base64," + base64;
        }
    }

public class HtmlToImageConverter
        {
            private string _Html;
            private Bitmap _Image;
    
            private const string HTML_START = "<html><head></head><body>";
            private const string HTML_END = "</body></html>";
    
            public HtmlToImageConverter()
            {
            }
            public string ConvertHTML(string html)
            {
                _Html = HTML_START + html + HTML_END;
                return ToBase64(Render()).SignBase64("image/png", "utf-8");
            }
    
            private string ToBase64(Bitmap bitmap)
            {
                using (var memory = new MemoryStream())
                {
                    using (var newImage = new Bitmap(bitmap))
                    {
                        newImage.Save(memory, ImageFormat.Png);
                        var SigBase64 = Convert.ToBase64String(memory.GetBuffer()); // Get Base64
                        return SigBase64;
                    }
                }
            }
    
            private Bitmap Render()
            {
                var thread = new Thread(GenerateInternal);
                thread.SetApartmentState(ApartmentState.STA);
                thread.Start();
                thread.Join();
                return _Image;
            }
    
            private void GenerateInternal()
            {
                var webBrowser = new WebBrowser
                {
                    ScrollBarsEnabled = false,
                    DocumentText = _Html,
                    ClientSize = new Size(3000, 3000)
                };
    
                webBrowser.DocumentCompleted += WebBrowser_DocumentCompleted;
                while (webBrowser.ReadyState != WebBrowserReadyState.Complete) Application.DoEvents();
                webBrowser.Dispose();
            }
    
            private void WebBrowser_DocumentCompleted(object sender, WebBrowserDocumentCompletedEventArgs e)
            {
                var webBrowser = (WebBrowser)sender;
    
                _Image = new Bitmap(webBrowser.Bounds.Width, webBrowser.Bounds.Height);
                webBrowser.BringToFront();
                webBrowser.DrawToBitmap(_Image, webBrowser.Bounds);
                _Image = AutoCrop(_Image);
            }
    
            private static byte[][] GetRgb(Bitmap bmp)
            {
                var bmpData = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
                var ptr = bmpData.Scan0;
                var numPixels = bmp.Width * bmp.Height;
                var numBytes = bmpData.Stride * bmp.Height;
                var padding = bmpData.Stride - bmp.Width * 3;
                var i = 0;
                var ct = 1;
    
                var r = new byte[numPixels];
                var g = new byte[numPixels];
                var b = new byte[numPixels];
                var rgb = new byte[numBytes];
    
                Marshal.Copy(ptr, rgb, 0, numBytes);
    
                for (var x = 0; x < numBytes - 3; x += 3)
                {
                    if (x == (bmpData.Stride * ct - padding))
                    {
                        x += padding;
                        ct++;
                    }
    
                    r[i] = rgb[x];
                    g[i] = rgb[x + 1];
                    b[i] = rgb[x + 2]; i++;
                }
    
                bmp.UnlockBits(bmpData);
                return new[] { r, g, b };
            }
            private static Bitmap AutoCrop(Bitmap bmp)
            {
                // Get an array containing the R, G, B components of each pixel
                var pixels = GetRgb(bmp);
    
                var h = bmp.Height - 1;
                var w = bmp.Width;
                var top = 0;
                var bottom = h;
                var left = bmp.Width;
                var right = 0;
                var white = 0;
    
                const int tolerance = 95;
    
                var prevColor = false;
                for (var i = 0; i < pixels[0].Length; i++)
                {
                    int x = (i % (w)), y = (int)(Math.Floor((decimal)(i / w)));
                    const int tol = 255 * tolerance / 100;
                    if (pixels[0][i] >= tol && pixels[1][i] >= tol && pixels[2][i] >= tol)
                    {
                        white++;
                        right = (x > right && white == 1) ? x : right;
                    }
                    else
                    {
                        left = (x < left && white >= 1) ? x : left;
                        right = (x == w - 1 && white == 0) ? w - 1 : right;
                        white = 0;
                    }
    
                    if (white == w)
                    {
                        top = (y - top < 3) ? y : top;
                        bottom = (prevColor && x == w - 1 && y > top + 1) ? y : bottom;
                    }
    
                    left = (x == 0 && white == 0) ? 0 : left;
                    bottom = (y == h && x == w - 1 && white != w && prevColor) ? h + 1 : bottom;
    
                    if (x == w - 1)
                    {
                        prevColor = (white < w);
                        white = 0;
                    }
                }
    
                right = (right == 0) ? w : right;
                left = (left == w) ? 0 : left;
    
                // Cropy the image
                if (bottom - top > 0)
                {
                    return bmp.Clone(new Rectangle(left, top, right - left + 1, bottom - top), bmp.PixelFormat);
                }
    
                return bmp;
            }
        }
Aquaphor
  • 129
  • 10
  • 2
    Wait, you're trying to render HTML, to an image, to embed inside an email? Why not just send the HTML as the email itself? Every modern email program will properly render it and show it to the user. – MindSwipe Sep 20 '22 at 05:41
  • https://stackoverflow.com/a/60741246/14171304 ... But why? Send the HTML instead as mentioned. – dr.null Sep 20 '22 at 19:45
  • @MindSwipe No, unfortunately, a lot of our clients use Outlook email accounts. Outlook barely supports 10-year-old css/html technology. A lot of the nicer stylization we want to use does not work in outlook. This question is actually quite specific to our use case because of the amount of older email users our company sends to. – Aquaphor Sep 21 '22 at 03:08

2 Answers2

2

Sounds like you need a HTML renderer. I don't think there is a easy or simple way to do this, especially if there is dynamic content. You're best pick would be something like puppeteersharp

public static void ExportPage() {
    await using var page = await browser.NewPageAsync();
    await page.SetContentAsync("<div>My Receipt</div>");
    var result = await page.GetContentAsync();
    page.ScreenshotAsync(myPath);
}
MiVoth
  • 972
  • 6
  • 16
  • This has worked perfectly. Puppeteersharp in particular. Thank you so much. I'll post my full converter class with Puppeteersharp as another answer, but will mark yours as the solution :) – Aquaphor Sep 22 '22 at 20:44
0

Thanks again to @MiVoth for the answer. Using Puppeteersharp worked like a charm and properly rendered the HTML with the CSS. My old solution was rendering the HTML fine, but was not waiting for CSS to load.

The following solution perfectly renders any HTML I pass to it and auto crops the HTML to get rid of white space. If you custom set the Width and Height, it will auto center the rendered HTML on the white background:

using PuppeteerSharp;
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;

namespace mst16.business
{
    public class HtmlToImageConverter
    {
        private string _Html { get; set; }
        private Bitmap _Image { get; set; }
        private Browser _Browser { get; set; }
        private Page _Page { get; set; }

        private const string HTML_DEFAULT_DOCTYPE = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">";
        private const string HTML_DEFAULT_HTML = "<html lang=\"en\" xml:lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:v=\"urn:schemas-microsoft-com:vml\" style=\"-ms-text-size-adjust: 100%; color: #4e4e50; margin: 0 auto; padding: 0; height: 100%; width: 100%;\">\r\n  ##HEADER_CONTENT##\r\n  ##BODY_CONTENT##\r\n</html>";
        private const string HTML_DEFAULT_HEADER = "<head>##CONTENT##</head>";
        private const string HTML_DEFAULT_BODY = "<body width=\"100%\" style=\"-ms-text-size-adjust: 100%; color: #4e4e50; mso-line-height-rule: exactly; margin: 0 auto; padding: 0; height: 100%; width: 100%;\">\r\n  ##CONTENT##\r\n</body>";

        private const int MAX_WIDTH = 3000;
        private const int MAX_HEIGHT = 3000;
        private const int DEVICE_SCALE = 3;
        private int? _Width { get; set; } = null;
        private int? _Height { get; set; } = null;
        private bool _CompleteHTML { get; set; }

        #region Intialize
        public static HtmlToImageConverter CreateSync()
        {
            HtmlToImageConverter obj;
            var task = Task.Run(async () => await CreateAsync());
            task.Wait();
            obj = task.Result;
            return obj;
        }
        public static async Task<HtmlToImageConverter> CreateAsync()
        {
            HtmlToImageConverter obj = new HtmlToImageConverter();
            await obj.InitializeAsync();
            return obj;
        }
        public async Task<bool> DestroyAsync()
        {
            return await DisposeAsync();
        }
        public bool DestroySync()
        {
            var task = Task.Run(async () => await DisposeAsync());
            task.Wait();
            return task.Result;
        }
        private HtmlToImageConverter() { }
        #endregion

        #region Main Functions
        /// <summary>
        /// Set Object Values.<br />
        /// _Html = build HTML via <paramref name="completeHTML"/><br />
        /// _Width = <paramref name="width"/>.Value<br />
        /// _Height = <paramref name="completeHTML"/>.Value<br />
        /// </summary>
        /// <param name="html"></param>
        /// <param name="width"></param>
        /// <param name="height"></param>
        /// <param name="completeHTML"></param>
        public void LoadHtml(string html, string htmlHeaderContent, int? width, int? height, bool completeHTML = false, string customHtmlStart = "", string customHtmlEnd = "")
        {
            _CompleteHTML = completeHTML;

            StringBuilder str = new StringBuilder();
            str.Append(HTML_DEFAULT_DOCTYPE);
            str.Append(HTML_DEFAULT_HTML);
            str.Replace("##HEADER_CONTENT##", HTML_DEFAULT_HEADER);
            str.Replace("##CONTENT##", htmlHeaderContent);
            str.Replace("##BODY_CONTENT##", HTML_DEFAULT_BODY);

            // Set _Html
            if (!_CompleteHTML)
            {
                if (customHtmlStart != string.Empty && customHtmlEnd != string.Empty)
                {
                    _Html = customHtmlStart + html + customHtmlEnd;
                }
                else
                {
                    str.Replace("##CONTENT##", html);
                    _Html = str.ToString();
                }
            }
            else _Html = html;

            if (width != null) _Width = width.Value;
            if (height != null) _Height = height.Value;
        }
        public string ConvertHTMLSync(ImageFormat imgFormat)
        {
            var task = Task.Run(async () => await ConvertHTMLAsync(imgFormat));
            task.Wait();
            return task.Result;
        }
        public async Task<string> ConvertHTMLAsync(ImageFormat imgFormat, string htmlHeaderContent = "")
        {
            string base64 = string.Empty;

            try
            {
                // Reload the browser and page if they're closed
                if (_Browser.IsClosed || _Page.IsClosed) await InitializeAsync();

                // Set content and viewport size
                await _Page.SetContentAsync(_Html);
                await _Page.SetViewportAsync(new ViewPortOptions
                {
                    Width = MAX_WIDTH,
                    Height = MAX_HEIGHT,
                    DeviceScaleFactor = DEVICE_SCALE
                });
                // Take screenshot and save results as Stream
                var result = await _Page.ScreenshotStreamAsync();
                using (System.Drawing.Image img = System.Drawing.Image.FromStream(result))
                {
                    // Set _Image to new Bitmap of img size and get graphical renderer from Bitmap
                    _Image = new Bitmap(img.Width, img.Height);
                    using (var graphics = Graphics.FromImage(_Image))
                    {
                        // Draw the image to the Bitmap
                        graphics.DrawImage(img, 0, 0, img.Width, img.Height);
                    }
                }

                // Auto crop with auto centering
                _Image = _Image.AutoCrop(_Width, _Height, DEVICE_SCALE);
                base64 = _Image.ToBase64(imgFormat);
            }
            catch (Exception ex)
            {
                throw ex;
            }

            // Return signed base64 image string or full HTML with image as body
            if (!_CompleteHTML)
                return base64.SignBase64(imgFormat.ImageFormatToString(), "utf-8");
            else
            {
                StringBuilder str = new StringBuilder();
                str.Append(HTML_DEFAULT_DOCTYPE);
                str.Append(HTML_DEFAULT_HTML);
                str.Replace("##HEADER_CONTENT##", HTML_DEFAULT_HEADER);
                str.Replace("##CONTENT##", htmlHeaderContent);
                str.Replace("##BODY_CONTENT##", HTML_DEFAULT_BODY);

                str.Replace("##CONTENT##", base64.SignBase64(imgFormat.ImageFormatToString(), "utf-8").TagSignBase64("Product Partner Rendered Email"));
                return str.ToString();
            }
        }
        #endregion

        #region Create / Destroy
        /// <summary>
        /// Intialize the _Browser and _Page for later use
        /// </summary>
        /// <returns></returns>
        private async Task InitializeAsync()
        {
            using (var browserFetcher = new BrowserFetcher())
            {
                await browserFetcher.DownloadAsync();
                _Browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true });
                _Page = await _Browser.NewPageAsync();
            }
        }
        /// <summary>
        /// Destroy the _Browser and _Page async objects
        /// </summary>
        /// <returns></returns>
        private async Task<bool> DisposeAsync()
        {
            await _Browser.DisposeAsync();
            await _Page.DisposeAsync();
            return (!_Browser.IsClosed) && (!_Page.IsClosed);
        }
        #endregion
    }

    public static class ConverterExtensions
    {
        /// <summary>
        /// Convert <paramref name="bitmap"/> to Base64 string.<br />
        /// Format image via <paramref name="imgFormat"/>
        /// </summary>
        /// <param name="bitmap"></param>
        /// <param name="imgFormat"></param>
        /// <returns></returns>
        public static string ToBase64(this Bitmap bitmap, ImageFormat imgFormat)
        {
            using (var memory = new MemoryStream())
            {
                using (var newImage = new Bitmap(bitmap))
                {
                    newImage.Save(memory, imgFormat);
                    // Get base64
                    var SigBase64 = Convert.ToBase64String(memory.GetBuffer());
                    return SigBase64;
                }
            }
        }
        /// <summary>
        /// Get array of all pixel colours by row/column from <paramref name="bmp"/> Bitmap
        /// </summary>
        /// <param name="bmp"></param>
        /// <returns></returns>
        public static byte[][] GetRgb(this Bitmap bmp)
        {
            var bmpData = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
            var ptr = bmpData.Scan0;
            var numPixels = bmp.Width * bmp.Height;
            var numBytes = bmpData.Stride * bmp.Height;
            var padding = bmpData.Stride - bmp.Width * 3;
            var i = 0;
            var ct = 1;

            var r = new byte[numPixels];
            var g = new byte[numPixels];
            var b = new byte[numPixels];
            var rgb = new byte[numBytes];

            Marshal.Copy(ptr, rgb, 0, numBytes);

            for (var x = 0; x < numBytes - 3; x += 3)
            {
                if (x == (bmpData.Stride * ct - padding))
                {
                    x += padding;
                    ct++;
                }

                r[i] = rgb[x];
                g[i] = rgb[x + 1];
                b[i] = rgb[x + 2]; i++;
            }

            bmp.UnlockBits(bmpData);
            return new[] { r, g, b };
        }
        /// <summary>
        /// Automatically Crop by any White pixels on <paramref name="bmp"/> Bitmap.<br />
        /// Cap the width of the resulting Bitmap via <paramref name="Cap_Width"/><br />
        /// Cap the height of the resulting Bitmap via <paramref name="Cap_Height"/>
        /// </summary>
        /// <param name="bmp"></param>
        /// <param name="Cap_Width"></param>
        /// <param name="Cap_Height"></param>
        /// <returns></returns>
        public static Bitmap AutoCrop(this Bitmap bmp, int? Cap_Width, int? Cap_Height, int Scale)
        {
            // Get an array containing the R, G, B components of each pixel
            var pixels = bmp.GetRgb();

            var h = bmp.Height - 1;
            var w = bmp.Width;
            var top = 0;
            var bottom = h;
            var left = bmp.Width;
            var right = 0;
            var white = 0;

            const int tolerance = 100;

            var prevColor = false;
            for (var i = 0; i < pixels[0].Length; i++)
            {
                int x = (i % (w)), y = (int)(Math.Floor((decimal)(i / w)));
                const int tol = 255 * tolerance / 100;
                if (pixels[0][i] >= tol && pixels[1][i] >= tol && pixels[2][i] >= tol)
                {
                    white++;
                    right = (x > right && white == 1) ? x : right;
                }
                else
                {
                    left = (x < left && white >= 1) ? x : left;
                    right = (x == w - 1 && white == 0) ? w - 1 : right;
                    white = 0;
                }

                if (white == w)
                {
                    top = (y - top < 3) ? y : top;
                    bottom = (prevColor && x == w - 1 && y > top + 1) ? y : bottom;
                }

                left = (x == 0 && white == 0) ? 0 : left;
                bottom = (y == h && x == w - 1 && white != w && prevColor) ? h + 1 : bottom;

                if (x == w - 1)
                {
                    prevColor = (white < w);
                    white = 0;
                }
            }

            right = (right == 0) ? w : right;
            left = (left == w) ? 0 : left;

            // Cap minimum values to set _Width and _Height
            var SetWidth = right - left;
            SetWidth = (Cap_Width == null ? SetWidth : (SetWidth >= Cap_Width.Value * Scale ? SetWidth : Cap_Width.Value * Scale));
            var SetHeight = bottom - top;
            SetHeight = (Cap_Height == null ? SetHeight : (SetHeight >= Cap_Height.Value * Scale ? SetHeight : Cap_Height.Value * Scale));
            // Cap left and right
            var CenteredLeft = left + (((right - SetWidth) - left) / 2);
            var CenteredTop = (top + ((bottom - SetHeight) - top) / 2);

            // Cropy the image
            if (bottom - top > 0)
            {
                return bmp.Clone(new Rectangle(CenteredLeft, CenteredTop, SetWidth, SetHeight), bmp.PixelFormat);
            }

            return bmp;
        }
        /// <summary>
        /// Sign a given <paramref name="base64"/> string with valid <paramref name="mediaType"/> and <paramref name="charSet"/>
        /// </summary>
        /// <param name="base64"></param>
        /// <param name="mediaType"></param>
        /// <param name="charSet"></param>
        /// <returns></returns>
        public static string SignBase64(this string base64, string mediaType, string charSet)
        {
            return "data:" + mediaType + ";charset=" + charSet + ";base64," + base64;
        }
        /// <summary>
        /// HTML Tag a given signed <paramref name="SignedBase64String"/> string.<br />
        /// Set the img alt text to <paramref name="alt"/> and set any <paramref name="customCss"/> on img.<br />
        /// Set width and height via <paramref name="width"/> and <paramref name="height"/>
        /// </summary>
        /// <param name="SignedBase64String"></param>
        /// <param name="alt"></param>
        /// <param name="customCss"></param>
        /// <param name="width"></param>
        /// <param name="height"></param>
        /// <returns></returns>
        public static string TagSignBase64(this string SignedBase64String, string alt, string customCss = "", string width = "100%", string height = "auto")
        {
            return "<img" +
                $" src=\"{SignedBase64String}\"" +
                $" width=\"{width}\"" +
                $" height=\"{height}\"" +
                $" alt=\"{alt}\"" +
                (customCss != string.Empty ? $" style=\"{customCss}\"" : "") +
                "/>";
        }
        public static string LinkHTMLImg(this string HTMLImgTag, string link = null)
        {
            if (link == null) return HTMLImgTag;
            const string HTML_A = @"<a href=""##LINK##"">##IMG##</a>";
            StringBuilder str = new StringBuilder();

            str.Append(HTML_A);
            str.Replace("##IMG##", HTMLImgTag);
            str.Replace("##LINK##", link);
            return str.ToString();
        }
        /// <summary>
        /// Converts given <paramref name="imgFormat"/> to image/type
        /// </summary>
        /// <param name="imgFormat"></param>
        /// <returns></returns>
        public static string ImageFormatToString(this ImageFormat imgFormat)
        {
            StringBuilder mediaType = new StringBuilder();
            mediaType.Append("image/");

            if (imgFormat == ImageFormat.MemoryBmp || imgFormat == ImageFormat.Bmp)
                mediaType.Append("bmp");
            if (imgFormat == ImageFormat.Emf)
                mediaType.Append("jpeg");
            if (imgFormat == ImageFormat.Wmf)
                mediaType.Append("x-wmf");
            if (imgFormat == ImageFormat.Gif)
                mediaType.Append("gif");
            if (imgFormat == ImageFormat.Jpeg)
                mediaType.Append("jpeg");
            if (imgFormat == ImageFormat.Png)
                mediaType.Append("png");
            if (imgFormat == ImageFormat.Tiff)
                mediaType.Append("tiff");
            if (imgFormat == ImageFormat.Exif)
                mediaType.Append("jpeg");
            if (imgFormat == ImageFormat.Icon)
                mediaType.Append("x-icon");

            return mediaType.ToString();
        }
    }
}
Aquaphor
  • 129
  • 10