Skip to content
This repository has been archived by the owner on Sep 1, 2021. It is now read-only.

Commit

Permalink
Add a Rack endpoint for exporting charts as images
Browse files Browse the repository at this point in the history
  • Loading branch information
PerfectlyNormal committed Apr 19, 2013
1 parent 1e69cdb commit 1aadbe9
Show file tree
Hide file tree
Showing 4 changed files with 171 additions and 0 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.markdown
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 3.0.1.5 / unreleased

* Add a Rack endpoint for exporting charts to image files

# 3.0.1 / 2013-04-09

* Updated Highcharts to 3.0.1
Expand Down
48 changes: 48 additions & 0 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,54 @@ Or one of the themes

Other than that, refer to the [Highcharts documentation](http://highcharts.com/documentation/how-to-use)

## Export endpoint

This gem contains a Rack endpoint for converting the chart to a downloadable file.
It is not required by default, so to use it, `require
'highcharts/export_endpoint'`

The endpoint is basically a port of the [PHP version made available](https://github.com/highslide-software/highcharts.com/blob/master/exporting-server/php/php-batik/index.php).
It currently needs a lot of cleanup, but it is working fine for me. Your milage
may vary.

It uses [Apache Batik](http://xmlgraphics.apache.org/batik/) for the conversion, which must be
installed separately, as well as a Java Runtime Environment.

It expects to find a JRE in `/usr/bin/java`, and Batik in
`/usr/share/java/batik-rasterizer.jar`, but both paths are configurable.

Example usage in Rails:

# config/routes.rb
require 'highcharts/export_endpoint'

MyRailsApp::Application.routes.draw do
...
mount Highcharts::ExportEndpoint.new({
java_path: "/usr/bin/java",
batik: "/usr/share/java/batik-rasterizer.jar"
}), at: "highcharts-export"
...
end

# When rendering the chart
new Highcharts.Chart({
...
exporting: {
url: "/highcharts-export",
...
}
})

### Cocaine

The exporting endpoint uses [Cocaine](https://github.com/thoughtbot/cocaine) for
handling the command lines and arguments and so on.

I don't know a way to get optional dependencies in the gemspec, so for now
that gets added whether you want it or not. I'd like to get this fixed,
but would also like to avoid `begin; require 'cocaine'; rescue LoadError; ...; end` and similar hacks.

## Licensing

Highcharts, which makes up the majority of this gem, has [its own, separate licensing](http://highcharts.com/license).
Expand Down
1 change: 1 addition & 0 deletions highcharts-rails.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Gem::Specification.new do |s|
s.require_paths = ["lib"]

s.add_dependency "railties", ">= 3.1"
s.add_dependency "cocaine", "~> 0.4.0"

This comment has been minimized.

Copy link
@derekprior

derekprior May 9, 2013

The rack endpoint doesn't strike me as central to the gem's purpose (providing highcharts to the asset pipeline) and is optional. I think it makes sense to move this to a gem that can depend on highcharts-rails. Have you considered this?

This comment has been minimized.

Copy link
@PerfectlyNormal

PerfectlyNormal via email May 9, 2013

Author Owner

This comment has been minimized.

Copy link
@adimichele

adimichele May 28, 2013

FYI, the latests Paperclip release depends on cocaine ~> 0.5.0 so this is creating a conflict. Any chance we can at least make the version dependency less restrictive for now?

This comment has been minimized.

Copy link
@PerfectlyNormal

PerfectlyNormal May 28, 2013

Author Owner

I've been terribly busy lately, and haven't gotten around to fixing this yet. Created an issue (#9) so I don't forget it as quickly, and added a commit that relaxes the version requirement. I'll get a proper version as soon as possible.

This comment has been minimized.

Copy link
@adimichele

adimichele May 28, 2013

No worries - this works for now. Thanks!

s.add_development_dependency "bundler", "~> 1.0"
s.add_development_dependency "rails", ">= 3.1"
end
118 changes: 118 additions & 0 deletions lib/highcharts/export_endpoint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# encoding: utf-8
require 'cocaine'

module Highcharts
class ExportEndpoint
class InsecureSVGError < ArgumentError; end
class MissingLibrary < RuntimeError; end
class FailedToGenerateChart < RuntimeError; end
class InvalidType < ArgumentError; end

attr_reader :output, :options

def initialize(options = {})
@options = default_options.merge(options)
end

def default_options
{
java_path: "/usr/bin/java",
batik: "/usr/share/java/batik-rasterizer.jar",
}
end

def call(env)
with_rescues do
raise MissingLibrary.new("Could not find batik-rasterizer.jar in #{options[:batik].inspect}") unless File.exists?(options[:batik].to_s)

request = Rack::Request.new(env)

filename = request.params["filename"].to_s
filename = "chart" if filename.blank? || filename !~ /\A[A-Za-z0-9\-_ ]+\Z/

type = request.params["type"].to_s
width = request.params["width"].to_i
svg = request.params["svg"].to_s

raise InsecureSVGError.new if svg.index("<!ENTITY") || svg.index("<!DOCTYPE")

if type == "image/svg+xml"
# We were sent SVG from the client, so can just render that back
return [200, {
'Content-Disposition' => "attachment; filename=\"#{filename}.svg\"",
'Content-Type' => 'image/svg+xml'
}, [svg]]
end

width = width > 0 ? width.to_s : "600"
extension = case type
when "image/png" then "png"
when "image/jpeg" then "jpg"
when "application/pdf" then "pdf"
when "image/svg+xml" then "svg"
else raise InvalidType.new("#{type} is not a valid type.")
end

input = write_svg_to_file(svg)
@output = create_output_file(extension)

command.run(batik: options[:batik], outfile: output.path, type: type, width: width, infile: input.path)
input.close
content_length = output.size
output.rewind

raise FailedToGenerateChart.new("Nothing written to file") if !File.exists?(output.path) || content_length < 10

Rack::Response.new(self, 200, {
'Content-Disposition' => "attachment; filename=\"#{filename}.#{extension}\"",
'Content-Type' => type
}).finish
end
end

# Pass the block along to the output file, and
# make sure to close the file afterwards
def each(&block)
output.each(&block)
ensure
output.close
end

def command
Cocaine::CommandLine.new(options[:java_path], "-jar :batik -m :type -d :outfile -w :width :infile")
end

def write_svg_to_file(contents)
file = ::Tempfile.new(["highcharts-input", ".svg"], Dir.tmpdir, encoding: 'utf-8')
file.puts contents
file.flush
file
end

def create_output_file(extension)
file = ::Tempfile.new(["highcharts-chart", ".#{extension}"])
file.binmode
file
end

def with_rescues
yield
rescue Highcharts::ExportEndpoint::InsecureSVGError => e
[400, {'Content-Type' => 'text/plain'}, ["The posted SVG could contain code for a malicious attack"]]
rescue Highcharts::ExportEndpoint::InvalidType => e
[400, {'Content-Type' => 'text/plain'}, [e]]
rescue Cocaine::CommandNotFoundError => e
[503, {'Content-Type' => 'text/plain'}, ["Unable to find required binary. #{e}"]]
rescue Highcharts::ExportEndpoint::MissingLibrary => e
[503, {'Content-Type' => 'text/plain'}, ["Unable to find required library. #{e}"]]
rescue Highcharts::ExportEndpoint::FailedToGenerateChart => e
[500, {'Content-Type' => 'text/plain'}, ["Failed to generate chart. More details may be available in the server logs."]]
rescue => e
[500, {'Content-Type' => 'text/plain'}, ["Something went wrong. More details may be available in the server logs."]]
end

def error(code, message)
[code, {}, [message].flatten]
end
end
end

0 comments on commit 1aadbe9

Please sign in to comment.