1mod advice;
2mod architecture;
3mod axo;
4mod boxplot;
5mod calendar;
6mod classes;
7mod color;
8mod contrast;
9mod cycle;
10mod delta;
11mod dendrogram;
12mod describe;
13mod diagram;
14mod diverging;
15mod draw;
16mod emphasis;
17mod error;
18mod exploded;
19mod fishbone;
20mod flow;
21mod funnel;
22mod heat;
23mod heatmap;
24mod inspect;
25mod journey;
26mod kanban;
27mod layers;
28mod layout;
29mod legend;
30mod metrics;
31mod mosaic;
32mod network;
33mod ohlc;
34mod parliament;
35mod parts;
36mod plan;
37#[cfg(feature = "png")]
38mod png;
39mod polar;
40pub mod qr;
41mod radar;
42mod rangebar;
43mod readability;
44mod reference;
45mod render;
46mod roles;
47mod sankey;
48mod scatter;
49mod scene;
50mod sequence;
51mod sha256;
52mod slope;
53mod social;
54mod sort;
55mod spec;
56mod state;
57mod storymap;
58mod stripes;
59mod structure;
60mod survival;
61mod symbols;
62mod text;
63mod theme;
64mod time;
65mod timeline;
66mod tree;
67mod treemap;
68mod typography;
69mod valuemap;
70mod venn;
71mod waffle;
72mod waterfall;
73
74pub use advice::{Help, PatchOperation, Remedy, RemedyKind, advise, advise_json};
75use describe::automatic_description;
76pub use error::{ChartError, ChartWarning};
77pub use inspect::{INSPECTIONS, Inspection, Severity, inspect_json};
78pub use metrics::{BuiltinMetrics, TextMetrics};
79pub use sha256::sha256;
80pub use spec::{
81 ArchitectureSpec, AreaKind, AreaSpec, AxisScale, BoundarySpec, BoxDisplay, BoxSpec, BranchSpec,
82 CalendarDay, CalendarLayout, CalendarSpec, CategoryAxisSpec, CategorySpec, ChartSpec,
83 ChartType, ClassKind, ClassSpec, ClassesSpec, ComponentKind, ComponentSpec,
84 ConnectionDirection, ConnectionSpec, Curve, CycleHubSpec, CycleLabels, CycleSpec,
85 CycleStepSpec, Dash, DataPoint, DeltaSpec, DiagramOrientation, DiagramRole, DiagramSpacing,
86 DistancesSpec, EmphasisSpec, FishboneBoneSpec, FishboneCauseSpec, FishboneSpec,
87 FishboneSubcauseSpec, FlowEdgeSpec, FlowNodeSpec, FlowSpec, FragmentKind, FragmentSpec,
88 FunnelDisplay, FunnelSpec, FunnelStageSpec, Gaps, GroupSpec, HeatmapCellSpec, HeatmapOrder,
89 HeatmapScale, HeatmapSpec, InstanceSpec, InterfaceSpec, ItemKind, ItemSpec, JourneyDisplay,
90 JourneySpec, JourneyStageSpec, KanbanCardSpec, KanbanColumnSpec, KanbanSpec, LaneSpec,
91 LayerSpec, LayersDisplay, LayersProjection, LayersSpec, LegendPlacement, Linkage, Look,
92 MainPath, Mark, MarkerSpec, MessageKind, MessageSpec, MobileSpec, MosaicColumnSpec,
93 MosaicSegmentSpec, MosaicSpec, NetworkEdgeSpec, NetworkLayout, NetworkNodeSpec, NetworkOrder,
94 NetworkSpec, NodeKind, NoteSpec, OhlcPoint, Orientation, PackageSpec, PaneSpec, ParliamentSpec,
95 PartSpec, ParticipantKind, ParticipantSpec, PartySpec, PerspectiveSpec, PlanSpec, PolarDisplay,
96 PolarPointSpec, PolarSpec, PortSide, RadarAxisSpec, RadarSeriesSpec, RadarSpec, RangeDisplay,
97 RangeSort, RangeSpec, ReferenceSpec, RelationKind, RelationSpec, SankeyLinkSpec,
98 SankeyNodeSpec, SankeyOrder, SankeySpec, ScatterAxis, ScatterLineSpec, ScatterPointSpec,
99 ScatterQuadrantLabels, ScatterQuadrantsSpec, ScatterSpec, ScatterTickSpec, SequenceSpec,
100 SeriesSpec, Shape, SizePreset, Stack, StackLayerSpec, StateKind, StateNodeSpec, StateSpec,
101 StepKind, StepSpec, StorymapActivitySpec, StorymapSpec, StorymapStorySpec, StorymapTaskSpec,
102 StripesSpec, Stroke, StrokeStyle, SurvivalGroupSpec, SurvivalObservationSpec, SurvivalSpec,
103 Theme, TimeAxisKind, TimeAxisSpec, TimePoint, TimePrecision, TimelineItemSpec, TimelineSpec,
104 Tooltips, TransitionSpec, TreeLayout, TreeNodeKind, TreeNodeSpec, TreeSpec, TreemapItemSpec,
105 TreemapSpec, ValueAxisSpec, ValueFormat, ValuemapComponentSpec, ValuemapDependencySpec,
106 ValuemapMoveSpec, ValuemapSpec, VennRegionSpec, VennSpec, ViewSpec, ViewStepSpec, ViolinScale,
107 WafflePartSpec, WaffleSpec, WaterfallSpec, ZoomBound, ZoomStep,
108};
109pub use theme::{apply_theme, bundled_theme};
110pub use typography::{FontMetrics, TypographySpec, embed_font};
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum RenderFormat {
114 Svg,
115 Html,
116}
117
118#[derive(Debug, Clone, Default)]
119pub struct RenderOptions {
120 pub id_prefix: Option<String>,
121 pub table_mode: TableMode,
122 pub variant: Variant,
123 pub manifest: bool,
125 pub styles: Styles,
127 pub hooks: bool,
133 pub view: Option<String>,
136 pub perspective: Option<String>,
139 pub all_views: bool,
144}
145
146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
148pub enum Styles {
149 #[default]
151 Inline,
152 External,
155}
156
157#[must_use]
160pub fn stylesheet() -> String {
161 render::shared_stylesheet(&ChartType::ALL, true)
162}
163
164#[must_use]
168pub fn stylesheet_for(chart_types: &[ChartType]) -> String {
169 render::shared_stylesheet(chart_types, true)
170}
171
172#[must_use]
175pub fn stylesheet_common() -> String {
176 render::shared_stylesheet(&[], true)
177}
178
179#[must_use]
183pub fn stylesheet_types(chart_types: &[ChartType]) -> String {
184 render::shared_stylesheet(chart_types, false)
185}
186
187#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
190pub enum Variant {
191 #[default]
193 Desktop,
194 Mobile,
196 Print,
200 Social,
204}
205
206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
207pub enum TableMode {
208 #[default]
209 Details,
210 Visible,
211}
212
213#[derive(Debug, Clone)]
214pub struct RenderOutput {
215 pub content: String,
216 pub warnings: Vec<ChartWarning>,
217 pub manifest: Option<Manifest>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct Manifest {
226 pub chartlet: &'static str,
228 pub schema_version: u8,
229 pub spec_hash: String,
232 pub output_hash: String,
234 pub format: RenderFormat,
235 pub variant: Variant,
236 pub id_prefix: String,
240 pub view: Option<String>,
243 pub views: Vec<String>,
246 pub perspective: Option<String>,
249 pub theme: Option<String>,
253 pub warnings: Vec<ChartWarning>,
254}
255
256impl Manifest {
257 #[must_use]
267 pub fn to_json(&self) -> String {
268 #[derive(serde::Serialize)]
269 #[serde(rename_all = "camelCase")]
270 struct Json<'a> {
271 chartlet: &'a str,
272 schema_version: u8,
273 spec_hash: &'a str,
274 output_hash: &'a str,
275 format: &'a str,
276 variant: &'a str,
277 id_prefix: &'a str,
278 #[serde(skip_serializing_if = "Option::is_none")]
279 view: Option<&'a str>,
280 #[serde(skip_serializing_if = "<[String]>::is_empty")]
281 views: &'a [String],
282 #[serde(skip_serializing_if = "Option::is_none")]
283 perspective: Option<&'a str>,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 theme: Option<&'a str>,
286 warnings: Vec<Warning<'a>>,
287 }
288 #[derive(serde::Serialize)]
289 struct Warning<'a> {
290 code: &'a str,
291 path: &'a str,
292 message: &'a str,
293 }
294 let json = Json {
295 chartlet: self.chartlet,
296 schema_version: self.schema_version,
297 spec_hash: &self.spec_hash,
298 output_hash: &self.output_hash,
299 format: match self.format {
300 RenderFormat::Svg => "svg",
301 RenderFormat::Html => "html",
302 },
303 variant: match self.variant {
304 Variant::Desktop => "desktop",
305 Variant::Mobile => "mobile",
306 Variant::Print => "print",
307 Variant::Social => "social",
308 },
309 id_prefix: &self.id_prefix,
310 view: self.view.as_deref(),
311 views: &self.views,
312 perspective: self.perspective.as_deref(),
313 theme: self.theme.as_deref(),
314 warnings: self
315 .warnings
316 .iter()
317 .map(|warning| Warning {
318 code: warning.code,
319 path: &warning.path,
320 message: &warning.message,
321 })
322 .collect(),
323 };
324 serde_json::to_string_pretty(&json).expect("a manifest always serializes")
325 }
326}
327
328#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct TextAlternative {
332 pub description: String,
333 pub table: DataTable,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct DataTable {
339 pub caption: String,
340 pub columns: Vec<String>,
342 pub rows: Vec<Vec<String>>,
344}
345
346pub fn text_alternative(spec: &ChartSpec) -> Result<TextAlternative, ChartError> {
353 spec.validate()?;
354 if !spec.has_delta()
355 && let Some(view) = spec.resolve_view(None)?
356 {
357 return text_alternative(&view.spec);
358 }
359 Ok(TextAlternative {
360 description: spec
361 .description
362 .clone()
363 .unwrap_or_else(|| automatic_description(spec)),
364 table: render::data_table(spec),
365 })
366}
367
368pub fn render_json(
374 input: &str,
375 format: RenderFormat,
376 options: &RenderOptions,
377) -> Result<RenderOutput, ChartError> {
378 let spec = ChartSpec::from_json(input)?;
379 render(&spec, format, options)
380}
381
382fn family_of(spec: &ChartSpec) -> Result<&str, ChartError> {
384 spec.typography
385 .as_ref()
386 .and_then(|typography| typography.family.as_deref())
387 .ok_or_else(|| {
388 ChartError::new(
389 "invalid_font",
390 "/typography/family",
391 "a font file needs the name it is used under: add \"typography\": { \"family\": \"Your Font\" }",
392 )
393 })
394}
395
396pub fn render_with_font(
406 spec: &ChartSpec,
407 format: RenderFormat,
408 options: &RenderOptions,
409 font: &[u8],
410) -> Result<RenderOutput, ChartError> {
411 family_of(spec)?;
412 let metrics = FontMetrics::from_ttf(font)?;
413 let mut output = render_with_metrics(spec, format, options, &metrics)?;
414 output
415 .warnings
416 .retain(|warning| warning.code != "font_not_embedded");
417 output
418 .warnings
419 .extend(metrics.missing_glyphs(&output.content));
420 Ok(output)
421}
422
423pub fn render(
429 spec: &ChartSpec,
430 format: RenderFormat,
431 options: &RenderOptions,
432) -> Result<RenderOutput, ChartError> {
433 match spec
434 .typography
435 .as_ref()
436 .map(typography::TypographySpec::scale)
437 {
438 Some(scale) if (scale - 1.0).abs() > f64::EPSILON => {
439 render_with_metrics(spec, format, options, &typography::ScaledMetrics(scale))
440 }
441 _ => render_with_metrics(spec, format, options, &BuiltinMetrics),
442 }
443}
444
445pub fn render_with_metrics(
451 spec: &ChartSpec,
452 format: RenderFormat,
453 options: &RenderOptions,
454 metrics: &impl TextMetrics,
455) -> Result<RenderOutput, ChartError> {
456 let model = spec;
459 let selected;
460 let spec = match &options.perspective {
461 Some(key) => {
462 selected = spec.with_perspective(key)?;
463 &selected
464 }
465 None => spec,
466 };
467 if options.all_views {
468 return render_all_views((model, spec), format, options, metrics);
469 }
470 let view = if (spec.has_views() && !spec.has_delta()) || options.view.is_some() {
473 spec.validate()?;
474 spec.resolve_view(options.view.as_deref())?
475 } else {
476 None
477 };
478 let shown = view.as_ref().map_or(spec, |view| &view.spec);
479 let mut output =
480 render_content(shown, format, options, metrics).map_err(|error| match &view {
481 Some(view) => ChartError::new(error.code, view.model_path(&error.path), error.message),
482 None => error,
483 })?;
484 if let Some(view) = &view {
485 for warning in &mut output.warnings {
486 warning.path = view.model_path(&warning.path);
487 }
488 }
489 if options.manifest {
490 let id_prefix = match &options.id_prefix {
491 Some(id_prefix) => id_prefix.clone(),
492 None => default_id_prefix(shown)?,
493 };
494 let perspective = shown
495 .architecture
496 .as_ref()
497 .and_then(|architecture| architecture.perspective.clone());
498 output.manifest = Some(Manifest {
499 view: view.map(|view| view.key),
500 perspective,
501 ..manifest(model, format, options, &output, id_prefix)?
502 });
503 }
504 Ok(output)
505}
506
507fn manifest(
509 spec: &ChartSpec,
510 format: RenderFormat,
511 options: &RenderOptions,
512 output: &RenderOutput,
513 id_prefix: String,
514) -> Result<Manifest, ChartError> {
515 Ok(Manifest {
516 chartlet: env!("CARGO_PKG_VERSION"),
517 schema_version: spec.schema_version,
518 spec_hash: format!("sha256:{}", sha256::hex(&sha256(&canonical_json(spec)?))),
519 output_hash: format!("sha256:{}", sha256::hex(&sha256(output.content.as_bytes()))),
520 format,
521 variant: options.variant,
522 id_prefix,
523 view: None,
524 views: Vec::new(),
525 perspective: None,
526 theme: None,
527 warnings: output.warnings.clone(),
528 })
529}
530
531fn render_all_views(
537 (model, spec): (&ChartSpec, &ChartSpec),
538 format: RenderFormat,
539 options: &RenderOptions,
540 metrics: &impl TextMetrics,
541) -> Result<RenderOutput, ChartError> {
542 let refuse = |message: &str| {
543 Err(ChartError::new(
544 "option_not_supported",
545 "/render/allViews",
546 message,
547 ))
548 };
549 if format == RenderFormat::Svg {
550 return refuse(
551 "all views share one HTML page; render each view as an SVG of its own by its key",
552 );
553 }
554 if options.view.is_some() {
555 return refuse("all views are shown; leave out the view");
556 }
557 if options.variant != Variant::Desktop {
558 return refuse("the page of all views carries the desktop and mobile variants of each");
559 }
560 spec.validate()?;
561 if !spec.has_views() {
562 return refuse("the specification has no views");
563 }
564 let page_prefix = resolve_id_prefix(spec, options)?;
565 let mut views = vec![
566 spec.resolve_view(None)?
567 .expect("a model with views has a default"),
568 ];
569 for key in spec.view_keys() {
570 if key != views[0].key {
571 views.push(
572 spec.resolve_view(Some(key))?
573 .expect("the key is that of a view"),
574 );
575 }
576 }
577 let mut warnings = Vec::new();
578 let mut sections = Vec::new();
579 for (number, view) in views.iter().enumerate() {
580 let id = format!("{page_prefix}-v{}", number + 1);
581 let output =
582 render_content_as(&view.spec, format, options, &id, metrics).map_err(|error| {
583 ChartError::new(error.code, view.model_path(&error.path), error.message)
584 })?;
585 warnings.extend(output.warnings.into_iter().map(|warning| ChartWarning {
586 path: view.model_path(&warning.path),
587 ..warning
588 }));
589 sections.push(render::ViewSection {
590 id,
591 title: view.spec.title.clone(),
592 content: output.content,
593 });
594 }
595 dedupe_warnings(&mut warnings);
596 let mut output = RenderOutput {
597 content: render::html_views(§ions, spec, &page_prefix),
598 warnings,
599 manifest: None,
600 };
601 if options.manifest {
602 output.manifest = Some(Manifest {
603 views: views.into_iter().map(|view| view.key).collect(),
604 perspective: options.perspective.clone(),
605 ..manifest(model, format, options, &output, page_prefix)?
606 });
607 }
608 Ok(output)
609}
610
611fn render_content(
612 spec: &ChartSpec,
613 format: RenderFormat,
614 options: &RenderOptions,
615 metrics: &impl TextMetrics,
616) -> Result<RenderOutput, ChartError> {
617 let id_prefix = resolve_id_prefix(spec, options)?;
618 render_content_as(spec, format, options, &id_prefix, metrics)
619}
620
621#[allow(clippy::too_many_lines)]
623fn render_content_as(
624 spec: &ChartSpec,
625 format: RenderFormat,
626 options: &RenderOptions,
627 id_prefix: &str,
628 metrics: &impl TextMetrics,
629) -> Result<RenderOutput, ChartError> {
630 let mut warnings = spec.validate()?;
631 warnings.extend(
632 spec.typography
633 .as_ref()
634 .and_then(typography::TypographySpec::disclosure),
635 );
636 let id_prefix = id_prefix.to_owned();
637
638 let mobile = spec.mobile_variant();
639 let mobile_prefix = |prefix: &str| format!("{prefix}-m");
640 let hooks = hook_mode(options.hooks, format);
641 let parts = |spec: &ChartSpec, prefix: &str, warnings: &mut Vec<ChartWarning>| {
642 render_panel_parts(
643 spec,
644 format,
645 prefix,
646 options.styles.into(),
647 hooks,
648 metrics,
649 warnings,
650 )
651 };
652 let panel = |spec: &ChartSpec, prefix: &str, warnings: &mut Vec<ChartWarning>| {
653 parts(spec, prefix, warnings).svg
654 };
655
656 match options.variant {
657 Variant::Desktop
658 if format == RenderFormat::Svg && spec.size.is_some_and(SizePreset::is_print) =>
659 {
660 return render_print(spec, format, &id_prefix, warnings, metrics);
661 }
662 Variant::Desktop => {}
663 Variant::Mobile => {
664 return render_mobile_alone(
665 mobile,
666 format,
667 &mobile_prefix(&id_prefix),
668 options.styles,
669 hooks,
670 metrics,
671 );
672 }
673 Variant::Print => return render_print(spec, format, &id_prefix, warnings, metrics),
674 Variant::Social => return render_social(spec, format, &id_prefix, metrics),
675 }
676
677 let mut mobile_warnings = match (&mobile, format) {
680 (Some(mobile), RenderFormat::Html) => mobile.validate_mobile_variant()?,
681 _ => Vec::new(),
682 };
683
684 if format == RenderFormat::Html && spec.zoom_steps.len() > 1 {
687 let mut panels = Vec::new();
688 for (index, step) in spec.zoom_steps.iter().enumerate() {
689 let sliced = zoom_variant(spec, step);
690 let panel_prefix = format!("{id_prefix}-z{index}");
691 let svg = panel(&sliced, &panel_prefix, &mut warnings);
692 let mobile = mobile.as_ref().map(|mobile| {
693 panel(
694 &zoom_variant(mobile, step),
695 &mobile_prefix(&panel_prefix),
696 &mut mobile_warnings,
697 )
698 });
699 panels.push(render::Panel {
700 label: step.label.clone(),
701 svg,
702 mobile,
703 stages: Vec::new(),
704 });
705 }
706 dedupe_warnings(&mut warnings);
707 merge_mobile_warnings(&mut warnings, mobile_warnings);
708 return Ok(RenderOutput {
709 content: render::html_zoom(
710 &panels,
711 spec,
712 options.table_mode,
713 &id_prefix,
714 options.hooks,
715 ),
716 warnings,
717 manifest: None,
718 });
719 }
720
721 let desktop = parts(spec, &id_prefix, &mut warnings);
722 let content = match format {
723 RenderFormat::Svg => desktop.svg,
724 RenderFormat::Html => {
725 let mobile = mobile
726 .as_ref()
727 .map(|mobile| parts(mobile, &mobile_prefix(&id_prefix), &mut mobile_warnings));
728 merge_mobile_warnings(&mut warnings, mobile_warnings);
729 let stages = stages(&desktop, mobile.as_ref());
730 render::html(
731 render::Panel {
732 label: String::new(),
733 svg: desktop.svg,
734 mobile: mobile.map(|mobile| mobile.svg),
735 stages,
736 },
737 spec,
738 options.table_mode,
739 &id_prefix,
740 options.hooks,
741 )
742 }
743 };
744 Ok(RenderOutput {
745 content,
746 warnings,
747 manifest: None,
748 })
749}
750
751fn zoom_variant(spec: &ChartSpec, step: &ZoomStep) -> ChartSpec {
754 if spec.chart_type == ChartType::Time {
755 let zone = spec.time_zone().unwrap_or_default();
756 let bound = |bound: &ZoomBound| {
757 bound
758 .resolve(zone)
759 .expect("validated zoom steps resolve to timestamps")
760 };
761 spec.windowed(bound(&step.from), bound(&step.to))
762 } else {
763 let index = |bound: &ZoomBound| {
764 bound
765 .index()
766 .expect("validated zoom steps of a category chart are indices")
767 };
768 spec.sliced(index(&step.from), index(&step.to))
769 }
770}
771
772fn render_mobile_alone(
774 mobile: Option<ChartSpec>,
775 format: RenderFormat,
776 prefix: &str,
777 styles: Styles,
778 hooks: render::Hooks,
779 metrics: &impl TextMetrics,
780) -> Result<RenderOutput, ChartError> {
781 if format == RenderFormat::Html {
782 return Err(ChartError::new(
783 "option_not_supported",
784 "/render/variant",
785 "the HTML profile carries both variants; render the mobile variant alone as SVG",
786 ));
787 }
788 let Some(mobile) = mobile else {
789 return Err(ChartError::new(
790 "missing_mobile",
791 "/mobile",
792 "the specification has no mobile variant; add \"mobile\": { \"width\": 360 }",
793 ));
794 };
795 let mut warnings = mobile.validate_mobile_variant()?;
796 let content = render_panel(
797 &mobile,
798 format,
799 prefix,
800 styles.into(),
801 hooks,
802 metrics,
803 &mut warnings,
804 );
805 Ok(RenderOutput {
806 content,
807 warnings,
808 manifest: None,
809 })
810}
811
812fn render_print(
814 spec: &ChartSpec,
815 format: RenderFormat,
816 id_prefix: &str,
817 mut warnings: Vec<ChartWarning>,
818 metrics: &impl TextMetrics,
819) -> Result<RenderOutput, ChartError> {
820 if format == RenderFormat::Html {
821 return Err(ChartError::new(
822 "option_not_supported",
823 "/render/variant",
824 "the print variant is a standalone SVG; render it with the SVG format",
825 ));
826 }
827 let content = render_panel(
828 spec,
829 format,
830 &format!("{id_prefix}-p"),
831 render::StyleMode::Print,
832 render::Hooks::Off,
833 metrics,
834 &mut warnings,
835 );
836 warn_unresolved_colors(spec, "print", &mut warnings);
837 Ok(RenderOutput {
838 content,
839 warnings,
840 manifest: None,
841 })
842}
843
844fn render_social(
847 spec: &ChartSpec,
848 format: RenderFormat,
849 id_prefix: &str,
850 metrics: &impl TextMetrics,
851) -> Result<RenderOutput, ChartError> {
852 if format == RenderFormat::Html {
853 return Err(ChartError::new(
854 "option_not_supported",
855 "/render/variant",
856 "the social variant is a standalone SVG; render it with the SVG format",
857 ));
858 }
859 let mut frame_warnings = Vec::new();
860 let frame = social::Frame::new(spec, metrics, &mut frame_warnings);
861 let chart = ChartSpec {
862 width: frame.chart_width,
863 height: frame.chart_height,
864 show_title: false,
865 mobile: None,
866 size: None,
867 ..spec.clone()
868 };
869 let mut warnings = chart.validate_mobile_variant()?;
870 warnings.append(&mut frame_warnings);
871 let content = render_panel(
872 &chart,
873 format,
874 &format!("{id_prefix}-s"),
875 render::StyleMode::Print,
876 render::Hooks::Off,
877 metrics,
878 &mut warnings,
879 );
880 warn_unresolved_colors(spec, "social", &mut warnings);
881 Ok(RenderOutput {
882 content: frame.compose(&content),
883 warnings,
884 manifest: None,
885 })
886}
887
888fn warn_unresolved_colors(spec: &ChartSpec, variant: &str, warnings: &mut Vec<ChartWarning>) {
891 for entry in spec.indexed_layers() {
892 if entry
893 .layer
894 .resolved_color()
895 .is_some_and(color::lacks_fallback)
896 {
897 warnings.push(ChartWarning::new(
898 "color_not_resolved",
899 format!("/panes/{}/layers/{}/color", entry.pane, entry.local),
900 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)"),
901 ));
902 }
903 }
904}
905
906#[cfg(feature = "png")]
908#[derive(Debug, Clone, Copy, PartialEq)]
909pub struct PngOptions {
910 pub variant: Variant,
914 pub scale: f32,
916}
917
918#[cfg(feature = "png")]
919impl Default for PngOptions {
920 fn default() -> Self {
921 Self {
922 variant: Variant::Print,
923 scale: 1.0,
924 }
925 }
926}
927
928#[cfg(feature = "png")]
930#[derive(Debug, Clone)]
931pub struct PngOutput {
932 pub png: Vec<u8>,
933 pub warnings: Vec<ChartWarning>,
934}
935
936#[cfg(feature = "png")]
945pub fn render_png(spec: &ChartSpec, options: &PngOptions) -> Result<PngOutput, ChartError> {
946 render_png_as(spec, *options, None)
947}
948
949#[cfg(feature = "png")]
958pub fn render_png_with_font(
959 spec: &ChartSpec,
960 options: &PngOptions,
961 font: &[u8],
962) -> Result<PngOutput, ChartError> {
963 render_png_as(spec, *options, Some(font))
964}
965
966#[cfg(feature = "png")]
967#[allow(clippy::too_many_lines)]
968fn render_png_as(
969 spec: &ChartSpec,
970 options: PngOptions,
971 font: Option<&[u8]>,
972) -> Result<PngOutput, ChartError> {
973 if !(0.25..=png::MAX_SCALE).contains(&options.scale) {
974 return Err(ChartError::new(
975 "invalid_scale",
976 "/render/scale",
977 "scale must be between 0.25 and 4",
978 ));
979 }
980 let mobile;
983 let (spec, variant) = match options.variant {
984 Variant::Desktop | Variant::Print => (spec, Variant::Print),
985 Variant::Social => (spec, Variant::Social),
986 Variant::Mobile => {
987 spec.validate()?;
988 mobile = spec.mobile_variant().ok_or_else(|| {
989 ChartError::new(
990 "missing_mobile",
991 "/mobile",
992 "the specification has no mobile variant; add \"mobile\": { \"width\": 360 }",
993 )
994 })?;
995 (&mobile, Variant::Print)
996 }
997 };
998 let render_options = RenderOptions {
999 variant,
1000 ..RenderOptions::default()
1001 };
1002 let (output, face) = match font {
1003 Some(font) => (
1004 render_with_font(spec, RenderFormat::Svg, &render_options, font)?,
1005 Some((family_of(spec)?, font)),
1006 ),
1007 None => (render(spec, RenderFormat::Svg, &render_options)?, None),
1008 };
1009 let mut warnings = output.warnings;
1010 if let Some(warning) = png::missing_glyphs(&output.content) {
1011 warnings.push(warning);
1012 }
1013 Ok(PngOutput {
1014 png: png::rasterize(&output.content, options.scale, face)?,
1015 warnings,
1016 })
1017}
1018
1019fn render_panel(
1023 spec: &ChartSpec,
1024 format: RenderFormat,
1025 id_prefix: &str,
1026 styles: render::StyleMode,
1027 hooks: render::Hooks,
1028 metrics: &impl TextMetrics,
1029 warnings: &mut Vec<ChartWarning>,
1030) -> String {
1031 render_panel_parts(spec, format, id_prefix, styles, hooks, metrics, warnings).svg
1032}
1033
1034#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1036fn scaled_pixels(length: u32, scale: f64) -> u32 {
1037 (f64::from(length) / scale).round() as u32
1038}
1039
1040#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1045fn fit_root(
1046 svg: &str,
1047 scene: &scene::Scene,
1048 scale: f64,
1049 layout: (u32, u32),
1050 warnings: &mut Vec<ChartWarning>,
1051) -> String {
1052 warnings.retain(|warning| warning.code != "canvas_too_small");
1054 let (view_width, view_height) = (scene.width.max(layout.0), scene.height.max(layout.1));
1055 let drawn = |length: u32| (f64::from(length) * scale).round() as u32;
1056 let (width, height) = (drawn(view_width), drawn(view_height));
1057 if scene.width > layout.0 || scene.height > layout.1 {
1058 warnings.push(ChartWarning::new(
1059 "size_exceeded",
1060 "/size",
1061 format!(
1062 "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",
1063 drawn(layout.0),
1064 drawn(layout.1)
1065 ),
1066 ));
1067 }
1068 let offset = |view: u32, content: u32| {
1069 let gap = view - content;
1071 match (gap % 2, gap / 2) {
1072 (0, 0) => "0".to_owned(),
1073 (0, half) => format!("-{half}"),
1074 (_, half) => format!("-{half}.5"),
1075 }
1076 };
1077 let origin = format!(
1078 "{} {}",
1079 offset(view_width, scene.width),
1080 offset(view_height, scene.height)
1081 );
1082 svg.replacen(
1084 &format!(
1085 "width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\"",
1086 w = scene.width,
1087 h = scene.height
1088 ),
1089 &format!(
1090 "width=\"{width}\" height=\"{height}\" viewBox=\"{origin} {view_width} {view_height}\""
1091 ),
1092 1,
1093 )
1094}
1095
1096fn stages(desktop: &PanelParts, mobile: Option<&PanelParts>) -> Vec<render::Stage> {
1098 if desktop.stage.hotspots.is_empty() {
1099 return Vec::new();
1100 }
1101 std::iter::once(desktop)
1102 .chain(mobile)
1103 .map(|parts| parts.stage.clone())
1104 .collect()
1105}
1106
1107struct PanelParts {
1109 svg: String,
1110 stage: render::Stage,
1111}
1112
1113fn render_panel_parts(
1115 spec: &ChartSpec,
1116 format: RenderFormat,
1117 id_prefix: &str,
1118 styles: render::StyleMode,
1119 hooks: render::Hooks,
1120 metrics: &impl TextMetrics,
1121 warnings: &mut Vec<ChartWarning>,
1122) -> PanelParts {
1123 let description = spec
1124 .description
1125 .clone()
1126 .unwrap_or_else(|| automatic_description(spec));
1127 let scale = spec.size.map_or(1.0, SizePreset::scale);
1130 let exact = spec
1131 .size
1132 .is_some_and(|size| size.scale() > 1.0 || size.is_print());
1133 let scaled;
1134 let spec = if exact {
1135 scaled = ChartSpec {
1136 width: scaled_pixels(spec.width, scale),
1137 height: scaled_pixels(spec.height, scale),
1138 ..spec.clone()
1139 };
1140 &scaled
1141 } else {
1142 spec
1143 };
1144 let scene = if format == RenderFormat::Html && spec.show_title {
1145 let untitled = ChartSpec {
1146 show_title: false,
1147 ..spec.clone()
1148 };
1149 layout::layout(&untitled, warnings, metrics)
1150 } else {
1151 layout::layout(spec, warnings, metrics)
1152 };
1153 let hotspots = scene
1154 .elements
1155 .iter()
1156 .filter_map(|element| match element {
1157 scene::Element::Hotspot(hotspot) => Some(hotspot.clone()),
1158 _ => None,
1159 })
1160 .collect();
1161 let mut svg = render::svg(&scene, spec, &description, id_prefix, styles, hooks);
1162 if exact {
1163 let layout = (spec.width, spec.height);
1164 svg = fit_root(&svg, &scene, scale, layout, warnings);
1165 }
1166 warnings.extend(contrast::role_warnings(spec, &svg));
1168 PanelParts {
1169 svg,
1170 stage: render::Stage {
1171 root: id_prefix.to_owned(),
1172 width: scene.width,
1173 height: scene.height,
1174 hotspots,
1175 },
1176 }
1177}
1178
1179const fn hook_mode(hooks: bool, format: RenderFormat) -> render::Hooks {
1182 match (hooks, format) {
1183 (false, _) => render::Hooks::Off,
1184 (true, RenderFormat::Html) => render::Hooks::Attributes,
1185 (true, RenderFormat::Svg) => render::Hooks::WithData,
1186 }
1187}
1188
1189fn dedupe_warnings(warnings: &mut Vec<ChartWarning>) {
1191 let mut seen = std::collections::BTreeSet::new();
1192 warnings.retain(|warning| seen.insert((warning.code, warning.path.clone())));
1193}
1194
1195fn merge_mobile_warnings(warnings: &mut Vec<ChartWarning>, mut mobile: Vec<ChartWarning>) {
1198 dedupe_warnings(&mut mobile);
1199 let reported = warnings
1200 .iter()
1201 .map(|warning| (warning.code, warning.path.clone()))
1202 .collect::<std::collections::BTreeSet<_>>();
1203 warnings.extend(
1204 mobile
1205 .into_iter()
1206 .filter(|warning| !reported.contains(&(warning.code, warning.path.clone())))
1207 .map(|warning| ChartWarning {
1208 message: format!("mobile variant: {}", warning.message),
1209 ..warning
1210 }),
1211 );
1212}
1213
1214fn resolve_id_prefix(spec: &ChartSpec, options: &RenderOptions) -> Result<String, ChartError> {
1217 match &options.id_prefix {
1218 Some(id_prefix) => {
1219 validate_id_prefix(id_prefix)?;
1220 Ok(id_prefix.clone())
1221 }
1222 None => default_id_prefix(spec),
1223 }
1224}
1225
1226fn validate_id_prefix(value: &str) -> Result<(), ChartError> {
1227 let mut characters = value.chars();
1228 let valid_start = characters
1229 .next()
1230 .is_some_and(|character| character.is_ascii_alphabetic());
1231 let valid_rest = characters
1232 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'));
1233 if !valid_start || !valid_rest || value.len() > 64 {
1234 return Err(ChartError::new(
1235 "invalid_id_prefix",
1236 "/render/idPrefix",
1237 "use 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
1238 ));
1239 }
1240 Ok(())
1241}
1242
1243fn canonical_json(spec: &ChartSpec) -> Result<Vec<u8>, ChartError> {
1246 serde_json::to_vec(spec).map_err(|error| {
1247 ChartError::new(
1248 "serialization_failed",
1249 "/",
1250 format!("could not canonicalize the chart specification: {error}"),
1251 )
1252 })
1253}
1254
1255fn default_id_prefix(spec: &ChartSpec) -> Result<String, ChartError> {
1256 let hash = canonical_json(spec)?
1257 .iter()
1258 .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
1259 (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
1260 });
1261 Ok(format!("chartlet-{hash:016x}"))
1262}
1263
1264#[cfg(test)]
1265mod tests;