diff --git a/Project.toml b/Project.toml index 7061f34b..093d82be 100644 --- a/Project.toml +++ b/Project.toml @@ -4,10 +4,12 @@ authors = ["Spencer Lyon "] version = "0.18.0" [deps] +Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" Blink = "ad839575-38b3-5650-b840-f874b8c74a25" DelimitedFiles = "8bb1440f-4735-579b-a4ab-409b98df4dab" JSExpr = "97c1335a-c9c5-57fe-bc5d-ec35cebe8660" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +Kaleido_jll = "f7e6163d-2fa5-5f23-b69c-1db539e41963" Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" PlotlyBase = "a03496cd-edff-5a9b-9e67-9cda94a718b5" @@ -20,7 +22,7 @@ WebIO = "0f1e0344-ec1d-5b48-a673-e5cf874b6c29" Blink = "0.12" JSExpr = "0.5" JSON = "0.20, 0.21" -PlotlyBase = "0.6, 0.7" +PlotlyBase = "0.6, ^0.7" Reexport = "0.2, 1" Requires = "1.0" WebIO = "0.8" diff --git a/src/PlotlyJS.jl b/src/PlotlyJS.jl index ca02931a..7be6b29f 100644 --- a/src/PlotlyJS.jl +++ b/src/PlotlyJS.jl @@ -1,5 +1,6 @@ module PlotlyJS +using Base64 using Reexport @reexport using PlotlyBase using JSON @@ -34,6 +35,7 @@ struct PlotlyJSDisplay <: AbstractDisplay end # include the rest of the core parts of the package include("display.jl") include("util.jl") +include("kaleido.jl") make_subplots(;kwargs...) = plot(Layout(Subplots(;kwargs...))) @@ -50,19 +52,6 @@ function docs() Blink.content!(w, "html", open(f -> read(f, String), schema_path), fade=false, async=false) end -PlotlyBase.savefig(p::SyncPlot, a...; k...) = savefig(p.plot, a...; k...) -PlotlyBase.savefig(io::IO, p::SyncPlot, a...; k...) = savefig(io, p.plot, a...; k...) - -for (mime, fmt) in PlotlyBase._KALEIDO_MIMES - @eval function Base.show( - io::IO, ::MIME{Symbol($mime)}, plt::SyncPlot, - width::Union{Nothing,Int}=nothing, - height::Union{Nothing,Int}=nothing, - scale::Union{Nothing,Real}=nothing, - ) - savefig(io, plt.plot, format=$fmt) - end -end @enum RENDERERS BLINK IJULIA BROWSER DOCS @@ -106,6 +95,8 @@ function __init__() @warn("Warnings were generated during the last build of PlotlyJS: please check the build log at $_build_log") end + @async _start_kaleido_process() + if !isfile(_js_path) @info("plotly.js javascript libary not found -- downloading now") include(joinpath(_pkg_root, "deps", "build.jl")) diff --git a/src/kaleido.jl b/src/kaleido.jl new file mode 100644 index 00000000..746b6b8f --- /dev/null +++ b/src/kaleido.jl @@ -0,0 +1,219 @@ +using Kaleido_jll + +mutable struct Pipes + stdin::Pipe + stdout::Pipe + stderr::Pipe + proc::Base.Process + Pipes() = new() +end + +const P = Pipes() + +const ALL_FORMATS = ["png", "jpeg", "webp", "svg", "pdf", "eps", "json"] +const TEXT_FORMATS = ["svg", "json", "eps"] + +function _restart_kaleido_process() + if isdefined(P, :proc) && process_running(P.proc) + kill(P.proc) + end + _start_kaleido_process() +end + + +function _start_kaleido_process() + global P + try + BIN = let + art = Kaleido_jll.artifact_dir + cmd = if Sys.islinux() || Sys.isapple() + joinpath(art, "kaleido") + else + # Windows + joinpath(art, "kaleido.cmd") + end + no_sandbox = "--no-sandbox" + Sys.isapple() ? `$(cmd) plotly --disable-gpu --single-process` : `$(cmd) plotly --disable-gpu $(no_sandbox)` + end + kstdin = Pipe() + kstdout = Pipe() + kstderr = Pipe() + kproc = run(pipeline(BIN, + stdin=kstdin, stdout=kstdout, stderr=kstderr), + wait=false) + process_running(kproc) || error("There was a problem startink up kaleido.") + close(kstdout.in) + close(kstderr.in) + close(kstdin.out) + Base.start_reading(kstderr.out) + P.stdin = kstdin + P.stdout = kstdout + P.stderr = kstderr + P.proc = kproc + + # read startup message and check for errors + res = readline(P.stdout) + if length(res) == 0 + error("Could not start Kaleido process") + end + + js = JSON.parse(res) + if get(js, "code", 0) != 0 + error("Could not start Kaleido process") + end + catch e + @warn "Kaleido is not available on this system. Julia will be unable to save images of any plots." + @warn "$e" + end + nothing +end + +function savefig( + p::Plot; + width::Union{Nothing,Int}=nothing, + height::Union{Nothing,Int}=nothing, + scale::Union{Nothing,Real}=nothing, + format::String="png" + )::Vector{UInt8} + if !(format in ALL_FORMATS) + error("Unknown format $format. Expected one of $ALL_FORMATS") + end + + # construct payload + _get(x, def) = x === nothing ? def : x + payload = Dict( + :width => _get(width, 700), + :height => _get(height, 500), + :scale => _get(scale, 1), + :format => format, + :data => p + ) + + _ensure_kaleido_running() + + # convert payload to vector of bytes + bytes = transcode(UInt8, JSON.json(payload)) + write(P.stdin, bytes) + write(P.stdin, transcode(UInt8, "\n")) + flush(P.stdin) + + # read stdout and parse to json + res = readline(P.stdout) + js = JSON.parse(res) + + # check error code + code = get(js, "code", 0) + if code != 0 + msg = get(js, "message", nothing) + error("Transform failed with error code $code: $msg") + end + + # get raw image + img = String(js["result"]) + + # base64 decode if needed, otherwise transcode to vector of byte + if format in TEXT_FORMATS + return transcode(UInt8, img) + else + return base64decode(img) + end +end + +""" + savefig( + io::IO, + p::Plot; + width::Union{Nothing,Int}=nothing, + height::Union{Nothing,Int}=nothing, + scale::Union{Nothing,Real}=nothing, + format::String="png" + ) + +Save a plot `p` to the io stream `io`. They keyword argument `format` +determines the type of data written to the figure and must be one of +$(join(ALL_FORMATS, ", ")), or html. `scale` sets the +image scale. `width` and `height` set the dimensions, in pixels. Defaults +are taken from `p.layout`, or supplied by plotly +""" +function savefig(io::IO, + p::Plot; + width::Union{Nothing,Int}=nothing, + height::Union{Nothing,Int}=nothing, + scale::Union{Nothing,Real}=nothing, + format::String="png") + + + if format == "html" + return show(io, MIME("text/html"), p, include_mathjax="cdn", include_plotlyjs="cdn", full_html=true) + end + + bytes = savefig(p, width=width, height=height, scale=scale, format=format) + write(io, bytes) +end + +""" + savefig( + p::Plot, fn::AbstractString; + format::Union{Nothing,String}=nothing, + width::Union{Nothing,Int}=nothing, + height::Union{Nothing,Int}=nothing, + scale::Union{Nothing,Real}=nothing, + ) + +Save a plot `p` to a file named `fn`. If `format` is given and is one of +$(join(ALL_FORMATS, ", ")), or html; it will be the format of the file. By +default the format is guessed from the extension of `fn`. `scale` sets the +image scale. `width` and `height` set the dimensions, in pixels. Defaults +are taken from `p.layout`, or supplied by plotly +""" +function savefig( + p::Plot, fn::AbstractString; + format::Union{Nothing,String}=nothing, + width::Union{Nothing,Int}=nothing, + height::Union{Nothing,Int}=nothing, + scale::Union{Nothing,Real}=nothing, + ) + ext = split(fn, ".")[end] + if format === nothing + format = String(ext) + end + + open(fn, "w") do f + savefig(f, p; format=format, scale=scale, width=width, height=height) + end + return fn +end + +_kaleido_running() = isdefined(P, :stdin) && isopen(P.stdin) && process_running(P.proc) +_ensure_kaleido_running() = !_kaleido_running() && _restart_kaleido_process() + +const _KALEIDO_MIMES = Dict( + "application/pdf" => "pdf", + "image/png" => "png", + "image/svg+xml" => "svg", + "image/eps" => "eps", + "image/jpeg" => "jpeg", + "image/jpeg" => "jpeg", + "application/json" => "json", + "application/json; charset=UTF-8" => "json", +) + +for (mime, fmt) in _KALEIDO_MIMES + @eval function Base.show( + io::IO, ::MIME{Symbol($mime)}, plt::Plot, + width::Union{Nothing,Int}=nothing, + height::Union{Nothing,Int}=nothing, + scale::Union{Nothing,Real}=nothing, + ) + savefig(io, plt, format=$fmt) + end + + @eval function Base.show( + io::IO, ::MIME{Symbol($mime)}, plt::SyncPlot, + width::Union{Nothing,Int}=nothing, + height::Union{Nothing,Int}=nothing, + scale::Union{Nothing,Real}=nothing, + ) + savefig(io, plt.plot, format=$fmt) + end +end diff --git a/test/kaleido.jl b/test/kaleido.jl new file mode 100644 index 00000000..084845d3 --- /dev/null +++ b/test/kaleido.jl @@ -0,0 +1,17 @@ +function myplot(fn) + x = 0:0.1:2π + plt = Plot(scatter(x=x, y=sin.(x))) + savefig(plt, fn) +end + +@testset "kaleido" begin + for ext in [PlotlyBase.ALL_FORMATS..., "html"] + if ext === "eps" + continue + end + @show fn = tempname() * "." * ext + myplot(fn) == fn + @test isfile(fn) + rm(fn) + end +end diff --git a/test/runtests.jl b/test/runtests.jl index e72abaf4..ad0f91b7 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,5 +8,6 @@ using Blink !Blink.AtomShell.isinstalled() && Blink.AtomShell.install() include("blink.jl") +include("kaleido.jl") end