use serde_json::{json, Value};
use super::{Canvas, Format, RenderRequest};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RpcError {
MethodNotFound(String),
InvalidParams(String),
Internal(String),
}
impl RpcError {
fn code(&self) -> i32 {
match self {
Self::MethodNotFound(_) => -32601,
Self::InvalidParams(_) => -32602,
Self::Internal(_) => -32603,
}
}
fn message(&self) -> &str {
match self {
Self::MethodNotFound(m) | Self::InvalidParams(m) | Self::Internal(m) => m,
}
}
pub fn to_json(&self) -> Value {
json!({ "code": self.code(), "message": self.message() })
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RenderParams {
pub request: RenderRequest,
pub pixel_ratio: f64,
}
impl RenderParams {
pub fn from_rpc(params: &Value) -> Result<Self, RpcError> {
let pixel_ratio = params
.get("pixel_ratio")
.and_then(Value::as_f64)
.ok_or_else(|| RpcError::InvalidParams("render needs a pixel_ratio".into()))?;
let format = match params.get("format").and_then(Value::as_str) {
Some("png") => Format::Png,
Some("jpeg") => Format::Jpeg,
Some("svg") => Format::Svg,
Some("pdf") => Format::Pdf,
Some("tiff") => Format::Tiff,
Some(other) => {
return Err(RpcError::InvalidParams(format!(
"render cannot produce the format '{other}'"
)))
}
None => return Err(RpcError::InvalidParams("render needs a format".into())),
};
let size = params.get("size").filter(|s| !s.is_null());
let canvas = match size {
Some(size) => {
let dimension = |key: &str| size.get(key).and_then(Value::as_f64).unwrap_or(0.0);
Canvas::from_logical(dimension("width"), dimension("height"), pixel_ratio)
}
None => {
let default = Canvas::default();
Canvas::from_logical(
f64::from(default.width),
f64::from(default.height),
pixel_ratio,
)
}
};
Ok(Self {
request: RenderRequest { format, canvas },
pixel_ratio,
})
}
pub fn available(mut self, backend_raster: bool) -> Self {
self.request.format = self.request.format.available(backend_raster);
self
}
pub fn encode(bytes: &[u8]) -> String {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
STANDARD.encode(bytes)
}
pub fn to_result(self, encoded: String) -> Value {
json!({
"data": encoded,
"mime_type": self.request.format.mime(),
"settings": {
"size": {
"width": self.request.canvas.css_size().0,
"height": self.request.canvas.css_size().1,
},
"pixel_ratio": self.pixel_ratio,
"format": format_name(self.request.format),
}
})
}
}
pub fn format_name(format: Format) -> &'static str {
match format {
Format::Png => "png",
Format::Jpeg => "jpeg",
Format::Svg => "svg",
Format::Pdf => "pdf",
Format::Tiff => "tiff",
}
}
#[derive(Debug, Clone)]
pub struct PlotMetadata {
pub name: String,
pub execution_id: String,
pub code: String,
}
impl PlotMetadata {
pub fn to_result(&self) -> Value {
json!({
"name": self.name,
"kind": "ggsql",
"execution_id": self.execution_id,
"code": self.code,
})
}
}
pub fn plot_name(title: Option<&str>, sequence: u32) -> String {
match title.map(str::trim).filter(|t| !t.is_empty()) {
Some(title) => title.to_string(),
None => format!("ggsql {sequence}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_render_request_becomes_a_canvas() {
let params =
json!({"size": {"width": 800, "height": 600}, "pixel_ratio": 2.0, "format": "png"});
let parsed = RenderParams::from_rpc(¶ms).unwrap();
assert_eq!(parsed.request.format, Format::Png);
assert_eq!(parsed.request.canvas.width, 1600);
assert_eq!(parsed.request.canvas.height, 1200);
assert_eq!(parsed.request.canvas.dpi, 192.0);
}
#[test]
fn a_ratio_of_one_leaves_the_size_alone() {
let params =
json!({"size": {"width": 800, "height": 600}, "pixel_ratio": 1, "format": "png"});
let parsed = RenderParams::from_rpc(¶ms).unwrap();
assert_eq!(parsed.request.canvas.width, 800);
assert_eq!(parsed.request.canvas.dpi, 96.0);
}
#[test]
fn an_absent_size_falls_back_rather_than_failing() {
let parsed = RenderParams::from_rpc(&json!({"pixel_ratio": 1, "format": "png"})).unwrap();
assert_eq!(parsed.request.canvas.width, Canvas::default().width);
let with_null =
RenderParams::from_rpc(&json!({"size": null, "pixel_ratio": 1, "format": "png"}))
.unwrap();
assert_eq!(with_null.request.canvas, parsed.request.canvas);
}
#[test]
fn a_transient_zero_size_is_clamped_not_rendered() {
let parsed = RenderParams::from_rpc(
&json!({"size": {"width": 0, "height": 0}, "pixel_ratio": 1, "format": "png"}),
)
.unwrap();
assert_eq!(parsed.request.canvas.width, 32);
assert_eq!(parsed.request.canvas.height, 32);
}
#[test]
fn every_format_positron_defines_is_accepted() {
let cases = [
("png", Format::Png),
("jpeg", Format::Jpeg),
("svg", Format::Svg),
("pdf", Format::Pdf),
("tiff", Format::Tiff),
];
for (name, expected) in cases {
let params = json!({"pixel_ratio": 1, "format": name});
assert_eq!(
RenderParams::from_rpc(¶ms).unwrap().request.format,
expected,
"{name}"
);
assert_eq!(format_name(expected), name);
}
}
#[test]
fn a_format_we_cannot_produce_is_invalid_params() {
let err = RenderParams::from_rpc(&json!({"pixel_ratio": 1, "format": "webp"})).unwrap_err();
assert!(matches!(err, RpcError::InvalidParams(_)));
assert_eq!(err.code(), -32602);
assert!(err.message().contains("webp"), "{}", err.message());
}
#[test]
fn the_required_params_are_required() {
for params in [json!({"format": "png"}), json!({"pixel_ratio": 1})] {
let err = RenderParams::from_rpc(¶ms).unwrap_err();
assert!(matches!(err, RpcError::InvalidParams(_)), "{params}");
}
}
#[test]
fn a_reply_names_the_format_it_actually_produced() {
let parsed = RenderParams::from_rpc(
&json!({"size": {"width": 400, "height": 300}, "pixel_ratio": 2, "format": "png"}),
)
.unwrap();
let result = parsed.to_result("AAAA".into());
assert_eq!(result["mime_type"], "image/png");
assert_eq!(result["data"], "AAAA");
assert_eq!(result["settings"]["size"]["width"], 400);
assert_eq!(result["settings"]["size"]["height"], 300);
assert_eq!(result["settings"]["pixel_ratio"], 2.0);
assert_eq!(result["settings"]["format"], "png");
}
#[test]
fn the_panes_render_settings_parse_as_a_render_request() {
let settings = json!({
"size": {"width": 500, "height": 400},
"pixel_ratio": 2,
"format": "png"
});
let parsed = RenderParams::from_rpc(&settings).unwrap();
assert_eq!(parsed.request.canvas.width, 1000);
assert_eq!(parsed.request.canvas.height, 800);
assert_eq!(parsed.request.format, Format::Png);
let result = parsed.to_result("AAAA".into());
assert_eq!(result["settings"]["size"]["width"], 500);
assert_eq!(result["settings"]["size"]["height"], 400);
assert!(result["settings"].get("pixel_ratio").is_some());
}
#[test]
fn svg_is_base64_encoded_like_every_other_format() {
let encoded = RenderParams::encode(b"<svg/>");
assert_eq!(encoded, "PHN2Zy8+");
assert!(
!encoded.starts_with('<'),
"svg must not travel as text here"
);
}
#[test]
fn an_unknown_method_is_method_not_found() {
let err = RpcError::MethodNotFound("hover".into());
assert_eq!(err.code(), -32601);
assert_eq!(err.to_json()["code"], -32601);
}
#[test]
fn metadata_names_a_titled_plot_by_its_title() {
assert_eq!(plot_name(Some("Sales by region"), 3), "Sales by region");
assert_eq!(plot_name(None, 3), "ggsql 3");
assert_eq!(plot_name(Some(" "), 7), "ggsql 7");
}
#[test]
fn metadata_declares_its_kind() {
let metadata = PlotMetadata {
name: "ggsql 1".into(),
execution_id: "abc-123".into(),
code: "SELECT 1 VISUALISE …".into(),
};
let result = metadata.to_result();
assert_eq!(result["kind"], "ggsql");
assert_eq!(result["name"], "ggsql 1");
assert_eq!(result["execution_id"], "abc-123");
assert!(result["code"].as_str().unwrap().starts_with("SELECT"));
}
}