Logic for rendering HTML to a PNG. Ported by @tlrobinson from https://github.com/radkovo/CSSBox/blob/cssbox-4.10/src/main/java/org/fit/cssbox/demo/ImageRenderer.java with subsequent code simplification and cleanup by @camsaul CSSBox JavaDoc is here: http://cssbox.sourceforge.net/api/index.html | (ns metabase.channel.render.png (:require [clojure.walk :as walk] [hiccup.core :refer [html]] [metabase.channel.render.body :as body] [metabase.channel.render.style :as style] [metabase.util.log :as log] [metabase.util.malli :as mu]) (:import (cz.vutbr.web.css MediaSpec) (java.awt Font GraphicsEnvironment Graphics2D RenderingHints) (java.awt.image BufferedImage) (java.io ByteArrayInputStream ByteArrayOutputStream) (java.nio.charset StandardCharsets) (javax.imageio ImageIO) (org.fit.cssbox.awt GraphicsEngine) (org.fit.cssbox.css CSSNorm DOMAnalyzer DOMAnalyzer$Origin) (org.fit.cssbox.io DefaultDOMSource StreamDocumentSource) (org.fit.cssbox.layout Dimension) (org.w3c.dom Document))) |
(set! *warn-on-reflection* true) | |
(defn- write-image! [^BufferedImage image, ^String format-name, ^ByteArrayOutputStream output-stream] (ImageIO/write image format-name output-stream)) | |
(defn- dom-analyzer ^DOMAnalyzer [^Document doc, ^StreamDocumentSource doc-source, ^Dimension window-size] (doto (DOMAnalyzer. doc (.getURL doc-source)) (.setMediaSpec (doto (MediaSpec. "screen") (.setDimensions (.width window-size) (.height window-size)) (.setDeviceDimensions (.width window-size) (.height window-size)))) .attributesToStyles (.addStyleSheet nil (CSSNorm/stdStyleSheet) DOMAnalyzer$Origin/AGENT) (.addStyleSheet nil (CSSNorm/userStyleSheet) DOMAnalyzer$Origin/AGENT) (.addStyleSheet nil (CSSNorm/formsStyleSheet) DOMAnalyzer$Origin/AGENT) .getStyleSheets)) | |
(defn- render-to-png ^java.awt.image.BufferedImage [^String html width] (style/register-fonts-if-needed!) (with-open [is (ByteArrayInputStream. (.getBytes html StandardCharsets/UTF_8)) doc-source (StreamDocumentSource. is nil "text/html; charset=utf-8")] (let [dimension (Dimension. width 1) doc (.parse (DefaultDOMSource. doc-source)) da (dom-analyzer doc doc-source dimension) graphics-engine (proxy [GraphicsEngine] [(.getRoot da) da (.getURL doc-source)] (setupGraphics [^Graphics2D g] (doto g (.setRenderingHint RenderingHints/KEY_RENDERING RenderingHints/VALUE_RENDER_QUALITY) (.setRenderingHint RenderingHints/KEY_ALPHA_INTERPOLATION RenderingHints/VALUE_ALPHA_INTERPOLATION_QUALITY) (.setRenderingHint RenderingHints/KEY_TEXT_ANTIALIASING RenderingHints/VALUE_TEXT_ANTIALIAS_GASP) (.setRenderingHint RenderingHints/KEY_FRACTIONALMETRICS RenderingHints/VALUE_FRACTIONALMETRICS_ON))))] (.createLayout graphics-engine dimension) (let [image (.getImage graphics-engine) viewport (.getViewport graphics-engine) ;; CSSBox voodoo -- sometimes maximal width < minimal width, no idea why content-width (max (int (.getMinimalWidth viewport)) (int (.getMaximalWidth viewport)))] ;; Crop the image to the actual size of the rendered content so that tables don't have a ton of whitespace. (if (< content-width (.getWidth image)) (.getSubimage image 0 0 content-width (.getHeight image)) image))))) | |
(defn- font-can-fully-render? [^Font font s] (neg? (.canDisplayUpTo font s))) | |
(def ^:private get-lato (letfn [(get-lato* [] (let [lato-names #{"Lato Regular" "Lato-Regular" "lato" "lato-regular"} env (GraphicsEnvironment/getLocalGraphicsEnvironment) fonts (.getAllFonts env) font ^Font (some #(when (lato-names (.getName ^Font %)) %) fonts)] font))] (memoize get-lato*))) | |
(defn- lato-can-render? [s] (let [lato (get-lato)] (when lato (font-can-fully-render? lato s)))) | |
Wrap characters not supported by the installed Lato font in a span so that we can explicitly set the font to sans-serif. We do this to work around unexpected font-fallback behaviours in CSSBox. Lato is properly loaded/registered in If a given string, inside a | (defn- wrap-non-lato-chars [content] (let [transformable-els #{:div :span :td :th :tr :table :p :tbody :thead} string-wrapper (fn [part] (if (and (string? part) (not (lato-can-render? part))) [:span {:style (style/style {:font-family "sans-serif"})} part] part))] (walk/postwalk (fn [form] (if (and (not (map-entry? form)) (vector? form) (transformable-els (first form))) (mapv string-wrapper form) form)) content))) |
(mu/defn render-html-to-png :- bytes? "Render the Hiccup HTML `content` of a Pulse to a PNG image, returning a byte array." ^bytes [{:keys [content]} :- body/RenderedPartCard width] (try (let [html (html [:html [:body {:style (style/style {:font-family "Lato, 'Helvetica Neue', 'Lucida Grande', sans-serif" :margin 0 :padding 0 :background-color :white})} (wrap-non-lato-chars content)]])] (with-open [os (ByteArrayOutputStream.)] (-> (render-to-png html width) (write-image! "png" os)) (.toByteArray os))) (catch Throwable e (log/error e "Error rendering Pulse") (throw e)))) | |