java/kotlin generates the optimal solution for echarts pictures

1. Method exploration

There are not many ways to generate images in the background. According to my search on the Internet, there are the following methods:

  1. The front-end service provides an interface, combines the generated images provided by the chart, and returns image data after request.
  2. Building a service is similar to the first point, and it also sends data.
  3. If there is a matching front-end service, image data can be generated when the front-end initiates a download and sent back to the background.
  4. Utilize phantomjs, organize the chart data into html, and then combine it with the corresponding javascript script to generate images. This method is also the method to be introduced in this article.

After comparing these methods, I found that phantomjsthis method has greater advantages.

First, it does not need to rely on additional services.

Second, the generation method is independently controllable. You can flexibly process the data and control the quality and size of the generated images.

Third, the scope of application is wider. You can not only use echarts, but also highchart, including but not limited to charts, as long as you can find a graphical method.

The only drawback is that you need to install it.

2. Source of inspiration

This article is inspired by the ECharts-Java issuse . In the process of looking for how to generate front-end chart images on the backend, I found this issue, and was guided by one of the authors, incandescentxxc , to the Snapshot-PhantomJS project.

But I didn’t use it directly Snapshot-PhantomJS. Since it doesn’t have much source code, I chose to absorb the core source code and reduce and optimize it accordingly.

3. Required tools

Echarts-Java

< dependency > 
  < groupId > org.icepear.echarts </ groupId > 
  < artifactId > echarts-java </ artifactId > 
  < version > 1.0.7 </ version > 
</ dependency >

This tool does two things:

  1. Conveniently organize data into options required by echarts.
  2. The option can be converted into the required html, and the chart can be opened directly in the browser.

If you use slf4j, it’s best to remove them all org.slf4j, otherwise there will be conflict problems. (I think this problem should not arise. The third-party jar itself should take this issue into consideration)

phantomjs

The function is somewhat equivalent to a browser running in the background, running the html interface in the background.

javascript script

var page = require ( "webpage" ). create ();
 var system = require ( "system" );

var file_type = system. args [ 1 ];
 var delay = system. args [ 2 ];
 var pixel_ratio = system. args [ 3 ];

var snapshot =
     " function(){" +
     " var ele = document.querySelector('div[_echarts_instance_]');" +
     " var mychart = echarts.getInstanceByDom(ele);" +
     " return mychart.getDataURL({type: '" + file_type + "', pixelRatio: " + pixel_ratio + ", excludeComponents: ['toolbox']});" +
     " }" ;
 var file_content = system. stdin . read ();
page.setContent (file_content, " " );

window . setTimeout ( function () {
     var content = page.evaluateJavaScript(snapshot);
    phantom.exit ( );
}, delay);

The function of this script is to generate a webpage internally, which is used to load the HTML containing the chart data you passed. After waiting for a period of time to complete the loading, the image is obtained dataURL, which is actually the base64 data of the image.

4. Demo code

Since I feel that java code writing demonstrations are too cumbersome, I use kotlin for demonstrations.

val bar = Bar()
        .setLegend()
        .setTooltip( "item" )
        .addXAxis(arrayOf( "Matcha Latte" , "Milk Tea" , "Cheese Cocoa" , "Walnut Brownie" ))
        .addYAxis()
        .addSeries( "2015" , arrayOf<Number>( 43.3 , 83.1 , 86.4 , 72.4 ))
        .addSeries( "2016" , arrayOf<Number>( 85.8 , 73.4 , 65.2 , 53.9 ))
        .addSeries( "2017" , arrayOf<Number>( 93.7 , 55.1 , 82.5 , 39.1 ))
     val engine = Engine()

    val html = engine.renderHtml(bar)

    val process = ProcessBuilder( "phantomjs" , "generate-images.js" , "jpg" , "10000" , "10" ).start()
    BufferedWriter(OutputStreamWriter(process.outputStream)).use {
        it.write(html)
    }
    val result = process.inputStream.use { IoUtil.read(it, Charset.defaultCharset()) }
     val contentArray = result.split( "," .toRegex()).dropLastWhile { it.isEmpty() }
     if (contentArray. size != 2 ) {
         throw RuntimeException( "wrong image data" )
    }

    val imageData = contentArray[ 1 ]

    FileUtil.writeBytes(Base64.decode(imageData), File( "test.jpg" ))

IoUtil and FileUtil both come from hutool

