Skip to main content

chartlet/
lib.rs

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    /// Also returns a [`Manifest`] of the render in [`RenderOutput::manifest`].
124    pub manifest: bool,
125    /// Where the chart's styles live. The print variant always carries its own.
126    pub styles: Styles,
127    /// Adds the `data-*` hooks that the optional interactive module of the npm package reads:
128    /// chart type and ID on the root, the plot geometry of every pane, a group around what each
129    /// layer draws, the specification ID of every diagram node of a flow chart, state,
130    /// architecture or class diagram, and the unformatted values on the data table, or in a
131    /// JSON data block in the SVG profile. The print variant carries none.
132    pub hooks: bool,
133    /// The `key` of the view of an architecture model to render; without one, its default view.
134    /// Every variant renders the same view.
135    pub view: Option<String>,
136    /// The `key` of the perspective of an architecture diagram to show, in place of the one the
137    /// model or the rendered view selects. Every variant shows the same perspective.
138    pub perspective: Option<String>,
139    /// Renders every view of an architecture model on one HTML page instead of one view: a list
140    /// of links to the views, the default view first, and each view as a section of its own
141    /// with its figure, description and data table. Only for the HTML profile and the desktop
142    /// variant, and not together with `view`.
143    pub all_views: bool,
144}
145
146/// Where a chart's styles live.
147#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
148pub enum Styles {
149    /// Every chart carries its whole stylesheet, scoped to its root: an SVG file stands alone.
150    #[default]
151    Inline,
152    /// The page loads the shared [`stylesheet`] once; a chart carries only what is its own, such
153    /// as declared layer colors, and often no `<style>` at all.
154    External,
155}
156
157/// The stylesheet every chart rendered with [`Styles::External`] relies on. It is the same for
158/// every chart, so a site serves it once as a cacheable file.
159#[must_use]
160pub fn stylesheet() -> String {
161    render::shared_stylesheet(&ChartType::ALL, true)
162}
163
164/// The shared stylesheet with only the rules of `chart_types`: enough for a site that renders no
165/// other types. It is [`stylesheet_common`] followed by [`stylesheet_types`], and the part of
166/// each type comes in the order of `chart_types`.
167#[must_use]
168pub fn stylesheet_for(chart_types: &[ChartType]) -> String {
169    render::shared_stylesheet(chart_types, true)
170}
171
172/// The part of the shared stylesheet that every chart relies on, whatever its type: colors,
173/// text, grid and axes. A page that loads type parts separately loads this part first.
174#[must_use]
175pub fn stylesheet_common() -> String {
176    render::shared_stylesheet(&[], true)
177}
178
179/// The parts of the shared stylesheet that only charts of `chart_types` use, without the common
180/// part. Each type's rules style only charts of that type, so the parts of separate calls can be
181/// loaded as separate files, in any order, after [`stylesheet_common`].
182#[must_use]
183pub fn stylesheet_types(chart_types: &[ChartType]) -> String {
184    render::shared_stylesheet(chart_types, false)
185}
186
187/// Which layout the SVG profile renders. The HTML profile always carries the chart and, when the
188/// specification has one, its mobile variant.
189#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
190pub enum Variant {
191    /// The chart at `width` × `height`.
192    #[default]
193    Desktop,
194    /// The chart at the size in `mobile`; its IDs end in `-m`.
195    Mobile,
196    /// The chart at `width` × `height` for print and PDF pipelines and renderers outside the
197    /// browser: its stylesheet carries the theme's colors as literal values instead of CSS
198    /// custom properties, and no rule that needs a browser. Its IDs end in `-p`.
199    Print,
200    /// The chart on a 1200 × 630 canvas for link previews such as Open Graph images: the title
201    /// drawn large, the source below, no tooltips, and the resolved stylesheet of
202    /// [`Variant::Print`]. Its IDs end in `-s`.
203    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    /// The provenance of `content`, when [`RenderOptions::manifest`] asks for it.
218    pub manifest: Option<Manifest>,
219}
220
221/// The provenance of one render: which version of chartlet produced which output from which
222/// specification, with which options and warnings. It carries no timestamp, so the same render
223/// always yields the same manifest.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct Manifest {
226    /// The version of the chartlet crate that rendered.
227    pub chartlet: &'static str,
228    pub schema_version: u8,
229    /// `sha256:` and the hexadecimal SHA-256 of the canonical specification: the parsed
230    /// specification serialized again, so key order and whitespace of the input do not matter.
231    pub spec_hash: String,
232    /// `sha256:` and the hexadecimal SHA-256 of [`RenderOutput::content`] as UTF-8.
233    pub output_hash: String,
234    pub format: RenderFormat,
235    pub variant: Variant,
236    /// The ID prefix the chart was rendered with: the one passed in, or the one derived from the
237    /// specification. The mobile variant appends `-m` to it, the print variant `-p`, the social
238    /// variant `-s`.
239    pub id_prefix: String,
240    /// The `key` of the view of an architecture model that was rendered; `None` for a
241    /// specification without views. `spec_hash` is the hash of the whole model.
242    pub view: Option<String>,
243    /// The keys of the views of an architecture model shown on one page with
244    /// [`RenderOptions::all_views`], in the order of the page; empty otherwise.
245    pub views: Vec<String>,
246    /// The `key` of the perspective of an architecture diagram that was shown; `None` without
247    /// one.
248    pub perspective: Option<String>,
249    /// The theme a host applied to the specification before rendering: the name of a bundled
250    /// theme or the path of a theme file. The compiler renders the themed specification and
251    /// does not know it; the CLI sets it. `None` without one.
252    pub theme: Option<String>,
253    pub warnings: Vec<ChartWarning>,
254}
255
256impl Manifest {
257    /// The manifest as a pretty-printed JSON object, its keys in a fixed order:
258    /// `chartlet`, `schemaVersion`, `specHash`, `outputHash`, `format`, `variant`, `idPrefix`,
259    /// `view` (only when a view was rendered), `views` (only when all views were rendered on one
260    /// page), `perspective` (only when one was shown), `theme` (only when a host applied one),
261    /// `warnings` (each with `code`, `path`, `message`).
262    ///
263    /// # Panics
264    ///
265    /// Never: every field is a string, a number or a list of strings.
266    #[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/// What a chart says without its graphic: the description its SVG carries and the data table of
329/// its HTML profile, as text, for hosts that build their own accessible wrapper around the SVG.
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct TextAlternative {
332    pub description: String,
333    pub table: DataTable,
334}
335
336/// A chart's data table as text, values written as the chart writes them.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct DataTable {
339    pub caption: String,
340    /// The column heads; the first names the categories.
341    pub columns: Vec<String>,
342    /// One row per category, starting with the category.
343    pub rows: Vec<Vec<String>>,
344}
345
346/// Validates a chart specification and returns its text alternative; that of the default view
347/// for an architecture model with views (see [`ChartSpec::view`] for another view).
348///
349/// # Errors
350///
351/// Returns a structured error when the specification is invalid.
352pub 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
368/// Parses and renders a chart specification.
369///
370/// # Errors
371///
372/// Returns a structured error when parsing, validation, or rendering fails.
373pub 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
382/// The family a font file is used under: `typography.family`.
383fn 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
396/// Renders with the advance widths of `font`, a TrueType or OpenType file the caller supplies:
397/// the layout measures with it instead of the built-in metrics, and `font_glyphs_missing` names
398/// the characters it has no glyph for. The output still only names the family; see [`embed_font`]
399/// to carry the file in an SVG. Nothing is fetched or looked up on the machine.
400///
401/// # Errors
402///
403/// As [`render_with_metrics`], and `invalid_font` for a file that is not a plain font or a
404/// specification without `typography.family`.
405pub 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
423/// Validates and renders an already parsed chart specification.
424///
425/// # Errors
426///
427/// Returns a structured error when the specification or render context is invalid.
428pub 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
445/// Renders with caller-provided deterministic text metrics.
446///
447/// # Errors
448///
449/// Returns a structured error when the specification or render context is invalid.
450pub fn render_with_metrics(
451    spec: &ChartSpec,
452    format: RenderFormat,
453    options: &RenderOptions,
454    metrics: &impl TextMetrics,
455) -> Result<RenderOutput, ChartError> {
456    // A perspective asked for takes the place of the one the model and its views select; the
457    // manifest still hashes the model as given.
458    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    // A model with views renders one of them, resolved to a diagram of its own; diagnostics
471    // point into the model.
472    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
507/// The manifest of `output`, rendered from `spec`, without a view or perspective.
508fn 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
531/// Every view of an architecture model on one HTML page, the default view first. Each view is a
532/// section with the figure of its own HTML export, under the ID prefix of the page followed by
533/// `-v` and the view's place on the page from 1; diagnostics point into the model. The model is
534/// given as it came, whose hash the manifest carries, and as rendered, with the perspective
535/// asked for.
536fn 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(&sections, 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/// Renders `spec` with its IDs under `id_prefix`.
622#[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    // The HTML profile lays the chart out a second time at the mobile size; its warnings are
678    // reported where they add to those of the chart itself.
679    let mut mobile_warnings = match (&mobile, format) {
680        (Some(mobile), RenderFormat::Html) => mobile.validate_mobile_variant()?,
681        _ => Vec::new(),
682    };
683
684    // Zoom steps render as pre-computed variants switched by radio buttons, which only the
685    // HTML profile can carry. The pure SVG profile stays a single static chart.
686    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
751/// The chart one zoom step shows: a window of time on a time chart, a range of categories on
752/// any other.
753fn 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
772/// The mobile variant alone, as SVG, with its IDs under `prefix`.
773fn 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
812/// The print variant, as SVG, with its IDs ending in `-p`.
813fn 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
844/// The social variant, as SVG, with its IDs ending in `-s`. The chart is laid out without its
845/// title at the size the canvas leaves it, and validated at that size like a mobile variant.
846fn 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
888/// A variant with a resolved stylesheet cannot see the page that would define a `var()` color, so
889/// such a layer is drawn in the text color; several of them would become indistinguishable.
890fn 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/// Options of [`render_png`].
907#[cfg(feature = "png")]
908#[derive(Debug, Clone, Copy, PartialEq)]
909pub struct PngOptions {
910    /// [`Variant::Print`] rasterizes the chart at `width` × `height`, [`Variant::Social`] the
911    /// 1200 × 630 canvas, [`Variant::Mobile`] the mobile layout. [`Variant::Desktop`] is taken as
912    /// [`Variant::Print`]: a PNG needs the resolved stylesheet.
913    pub variant: Variant,
914    /// Image pixels per SVG pixel, 0.25–4; 2 for a high-density screen.
915    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/// A rendered PNG and the warnings of the SVG it was rasterized from.
929#[cfg(feature = "png")]
930#[derive(Debug, Clone)]
931pub struct PngOutput {
932    pub png: Vec<u8>,
933    pub warnings: Vec<ChartWarning>,
934}
935
936/// Validates a chart specification and renders it as PNG: the print or the social variant,
937/// rasterized with the bundled Inter font and no system fonts. The same specification, options
938/// and chartlet version yield the same bytes.
939///
940/// # Errors
941///
942/// Returns a structured error when the specification is invalid, the scale is out of range, or
943/// the mobile variant is asked for and the specification has none.
944#[cfg(feature = "png")]
945pub fn render_png(spec: &ChartSpec, options: &PngOptions) -> Result<PngOutput, ChartError> {
946    render_png_as(spec, *options, None)
947}
948
949/// [`render_png`] with a font file of the caller's: the text is measured with it and rasterized
950/// with it, under the family the specification names in `typography.family`. The font is the
951/// caller's to license; chartlet embeds nothing it was not given.
952///
953/// # Errors
954///
955/// As [`render_png`], and `invalid_font` when the file is not a plain TrueType or OpenType font
956/// or the specification names no `typography.family`.
957#[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    // The mobile variant is rasterized like the chart itself: its layout with the resolved
981    // stylesheet of the print variant.
982    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
1019/// Lays out and serializes one chart, using its explicit description or a generated one. The HTML
1020/// profile captions the chart, so its SVGs leave the drawn title out (`format`); the title stays
1021/// their accessible name either way. `print` draws it for print (see [`Variant::Print`]).
1022fn 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/// A layout length for a canvas length drawn at `scale`.
1035#[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/// Draws the SVG root at the preset's canvas: `scale` times the size the chart was laid out at,
1041/// so that its marks, text and strokes grow with the canvas. A diagram smaller than the canvas is
1042/// centered on it; one that needs more room than the canvas keeps it and is reported with
1043/// `size_exceeded`. Nothing is clipped or dropped.
1044#[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    // The layout names its own, scaled-down size; the preset's canvas is the size that matters.
1053    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        // Twice the offset is a whole number; an odd gap leaves a half pixel.
1070        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    // The root names the content's own size; the viewBox starts where the content is centered.
1083    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
1096/// The stages of a diagram whose nodes can be focused, desktop first; none for any other chart.
1097fn 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
1107/// A chart laid out and serialized, with what the HTML profile needs to focus its nodes.
1108struct PanelParts {
1109    svg: String,
1110    stage: render::Stage,
1111}
1112
1113/// [`render_panel`], keeping where the nodes of a diagram stand.
1114fn 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    // A size preset with a scale lays the chart out at the canvas divided by it and draws the
1128    // result that much larger.
1129    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    // The roles a diagram draws are known only once it is drawn.
1167    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
1179/// Which hooks a chart's SVGs carry: the HTML profile has its data table to read the values
1180/// from, the SVG profile carries them itself.
1181const 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
1189/// Zoom panels repeat the same data, so identical warnings would otherwise appear once per panel.
1190fn 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
1195/// Adds the warnings of the mobile variant that the chart itself does not already report, marked
1196/// as coming from the mobile variant.
1197fn 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
1214/// The ID prefix a render uses: the one passed in, checked, or the one derived from the
1215/// specification.
1216fn 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
1243/// The canonical form of a specification: the parsed specification serialized again, so that two
1244/// inputs that differ only in key order or whitespace share it.
1245fn 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;