mod advice;
mod architecture;
mod axo;
mod boxplot;
mod calendar;
mod classes;
mod color;
mod contrast;
mod cycle;
mod delta;
mod dendrogram;
mod describe;
mod diagram;
mod diverging;
mod draw;
mod error;
mod exploded;
mod fishbone;
mod flow;
mod funnel;
mod heat;
mod heatmap;
mod inspect;
mod journey;
mod kanban;
mod layers;
mod layout;
mod metrics;
mod mosaic;
mod network;
mod ohlc;
mod parliament;
mod parts;
mod plan;
#[cfg(feature = "png")]
mod png;
mod polar;
pub mod qr;
mod radar;
mod rangebar;
mod readability;
mod reference;
mod render;
mod roles;
mod sankey;
mod scatter;
mod scene;
mod sequence;
mod sha256;
mod slope;
mod social;
mod sort;
mod spec;
mod state;
mod storymap;
mod stripes;
mod structure;
mod survival;
mod symbols;
mod text;
mod theme;
mod time;
mod timeline;
mod tree;
mod treemap;
mod typography;
mod valuemap;
mod venn;
mod waffle;
mod waterfall;
pub use advice::{Help, PatchOperation, Remedy, RemedyKind, advise, advise_json};
use describe::automatic_description;
pub use error::{ChartError, ChartWarning};
pub use inspect::{INSPECTIONS, Inspection, Severity, inspect_json};
pub use metrics::{BuiltinMetrics, TextMetrics};
pub use sha256::sha256;
pub use spec::{
ArchitectureSpec, AreaKind, AreaSpec, AxisScale, BoundarySpec, BoxDisplay, BoxSpec, BranchSpec,
CalendarDay, CalendarLayout, CalendarSpec, CategoryAxisSpec, CategorySpec, ChartSpec,
ChartType, ClassKind, ClassSpec, ClassesSpec, ComponentKind, ComponentSpec,
ConnectionDirection, ConnectionSpec, Curve, CycleHubSpec, CycleLabels, CycleSpec,
CycleStepSpec, Dash, DataPoint, DeltaSpec, DiagramOrientation, DiagramRole, DiagramSpacing,
DistancesSpec, FishboneBoneSpec, FishboneCauseSpec, FishboneSpec, FishboneSubcauseSpec,
FlowEdgeSpec, FlowNodeSpec, FlowSpec, FragmentKind, FragmentSpec, FunnelDisplay, FunnelSpec,
FunnelStageSpec, Gaps, GroupSpec, HeatmapCellSpec, HeatmapOrder, HeatmapScale, HeatmapSpec,
InstanceSpec, InterfaceSpec, ItemKind, ItemSpec, JourneyDisplay, JourneySpec, JourneyStageSpec,
KanbanCardSpec, KanbanColumnSpec, KanbanSpec, LaneSpec, LayerSpec, LayersDisplay,
LayersProjection, LayersSpec, LegendPlacement, Linkage, MainPath, Mark, MarkerSpec,
MessageKind, MessageSpec, MobileSpec, MosaicColumnSpec, MosaicSegmentSpec, MosaicSpec,
NetworkEdgeSpec, NetworkLayout, NetworkNodeSpec, NetworkOrder, NetworkSpec, NodeKind, NoteSpec,
OhlcPoint, Orientation, PackageSpec, PaneSpec, ParliamentSpec, PartSpec, ParticipantKind,
ParticipantSpec, PartySpec, PerspectiveSpec, PlanSpec, PolarDisplay, PolarPointSpec, PolarSpec,
PortSide, RadarAxisSpec, RadarSeriesSpec, RadarSpec, RangeDisplay, RangeSort, RangeSpec,
ReferenceSpec, RelationKind, RelationSpec, SankeyLinkSpec, SankeyNodeSpec, SankeyOrder,
SankeySpec, ScatterAxis, ScatterLineSpec, ScatterPointSpec, ScatterQuadrantLabels,
ScatterQuadrantsSpec, ScatterSpec, ScatterTickSpec, SequenceSpec, SeriesSpec, Shape,
SizePreset, Stack, StackLayerSpec, StateKind, StateNodeSpec, StateSpec, StepKind, StepSpec,
StorymapActivitySpec, StorymapSpec, StorymapStorySpec, StorymapTaskSpec, StripesSpec, Stroke,
StrokeStyle, SurvivalGroupSpec, SurvivalObservationSpec, SurvivalSpec, Theme, TimeAxisKind,
TimeAxisSpec, TimePoint, TimePrecision, TimelineItemSpec, TimelineSpec, Tooltips,
TransitionSpec, TreeLayout, TreeNodeKind, TreeNodeSpec, TreeSpec, TreemapItemSpec, TreemapSpec,
ValueAxisSpec, ValueFormat, ValuemapComponentSpec, ValuemapDependencySpec, ValuemapMoveSpec,
ValuemapSpec, VennRegionSpec, VennSpec, ViewSpec, ViewStepSpec, ViolinScale, WafflePartSpec,
WaffleSpec, WaterfallSpec, ZoomBound, ZoomStep,
};
pub use theme::{apply_theme, bundled_theme};
pub use typography::{FontMetrics, TypographySpec, embed_font};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderFormat {
Svg,
Html,
}
#[derive(Debug, Clone, Default)]
pub struct RenderOptions {
pub id_prefix: Option<String>,
pub table_mode: TableMode,
pub variant: Variant,
pub manifest: bool,
pub styles: Styles,
pub hooks: bool,
pub view: Option<String>,
pub perspective: Option<String>,
pub all_views: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Styles {
#[default]
Inline,
External,
}
#[must_use]
pub fn stylesheet() -> String {
render::shared_stylesheet(&ChartType::ALL, true)
}
#[must_use]
pub fn stylesheet_for(chart_types: &[ChartType]) -> String {
render::shared_stylesheet(chart_types, true)
}
#[must_use]
pub fn stylesheet_common() -> String {
render::shared_stylesheet(&[], true)
}
#[must_use]
pub fn stylesheet_types(chart_types: &[ChartType]) -> String {
render::shared_stylesheet(chart_types, false)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Variant {
#[default]
Desktop,
Mobile,
Print,
Social,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TableMode {
#[default]
Details,
Visible,
}
#[derive(Debug, Clone)]
pub struct RenderOutput {
pub content: String,
pub warnings: Vec<ChartWarning>,
pub manifest: Option<Manifest>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Manifest {
pub chartlet: &'static str,
pub schema_version: u8,
pub spec_hash: String,
pub output_hash: String,
pub format: RenderFormat,
pub variant: Variant,
pub id_prefix: String,
pub view: Option<String>,
pub views: Vec<String>,
pub perspective: Option<String>,
pub theme: Option<String>,
pub warnings: Vec<ChartWarning>,
}
impl Manifest {
#[must_use]
pub fn to_json(&self) -> String {
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Json<'a> {
chartlet: &'a str,
schema_version: u8,
spec_hash: &'a str,
output_hash: &'a str,
format: &'a str,
variant: &'a str,
id_prefix: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
view: Option<&'a str>,
#[serde(skip_serializing_if = "<[String]>::is_empty")]
views: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
perspective: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
theme: Option<&'a str>,
warnings: Vec<Warning<'a>>,
}
#[derive(serde::Serialize)]
struct Warning<'a> {
code: &'a str,
path: &'a str,
message: &'a str,
}
let json = Json {
chartlet: self.chartlet,
schema_version: self.schema_version,
spec_hash: &self.spec_hash,
output_hash: &self.output_hash,
format: match self.format {
RenderFormat::Svg => "svg",
RenderFormat::Html => "html",
},
variant: match self.variant {
Variant::Desktop => "desktop",
Variant::Mobile => "mobile",
Variant::Print => "print",
Variant::Social => "social",
},
id_prefix: &self.id_prefix,
view: self.view.as_deref(),
views: &self.views,
perspective: self.perspective.as_deref(),
theme: self.theme.as_deref(),
warnings: self
.warnings
.iter()
.map(|warning| Warning {
code: warning.code,
path: &warning.path,
message: &warning.message,
})
.collect(),
};
serde_json::to_string_pretty(&json).expect("a manifest always serializes")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextAlternative {
pub description: String,
pub table: DataTable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataTable {
pub caption: String,
pub columns: Vec<String>,
pub rows: Vec<Vec<String>>,
}
pub fn text_alternative(spec: &ChartSpec) -> Result<TextAlternative, ChartError> {
spec.validate()?;
if !spec.has_delta()
&& let Some(view) = spec.resolve_view(None)?
{
return text_alternative(&view.spec);
}
Ok(TextAlternative {
description: spec
.description
.clone()
.unwrap_or_else(|| automatic_description(spec)),
table: render::data_table(spec),
})
}
pub fn render_json(
input: &str,
format: RenderFormat,
options: &RenderOptions,
) -> Result<RenderOutput, ChartError> {
let spec = ChartSpec::from_json(input)?;
render(&spec, format, options)
}
fn family_of(spec: &ChartSpec) -> Result<&str, ChartError> {
spec.typography
.as_ref()
.and_then(|typography| typography.family.as_deref())
.ok_or_else(|| {
ChartError::new(
"invalid_font",
"/typography/family",
"a font file needs the name it is used under: add \"typography\": { \"family\": \"Your Font\" }",
)
})
}
pub fn render_with_font(
spec: &ChartSpec,
format: RenderFormat,
options: &RenderOptions,
font: &[u8],
) -> Result<RenderOutput, ChartError> {
family_of(spec)?;
let metrics = FontMetrics::from_ttf(font)?;
let mut output = render_with_metrics(spec, format, options, &metrics)?;
output
.warnings
.retain(|warning| warning.code != "font_not_embedded");
output
.warnings
.extend(metrics.missing_glyphs(&output.content));
Ok(output)
}
pub fn render(
spec: &ChartSpec,
format: RenderFormat,
options: &RenderOptions,
) -> Result<RenderOutput, ChartError> {
match spec
.typography
.as_ref()
.map(typography::TypographySpec::scale)
{
Some(scale) if (scale - 1.0).abs() > f64::EPSILON => {
render_with_metrics(spec, format, options, &typography::ScaledMetrics(scale))
}
_ => render_with_metrics(spec, format, options, &BuiltinMetrics),
}
}
pub fn render_with_metrics(
spec: &ChartSpec,
format: RenderFormat,
options: &RenderOptions,
metrics: &impl TextMetrics,
) -> Result<RenderOutput, ChartError> {
let model = spec;
let selected;
let spec = match &options.perspective {
Some(key) => {
selected = spec.with_perspective(key)?;
&selected
}
None => spec,
};
if options.all_views {
return render_all_views((model, spec), format, options, metrics);
}
let view = if (spec.has_views() && !spec.has_delta()) || options.view.is_some() {
spec.validate()?;
spec.resolve_view(options.view.as_deref())?
} else {
None
};
let shown = view.as_ref().map_or(spec, |view| &view.spec);
let mut output =
render_content(shown, format, options, metrics).map_err(|error| match &view {
Some(view) => ChartError::new(error.code, view.model_path(&error.path), error.message),
None => error,
})?;
if let Some(view) = &view {
for warning in &mut output.warnings {
warning.path = view.model_path(&warning.path);
}
}
if options.manifest {
let id_prefix = match &options.id_prefix {
Some(id_prefix) => id_prefix.clone(),
None => default_id_prefix(shown)?,
};
let perspective = shown
.architecture
.as_ref()
.and_then(|architecture| architecture.perspective.clone());
output.manifest = Some(Manifest {
view: view.map(|view| view.key),
perspective,
..manifest(model, format, options, &output, id_prefix)?
});
}
Ok(output)
}
fn manifest(
spec: &ChartSpec,
format: RenderFormat,
options: &RenderOptions,
output: &RenderOutput,
id_prefix: String,
) -> Result<Manifest, ChartError> {
Ok(Manifest {
chartlet: env!("CARGO_PKG_VERSION"),
schema_version: spec.schema_version,
spec_hash: format!("sha256:{}", sha256::hex(&sha256(&canonical_json(spec)?))),
output_hash: format!("sha256:{}", sha256::hex(&sha256(output.content.as_bytes()))),
format,
variant: options.variant,
id_prefix,
view: None,
views: Vec::new(),
perspective: None,
theme: None,
warnings: output.warnings.clone(),
})
}
fn render_all_views(
(model, spec): (&ChartSpec, &ChartSpec),
format: RenderFormat,
options: &RenderOptions,
metrics: &impl TextMetrics,
) -> Result<RenderOutput, ChartError> {
let refuse = |message: &str| {
Err(ChartError::new(
"option_not_supported",
"/render/allViews",
message,
))
};
if format == RenderFormat::Svg {
return refuse(
"all views share one HTML page; render each view as an SVG of its own by its key",
);
}
if options.view.is_some() {
return refuse("all views are shown; leave out the view");
}
if options.variant != Variant::Desktop {
return refuse("the page of all views carries the desktop and mobile variants of each");
}
spec.validate()?;
if !spec.has_views() {
return refuse("the specification has no views");
}
let page_prefix = resolve_id_prefix(spec, options)?;
let mut views = vec![
spec.resolve_view(None)?
.expect("a model with views has a default"),
];
for key in spec.view_keys() {
if key != views[0].key {
views.push(
spec.resolve_view(Some(key))?
.expect("the key is that of a view"),
);
}
}
let mut warnings = Vec::new();
let mut sections = Vec::new();
for (number, view) in views.iter().enumerate() {
let id = format!("{page_prefix}-v{}", number + 1);
let output =
render_content_as(&view.spec, format, options, &id, metrics).map_err(|error| {
ChartError::new(error.code, view.model_path(&error.path), error.message)
})?;
warnings.extend(output.warnings.into_iter().map(|warning| ChartWarning {
path: view.model_path(&warning.path),
..warning
}));
sections.push(render::ViewSection {
id,
title: view.spec.title.clone(),
content: output.content,
});
}
dedupe_warnings(&mut warnings);
let mut output = RenderOutput {
content: render::html_views(§ions, spec, &page_prefix),
warnings,
manifest: None,
};
if options.manifest {
output.manifest = Some(Manifest {
views: views.into_iter().map(|view| view.key).collect(),
perspective: options.perspective.clone(),
..manifest(model, format, options, &output, page_prefix)?
});
}
Ok(output)
}
fn render_content(
spec: &ChartSpec,
format: RenderFormat,
options: &RenderOptions,
metrics: &impl TextMetrics,
) -> Result<RenderOutput, ChartError> {
let id_prefix = resolve_id_prefix(spec, options)?;
render_content_as(spec, format, options, &id_prefix, metrics)
}
#[allow(clippy::too_many_lines)]
fn render_content_as(
spec: &ChartSpec,
format: RenderFormat,
options: &RenderOptions,
id_prefix: &str,
metrics: &impl TextMetrics,
) -> Result<RenderOutput, ChartError> {
let mut warnings = spec.validate()?;
warnings.extend(
spec.typography
.as_ref()
.and_then(typography::TypographySpec::disclosure),
);
let id_prefix = id_prefix.to_owned();
let mobile = spec.mobile_variant();
let mobile_prefix = |prefix: &str| format!("{prefix}-m");
let hooks = hook_mode(options.hooks, format);
let parts = |spec: &ChartSpec, prefix: &str, warnings: &mut Vec<ChartWarning>| {
render_panel_parts(
spec,
format,
prefix,
options.styles.into(),
hooks,
metrics,
warnings,
)
};
let panel = |spec: &ChartSpec, prefix: &str, warnings: &mut Vec<ChartWarning>| {
parts(spec, prefix, warnings).svg
};
match options.variant {
Variant::Desktop
if format == RenderFormat::Svg && spec.size.is_some_and(SizePreset::is_print) =>
{
return render_print(spec, format, &id_prefix, warnings, metrics);
}
Variant::Desktop => {}
Variant::Mobile => {
return render_mobile_alone(
mobile,
format,
&mobile_prefix(&id_prefix),
options.styles,
hooks,
metrics,
);
}
Variant::Print => return render_print(spec, format, &id_prefix, warnings, metrics),
Variant::Social => return render_social(spec, format, &id_prefix, metrics),
}
let mut mobile_warnings = match (&mobile, format) {
(Some(mobile), RenderFormat::Html) => mobile.validate_mobile_variant()?,
_ => Vec::new(),
};
if format == RenderFormat::Html && spec.zoom_steps.len() > 1 {
let mut panels = Vec::new();
for (index, step) in spec.zoom_steps.iter().enumerate() {
let sliced = zoom_variant(spec, step);
let panel_prefix = format!("{id_prefix}-z{index}");
let svg = panel(&sliced, &panel_prefix, &mut warnings);
let mobile = mobile.as_ref().map(|mobile| {
panel(
&zoom_variant(mobile, step),
&mobile_prefix(&panel_prefix),
&mut mobile_warnings,
)
});
panels.push(render::Panel {
label: step.label.clone(),
svg,
mobile,
stages: Vec::new(),
});
}
dedupe_warnings(&mut warnings);
merge_mobile_warnings(&mut warnings, mobile_warnings);
return Ok(RenderOutput {
content: render::html_zoom(
&panels,
spec,
options.table_mode,
&id_prefix,
options.hooks,
),
warnings,
manifest: None,
});
}
let desktop = parts(spec, &id_prefix, &mut warnings);
let content = match format {
RenderFormat::Svg => desktop.svg,
RenderFormat::Html => {
let mobile = mobile
.as_ref()
.map(|mobile| parts(mobile, &mobile_prefix(&id_prefix), &mut mobile_warnings));
merge_mobile_warnings(&mut warnings, mobile_warnings);
let stages = stages(&desktop, mobile.as_ref());
render::html(
render::Panel {
label: String::new(),
svg: desktop.svg,
mobile: mobile.map(|mobile| mobile.svg),
stages,
},
spec,
options.table_mode,
&id_prefix,
options.hooks,
)
}
};
Ok(RenderOutput {
content,
warnings,
manifest: None,
})
}
fn zoom_variant(spec: &ChartSpec, step: &ZoomStep) -> ChartSpec {
if spec.chart_type == ChartType::Time {
let zone = spec.time_zone().unwrap_or_default();
let bound = |bound: &ZoomBound| {
bound
.resolve(zone)
.expect("validated zoom steps resolve to timestamps")
};
spec.windowed(bound(&step.from), bound(&step.to))
} else {
let index = |bound: &ZoomBound| {
bound
.index()
.expect("validated zoom steps of a category chart are indices")
};
spec.sliced(index(&step.from), index(&step.to))
}
}
fn render_mobile_alone(
mobile: Option<ChartSpec>,
format: RenderFormat,
prefix: &str,
styles: Styles,
hooks: render::Hooks,
metrics: &impl TextMetrics,
) -> Result<RenderOutput, ChartError> {
if format == RenderFormat::Html {
return Err(ChartError::new(
"option_not_supported",
"/render/variant",
"the HTML profile carries both variants; render the mobile variant alone as SVG",
));
}
let Some(mobile) = mobile else {
return Err(ChartError::new(
"missing_mobile",
"/mobile",
"the specification has no mobile variant; add \"mobile\": { \"width\": 360 }",
));
};
let mut warnings = mobile.validate_mobile_variant()?;
let content = render_panel(
&mobile,
format,
prefix,
styles.into(),
hooks,
metrics,
&mut warnings,
);
Ok(RenderOutput {
content,
warnings,
manifest: None,
})
}
fn render_print(
spec: &ChartSpec,
format: RenderFormat,
id_prefix: &str,
mut warnings: Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> Result<RenderOutput, ChartError> {
if format == RenderFormat::Html {
return Err(ChartError::new(
"option_not_supported",
"/render/variant",
"the print variant is a standalone SVG; render it with the SVG format",
));
}
let content = render_panel(
spec,
format,
&format!("{id_prefix}-p"),
render::StyleMode::Print,
render::Hooks::Off,
metrics,
&mut warnings,
);
warn_unresolved_colors(spec, "print", &mut warnings);
Ok(RenderOutput {
content,
warnings,
manifest: None,
})
}
fn render_social(
spec: &ChartSpec,
format: RenderFormat,
id_prefix: &str,
metrics: &impl TextMetrics,
) -> Result<RenderOutput, ChartError> {
if format == RenderFormat::Html {
return Err(ChartError::new(
"option_not_supported",
"/render/variant",
"the social variant is a standalone SVG; render it with the SVG format",
));
}
let mut frame_warnings = Vec::new();
let frame = social::Frame::new(spec, metrics, &mut frame_warnings);
let chart = ChartSpec {
width: frame.chart_width,
height: frame.chart_height,
show_title: false,
mobile: None,
size: None,
..spec.clone()
};
let mut warnings = chart.validate_mobile_variant()?;
warnings.append(&mut frame_warnings);
let content = render_panel(
&chart,
format,
&format!("{id_prefix}-s"),
render::StyleMode::Print,
render::Hooks::Off,
metrics,
&mut warnings,
);
warn_unresolved_colors(spec, "social", &mut warnings);
Ok(RenderOutput {
content: frame.compose(&content),
warnings,
manifest: None,
})
}
fn warn_unresolved_colors(spec: &ChartSpec, variant: &str, warnings: &mut Vec<ChartWarning>) {
for entry in spec.indexed_layers() {
if entry
.layer
.resolved_color()
.is_some_and(color::lacks_fallback)
{
warnings.push(ChartWarning::new(
"color_not_resolved",
format!("/panes/{}/layers/{}/color", entry.pane, entry.local),
format!("the {variant} variant cannot resolve a var() color and draws this layer in the text color; give it a fallback such as var(--name, #2563eb)"),
));
}
}
}
#[cfg(feature = "png")]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PngOptions {
pub variant: Variant,
pub scale: f32,
}
#[cfg(feature = "png")]
impl Default for PngOptions {
fn default() -> Self {
Self {
variant: Variant::Print,
scale: 1.0,
}
}
}
#[cfg(feature = "png")]
#[derive(Debug, Clone)]
pub struct PngOutput {
pub png: Vec<u8>,
pub warnings: Vec<ChartWarning>,
}
#[cfg(feature = "png")]
pub fn render_png(spec: &ChartSpec, options: &PngOptions) -> Result<PngOutput, ChartError> {
render_png_as(spec, *options, None)
}
#[cfg(feature = "png")]
pub fn render_png_with_font(
spec: &ChartSpec,
options: &PngOptions,
font: &[u8],
) -> Result<PngOutput, ChartError> {
render_png_as(spec, *options, Some(font))
}
#[cfg(feature = "png")]
#[allow(clippy::too_many_lines)]
fn render_png_as(
spec: &ChartSpec,
options: PngOptions,
font: Option<&[u8]>,
) -> Result<PngOutput, ChartError> {
if !(0.25..=png::MAX_SCALE).contains(&options.scale) {
return Err(ChartError::new(
"invalid_scale",
"/render/scale",
"scale must be between 0.25 and 4",
));
}
let mobile;
let (spec, variant) = match options.variant {
Variant::Desktop | Variant::Print => (spec, Variant::Print),
Variant::Social => (spec, Variant::Social),
Variant::Mobile => {
spec.validate()?;
mobile = spec.mobile_variant().ok_or_else(|| {
ChartError::new(
"missing_mobile",
"/mobile",
"the specification has no mobile variant; add \"mobile\": { \"width\": 360 }",
)
})?;
(&mobile, Variant::Print)
}
};
let render_options = RenderOptions {
variant,
..RenderOptions::default()
};
let (output, face) = match font {
Some(font) => (
render_with_font(spec, RenderFormat::Svg, &render_options, font)?,
Some((family_of(spec)?, font)),
),
None => (render(spec, RenderFormat::Svg, &render_options)?, None),
};
let mut warnings = output.warnings;
if let Some(warning) = png::missing_glyphs(&output.content) {
warnings.push(warning);
}
Ok(PngOutput {
png: png::rasterize(&output.content, options.scale, face)?,
warnings,
})
}
fn render_panel(
spec: &ChartSpec,
format: RenderFormat,
id_prefix: &str,
styles: render::StyleMode,
hooks: render::Hooks,
metrics: &impl TextMetrics,
warnings: &mut Vec<ChartWarning>,
) -> String {
render_panel_parts(spec, format, id_prefix, styles, hooks, metrics, warnings).svg
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn scaled_pixels(length: u32, scale: f64) -> u32 {
(f64::from(length) / scale).round() as u32
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn fit_root(
svg: &str,
scene: &scene::Scene,
scale: f64,
layout: (u32, u32),
warnings: &mut Vec<ChartWarning>,
) -> String {
warnings.retain(|warning| warning.code != "canvas_too_small");
let (view_width, view_height) = (scene.width.max(layout.0), scene.height.max(layout.1));
let drawn = |length: u32| (f64::from(length) * scale).round() as u32;
let (width, height) = (drawn(view_width), drawn(view_height));
if scene.width > layout.0 || scene.height > layout.1 {
warnings.push(ChartWarning::new(
"size_exceeded",
"/size",
format!(
"the content needs {width} × {height} pixels, more than the {} × {} of the size preset; the image is that large and nothing is cut. Shorten labels, remove elements, or choose a larger size",
drawn(layout.0),
drawn(layout.1)
),
));
}
let offset = |view: u32, content: u32| {
let gap = view - content;
match (gap % 2, gap / 2) {
(0, 0) => "0".to_owned(),
(0, half) => format!("-{half}"),
(_, half) => format!("-{half}.5"),
}
};
let origin = format!(
"{} {}",
offset(view_width, scene.width),
offset(view_height, scene.height)
);
svg.replacen(
&format!(
"width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\"",
w = scene.width,
h = scene.height
),
&format!(
"width=\"{width}\" height=\"{height}\" viewBox=\"{origin} {view_width} {view_height}\""
),
1,
)
}
fn stages(desktop: &PanelParts, mobile: Option<&PanelParts>) -> Vec<render::Stage> {
if desktop.stage.hotspots.is_empty() {
return Vec::new();
}
std::iter::once(desktop)
.chain(mobile)
.map(|parts| parts.stage.clone())
.collect()
}
struct PanelParts {
svg: String,
stage: render::Stage,
}
fn render_panel_parts(
spec: &ChartSpec,
format: RenderFormat,
id_prefix: &str,
styles: render::StyleMode,
hooks: render::Hooks,
metrics: &impl TextMetrics,
warnings: &mut Vec<ChartWarning>,
) -> PanelParts {
let description = spec
.description
.clone()
.unwrap_or_else(|| automatic_description(spec));
let scale = spec.size.map_or(1.0, SizePreset::scale);
let exact = spec
.size
.is_some_and(|size| size.scale() > 1.0 || size.is_print());
let scaled;
let spec = if exact {
scaled = ChartSpec {
width: scaled_pixels(spec.width, scale),
height: scaled_pixels(spec.height, scale),
..spec.clone()
};
&scaled
} else {
spec
};
let scene = if format == RenderFormat::Html && spec.show_title {
let untitled = ChartSpec {
show_title: false,
..spec.clone()
};
layout::layout(&untitled, warnings, metrics)
} else {
layout::layout(spec, warnings, metrics)
};
let hotspots = scene
.elements
.iter()
.filter_map(|element| match element {
scene::Element::Hotspot(hotspot) => Some(hotspot.clone()),
_ => None,
})
.collect();
let mut svg = render::svg(&scene, spec, &description, id_prefix, styles, hooks);
if exact {
let layout = (spec.width, spec.height);
svg = fit_root(&svg, &scene, scale, layout, warnings);
}
warnings.extend(contrast::role_warnings(spec, &svg));
PanelParts {
svg,
stage: render::Stage {
root: id_prefix.to_owned(),
width: scene.width,
height: scene.height,
hotspots,
},
}
}
const fn hook_mode(hooks: bool, format: RenderFormat) -> render::Hooks {
match (hooks, format) {
(false, _) => render::Hooks::Off,
(true, RenderFormat::Html) => render::Hooks::Attributes,
(true, RenderFormat::Svg) => render::Hooks::WithData,
}
}
fn dedupe_warnings(warnings: &mut Vec<ChartWarning>) {
let mut seen = std::collections::BTreeSet::new();
warnings.retain(|warning| seen.insert((warning.code, warning.path.clone())));
}
fn merge_mobile_warnings(warnings: &mut Vec<ChartWarning>, mut mobile: Vec<ChartWarning>) {
dedupe_warnings(&mut mobile);
let reported = warnings
.iter()
.map(|warning| (warning.code, warning.path.clone()))
.collect::<std::collections::BTreeSet<_>>();
warnings.extend(
mobile
.into_iter()
.filter(|warning| !reported.contains(&(warning.code, warning.path.clone())))
.map(|warning| ChartWarning {
message: format!("mobile variant: {}", warning.message),
..warning
}),
);
}
fn resolve_id_prefix(spec: &ChartSpec, options: &RenderOptions) -> Result<String, ChartError> {
match &options.id_prefix {
Some(id_prefix) => {
validate_id_prefix(id_prefix)?;
Ok(id_prefix.clone())
}
None => default_id_prefix(spec),
}
}
fn validate_id_prefix(value: &str) -> Result<(), ChartError> {
let mut characters = value.chars();
let valid_start = characters
.next()
.is_some_and(|character| character.is_ascii_alphabetic());
let valid_rest = characters
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'));
if !valid_start || !valid_rest || value.len() > 64 {
return Err(ChartError::new(
"invalid_id_prefix",
"/render/idPrefix",
"use 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
));
}
Ok(())
}
fn canonical_json(spec: &ChartSpec) -> Result<Vec<u8>, ChartError> {
serde_json::to_vec(spec).map_err(|error| {
ChartError::new(
"serialization_failed",
"/",
format!("could not canonicalize the chart specification: {error}"),
)
})
}
fn default_id_prefix(spec: &ChartSpec) -> Result<String, ChartError> {
let hash = canonical_json(spec)?
.iter()
.fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
});
Ok(format!("chartlet-{hash:016x}"))
}
#[cfg(test)]
mod tests;