Explain the command line parameters:

  1. phantomjs, the execution path of phantomjs.
  2. generate-images.js is the javascript script mentioned above.
  3. jpg is the image format you need. For svg, you need to modify the javascript script yourself.
  4. 10000 is the delay time. This time is reserved for HTML loading. The time consuming includes downloading the echarts script and image generation.
  5. 10. Image quality, the larger the image, the higher the quality, and the larger the image size.

You can see that after my streamlining, the overall code is relatively simple.

5. Optimization process

The demo code above cannot be called the final version.

You have to face two problems:

  1. The generated HTML needs to be connected to the Internet to download echarts. Not to mention that this part is time-consuming, and some environments are also faced with the situation of being unable to connect to the Internet.
  2. The size of a picture with a quality of 10 can reach more than 40M, which is definitely unacceptable.

Use local echarts library

Here you only need to download the file and make targeted replacements.

val html = engine.renderHtml(bar)
        .replace(Regex( "(?is)<script src.+?/script>" ), """<script src="file://echart.min.js"></script>""" )

Compress jpg

Since the generated pictures are very simple, this also means that the compression space is very huge. After my own testing, a picture of about 40M can be reduced in size to a few hundred K after compression, and the picture quality will basically not be affected.

I will give the implementation code directly.

fun  removeAlpha (img: BufferedImage ) : BufferedImage {
     if (!img.colorModel.hasAlpha()) {
         return img
    }
    val target = BufferedImage(img.width, img.height, BufferedImage.TYPE_INT_RGB)
     val g = target.createGraphics()
    g.fillRect( 0 , 0 , img.width, img.height)
    g.drawImage(img, 0 , 0 , null )
    g.dispose()
    return target
}

fun  compress (imageData: ByteArray ) : ByteArray {
     return ByteArrayOutputStream().use { compressed ->
         // Compress the image. The original image is too large. After compression, the volume is reduced without much loss of quality.
        ImageIO.createImageOutputStream(compressed).use {
            val jpgWriter = ImageIO.getImageWritersByFormatName( "JPEG" ).next()
            jpgWriter.output = it

            val jpgWriteParam = jpgWriter.defaultWriteParam
            jpgWriteParam.compressionMode = ImageWriteParam.MODE_EXPLICIT
            jpgWriteParam.compressionQuality = 0.7f

            val img = ByteArrayInputStream(imageData).use {
                 // Remove the original alpha channel 
                IIOImage(removeAlpha(ImageIO.read(it)), null , null )
            }
            jpgWriter.write( null , img, jpgWriteParam)
            jpgWriter.dispose()
            compressed.toByteArray()
        }
    }
}

Because the prerequisite for compressing images is that they cannot contain alpha channels, I found a way to remove the channels online.

Optimization time-consuming

In fact, I originally finished writing it above, but as inspiration came, I solved this problem along the way.

If you fully understand the above example, you will find that there is a big problem in time-consuming processing in this example:

  1. The time consuming is uncontrollable and there is no way to know when the chart is rendered.
  2. The time consumption can only be fixed. Even if the image is rendered earlier than the time you set, you still need to wait for a long time.
  3. There is an animation in the chart rendering process. If you shorten the time based on the above, you may get a picture in the middle of the animation. We use it in the backend and can completely save this part of the time.

Therefore, I made further optimizations to address these issues.

Before that, you need to know phantomjsthat it can monitor some events of the webpage. One of the events is that [onConsoleMessage](https://phantomjs.org/api/webpage/handler/on-console-message.html)it can capture the printing event of the webpage and obtain the printing information.

At the same time, echarts also provides rendering end events finished .

In this way, you can fully control the time-consuming problems caused by rendering.

The optimized script is as follows. At the same time, I also set a maximum timeout for the script. If the rendering is not completed within this time, it will be forced to end to prevent freezing. I also abandoned the configuration of quality and image format. Put them in the echarts finishedevent.

var page = require ( "webpage" ). create ();
 var system = require ( "system" );

var delay = system.args [ 1 ];

var file_content = system.stdin.read ( ) ;

page.setContent (file_content, " " );
page.onConsoleMessage = function ( msg ) {
     console.log ( msg );
    phantom.exit ( );
};

window . setTimeout ( function () {
    phantom.exit ( );
}, delay);

At this point, we use a custom script to finishedadd the missing elements of the previous html.

val script = """
        <script type="text/javascript">
        var chart = echarts.init(document.getElementById("display-container"));
        var option = ${engine.renderJsonOption(bar)} ;
        chart.on("finished", function () { console.log(chart.getDataURL({type: "jpg", pixelRatio: "10", excludeComponents: ["toolbox"]})) });
        chart.setOption(option);
        </script>
    """ .trimIndent().replace( "\n" , "" )

Finally, set the cancellation animation to further shorten the generation time.

bar.option.animation = false

At this point, the action that originally took more than ten seconds to complete now only takes 6 seconds (tested on MacBook Pro m1).

The complete code of kotlin

import cn.hutool.core.codec.Base64
import cn.hutool.core.io.FileUtil
import cn.hutool.core.io.IoUtil
import org.icepear.echarts.Bar
import org.icepear.echarts.render.Engine
import java .awt.image.BufferedImage
import java.io.BufferedWriter
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.OutputStreamWriter
import java.nio.charset.Charset
import javax.imageio.IIOImage
import javax.imageio.ImageIO
import javax.imageio.ImageWriteParam

fun removeAlpha (img: BufferedImage ) : BufferedImage {
if (!img.colorModel.hasAlpha()) {
return img
}
val target = BufferedImage(img.width, img.height, BufferedImage.TYPE_INT_RGB)
val g = target.createGraphics()
g.fillRect( 0 , 0 , img.width, img.height)
g.drawImage(img, 0 , 0 , null )
g.dispose()
return target
}

fun compress (imageData: ByteArray ) : ByteArray {
return ByteArrayOutputStream().use { compressed ->
// Compress the image. The original image is too large. After compression, the volume is reduced without much loss of quality.
ImageIO.createImageOutputStream(compressed).use {
val jpgWriter = ImageIO.getImageWritersByFormatName( "JPEG" ).next()
jpgWriter.output = it

val jpgWriteParam = jpgWriter.defaultWriteParam
jpgWriteParam.compressionMode = ImageWriteParam.MODE_EXPLICIT
jpgWriteParam.compressionQuality = 0.7f

val img = ByteArrayInputStream(imageData).use {
// Remove the original alpha channel
IIOImage(removeAlpha(ImageIO.read(it)), null , null )
}
jpgWriter.write( null , img, jpgWriteParam)
jpgWriter.dispose()
compressed.toByteArray()
}
}
}

fun main () {

val bar = Bar()
.setLegend()
.setTooltip( "item" )
.addXAxis(arrayOf( "Matcha Latte" , "Milk Tea" , "Cheese Cocoa" , "Walnut Brownie" ))
.addYAxis()
.addSeries( "2015" , arrayOf<Number>( 43.3 , 83.1 , 86.4 , 72.4 ))
.addSeries( "2016" , arrayOf<Number>( 85.8 , 73.4 , 65.2 , 53.9 ))
.addSeries( "2017" , arrayOf<Number>( 93.7 , 55.1 , 82.5 , 39.1 ))

bar.option.animation = false

val engine = Engine()

val script = """
<script type="text/javascript">
var chart = echarts.init(document.getElementById("display-container"));
var option = ${engine.renderJsonOption(bar)} ;
chart.on("finished", function () { console.log(chart.getDataURL({type: "jpg", pixelRatio: "10", excludeComponents: ["toolbox"]})) });
chart.setOption(option);
</script>
""" .trimIndent().replace( "\n" , "" )

val html = engine.renderHtml(bar)
.replace(Regex( "(?is)<script src.+?</script>" ), """<script src="file://echart.min.js"></script>""" )
.replace(Regex( "(?is)<script type.+?</script>" ), script)

println(html)
val processBuilder = ProcessBuilder( "phantomjs" , "generate-images.js" , "10000" )
val process = processBuilder.start()

BufferedWriter(OutputStreamWriter(process.outputStream)).use {
it.write(html)
}

val result = process.inputStream.use { IoUtil.read(it, Charset.defaultCharset()) }
val contentArray = result.split( "," .toRegex()).dropLastWhile { it.isEmpty() }
if (contentArray. size != 2 ) {
throw RuntimeException( "wrong image data" )
}

val imageData = contentArray[ 1 ]

val compressImageData = compress(Base64.decode(imageData))

FileUtil.writeBytes(compressImageData, File( "test.jpg" ))
}