1#![forbid(unsafe_code)]
2
3#[cfg(feature = "layout-cytoscape")]
16#[cfg(feature = "diagram-architecture")]
17pub mod architecture;
18#[cfg(all(feature = "layout-cytoscape", feature = "diagram-architecture"))]
19pub(crate) mod architecture_metrics;
20#[cfg(feature = "diagram-block")]
21pub mod block;
22#[cfg(feature = "diagram-c4")]
23pub mod c4;
24#[cfg(feature = "diagram-xychart")]
25mod chart_palette;
26#[cfg(feature = "diagram-class")]
27pub mod class;
28#[cfg_attr(
29 not(feature = "all-diagrams"),
30 allow(
31 dead_code,
32 unused_imports,
33 reason = "Shared rendering utilities have different callers in each diagram selection."
34 )
35)]
36mod config;
37#[cfg(feature = "diagram-cynefin")]
38pub mod cynefin;
39#[cfg(any(
40 feature = "diagram-agentflow",
41 feature = "diagram-flowchart",
42 feature = "diagram-swimlane",
43 feature = "diagram-state"
44))]
45mod dagre;
46#[cfg(feature = "layout-elk")]
47#[cfg_attr(
48 not(feature = "all-diagrams"),
49 allow(
50 dead_code,
51 unused_imports,
52 reason = "Shared rendering utilities have different callers in each diagram selection."
53 )
54)]
55mod elk_adapter;
56#[cfg_attr(
57 not(feature = "all-diagrams"),
58 allow(
59 dead_code,
60 unused_imports,
61 reason = "Shared rendering utilities have different callers in each diagram selection."
62 )
63)]
64mod elk_edge_geometry;
65#[cfg(feature = "layout-elk")]
66#[cfg_attr(
67 not(feature = "all-diagrams"),
68 allow(
69 dead_code,
70 unused_imports,
71 reason = "Shared rendering utilities have different callers in each diagram selection."
72 )
73)]
74mod elk_feedback_edges;
75#[cfg_attr(
76 not(feature = "all-diagrams"),
77 allow(
78 dead_code,
79 unused_imports,
80 reason = "Shared rendering utilities have different callers in each diagram selection."
81 )
82)]
83mod elk_geometry;
84#[cfg(feature = "layout-elk")]
85#[cfg_attr(
86 not(feature = "all-diagrams"),
87 allow(
88 dead_code,
89 unused_imports,
90 reason = "Shared rendering utilities have different callers in each diagram selection."
91 )
92)]
93mod elk_hierarchy;
94#[cfg(feature = "layout-elk")]
95#[cfg_attr(
96 not(feature = "all-diagrams"),
97 allow(
98 dead_code,
99 unused_imports,
100 reason = "Shared rendering utilities have different callers in each diagram selection."
101 )
102)]
103mod elk_options;
104#[cfg_attr(
105 not(feature = "all-diagrams"),
106 allow(
107 dead_code,
108 unused_imports,
109 reason = "Shared rendering utilities have different callers in each diagram selection."
110 )
111)]
112mod elk_terminal_jogs;
113#[cfg_attr(
114 not(feature = "all-diagrams"),
115 allow(
116 dead_code,
117 unused_imports,
118 reason = "Shared rendering utilities have different callers in each diagram selection."
119 )
120)]
121mod entities;
122pub mod environment;
123#[cfg(feature = "diagram-er")]
124pub mod er;
125pub mod error;
126#[cfg(feature = "diagram-event-modeling")]
127pub mod eventmodeling;
128pub mod family;
129#[cfg(any(
130 feature = "diagram-flowchart",
131 feature = "diagram-swimlane",
132 feature = "diagram-agentflow"
133))]
134pub mod flowchart;
135#[cfg(feature = "diagram-gantt")]
136pub mod gantt;
137mod generated;
138#[cfg(feature = "diagram-git-graph")]
139pub mod gitgraph;
140#[cfg(any(
141 feature = "diagram-agentflow",
142 feature = "diagram-class",
143 feature = "diagram-flowchart",
144 feature = "diagram-swimlane",
145 feature = "diagram-mindmap"
146))]
147#[cfg_attr(
148 not(feature = "all-diagrams"),
149 allow(
150 dead_code,
151 unused_imports,
152 reason = "Shared rendering utilities have different callers in each diagram selection."
153 )
154)]
155mod graph_label;
156#[cfg(feature = "diagram-info")]
157pub mod info;
158#[cfg(feature = "diagram-ishikawa")]
159pub mod ishikawa;
160#[cfg(feature = "diagram-journey")]
161pub mod journey;
162#[cfg(feature = "diagram-kanban")]
163pub mod kanban;
164#[cfg_attr(
165 not(feature = "all-diagrams"),
166 allow(
167 dead_code,
168 unused_imports,
169 reason = "Shared rendering utilities have different callers in each diagram selection."
170 )
171)]
172mod layout_backend;
173#[cfg_attr(
174 not(feature = "all-diagrams"),
175 allow(
176 dead_code,
177 unused_imports,
178 reason = "Shared rendering utilities have different callers in each diagram selection."
179 )
180)]
181mod layout_work;
182#[cfg_attr(
183 not(feature = "all-diagrams"),
184 allow(
185 dead_code,
186 unused_imports,
187 reason = "Shared rendering utilities have different callers in each diagram selection."
188 )
189)]
190pub mod math;
191#[cfg_attr(
192 not(feature = "all-diagrams"),
193 allow(
194 dead_code,
195 unused_imports,
196 reason = "Shared rendering utilities have different callers in each diagram selection."
197 )
198)]
199mod mermaid_style;
200#[cfg(feature = "diagram-mindmap")]
201pub mod mindmap;
202pub mod model;
203#[cfg(feature = "diagram-packet")]
204pub mod packet;
205#[cfg(feature = "diagram-pie")]
206pub mod pie;
207pub mod presentation;
208#[cfg(feature = "diagram-quadrant-chart")]
209pub mod quadrantchart;
210#[cfg(feature = "diagram-radar")]
211pub mod radar;
212#[cfg(feature = "diagram-railroad")]
213pub mod railroad;
214#[cfg(feature = "diagram-requirement")]
215pub mod requirement;
216pub mod resources;
217#[cfg(feature = "diagram-sankey")]
218pub mod sankey;
219#[cfg(feature = "diagram-sequence")]
220pub mod sequence;
221#[cfg(feature = "diagram-state")]
222pub mod state;
223pub mod svg;
224#[cfg(any(
225 feature = "diagram-flowchart",
226 feature = "diagram-swimlane",
227 feature = "diagram-mindmap"
228))]
229pub mod swimlane;
230#[cfg_attr(
231 not(feature = "all-diagrams"),
232 allow(
233 dead_code,
234 unused_imports,
235 reason = "Shared rendering utilities have different callers in each diagram selection."
236 )
237)]
238pub mod text;
239#[cfg_attr(
240 not(feature = "all-diagrams"),
241 allow(
242 dead_code,
243 unused_imports,
244 reason = "Shared rendering utilities have different callers in each diagram selection."
245 )
246)]
247mod theme;
248#[cfg(feature = "diagram-timeline")]
249pub mod timeline;
250#[cfg(feature = "diagram-tree-view")]
251pub mod tree_view;
252#[cfg(feature = "diagram-treemap")]
253pub mod treemap;
254
255#[cfg(feature = "diagram-usecase")]
256pub mod usecase;
257#[cfg(feature = "diagram-venn")]
258pub mod venn;
259#[cfg(feature = "diagram-wardley")]
260pub mod wardley;
261#[cfg_attr(
262 not(feature = "all-diagrams"),
263 allow(
264 dead_code,
265 unused_imports,
266 reason = "Shared rendering utilities have different callers in each diagram selection."
267 )
268)]
269mod xml;
270#[cfg(feature = "diagram-xychart")]
271pub mod xychart;
272#[cfg(feature = "diagram-zenuml")]
273pub mod zenuml;
274
275pub const fn layout_cytoscape_available() -> bool {
277 cfg!(feature = "layout-cytoscape")
278}
279
280pub const fn layout_elk_available() -> bool {
282 cfg!(feature = "layout-elk")
283}
284
285pub const fn math_available() -> bool {
287 cfg!(feature = "math")
288}
289
290#[non_exhaustive]
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
293pub enum RenderCapability {
294 LayoutCytoscape,
295 LayoutElk,
296 Math,
297}
298
299impl RenderCapability {
300 const fn bit(self) -> u8 {
301 match self {
302 Self::LayoutCytoscape => 1 << 0,
303 Self::LayoutElk => 1 << 1,
304 Self::Math => 1 << 2,
305 }
306 }
307
308 pub const fn id(self) -> &'static str {
309 match self {
310 Self::LayoutCytoscape => "layout-cytoscape",
311 Self::LayoutElk => "layout-elk",
312 Self::Math => "math",
313 }
314 }
315}
316
317const ALL_RENDER_CAPABILITY_BITS: u8 = RenderCapability::LayoutCytoscape.bit()
318 | RenderCapability::LayoutElk.bit()
319 | RenderCapability::Math.bit();
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub struct RenderCapabilityPolicy {
329 allowed_bits: u8,
330}
331
332impl RenderCapabilityPolicy {
333 pub const fn unrestricted() -> Self {
335 Self {
336 allowed_bits: ALL_RENDER_CAPABILITY_BITS,
337 }
338 }
339
340 pub const fn deny_all() -> Self {
342 Self { allowed_bits: 0 }
343 }
344
345 #[must_use]
347 pub const fn with_allowed(mut self, capability: RenderCapability) -> Self {
348 self.allowed_bits |= capability.bit();
349 self
350 }
351
352 pub const fn allows(self, capability: RenderCapability) -> bool {
354 self.allowed_bits & capability.bit() != 0
355 }
356}
357
358impl Default for RenderCapabilityPolicy {
359 fn default() -> Self {
360 Self::unrestricted()
361 }
362}
363
364impl std::fmt::Display for RenderCapability {
365 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366 f.write_str(self.id())
367 }
368}
369
370use crate::environment::{RenderSession, RoutedTextMeasurer, TextMeasurementPhase};
371use merman_core::OperationPhase;
372#[cfg(any(
373 feature = "diagram-flowchart",
374 feature = "diagram-swimlane",
375 feature = "diagram-agentflow"
376))]
377use merman_core::diagrams::flowchart::FlowchartModel;
378#[cfg(feature = "diagram-class")]
379use merman_core::models::class_diagram::ClassDiagram;
380
381pub use resources::{
382 CLI_DEFAULT_RESOURCE_PROFILE, GENERAL_BINDING_DEFAULT_RESOURCE_PROFILE, RenderResourceLimitId,
383 RenderResourcePolicy, RenderResourceProfile, RenderResourceProfileDescriptor,
384 ResourceLimitCause, ResourceLimitDescriptor, ResourceLimitExceeded, ResourceLimitId,
385 ResourceLimitOverride, ResourceLimitOverrideError, ResourceLimitPhase,
386 resource_limit_descriptors, resource_profile_descriptors,
387};
388
389#[derive(Debug, thiserror::Error)]
390#[non_exhaustive]
391pub enum Error {
392 #[error(transparent)]
393 Cancelled(#[from] merman_core::OperationCancelled),
394 #[error("unsupported diagram type for layout: {diagram_type}")]
395 UnsupportedDiagram { diagram_type: String },
396 #[error("render session lacks capability `{capability}` required by diagram `{diagram_type}`")]
397 MissingCapability {
398 capability: RenderCapability,
399 diagram_type: String,
400 },
401 #[error("invalid semantic model: {message}")]
402 InvalidModel { message: String },
403 #[error(
404 "custom JSON model `{model_name}` from {provenance:?} cannot render diagram type `{diagram_type}`"
405 )]
406 NonRenderableCustomModel {
407 diagram_type: String,
408 model_name: String,
409 provenance: merman_core::CustomJsonProvenance,
410 },
411 #[error("SVG postprocessor `{pass}` failed: {message}")]
412 SvgPostprocess { pass: String, message: String },
413 #[error("external icon output is invalid: {message}")]
414 InvalidIconOutput { message: String },
415 #[error("icon rendering failed internally: {message}")]
416 IconProcessing { message: String },
417 #[error(transparent)]
418 ResourceLimitExceeded(#[from] ResourceLimitExceeded),
419 #[error(transparent)]
420 OperationResourceTerminal(merman_core::OperationLedgerError),
421 #[error(transparent)]
422 Color(#[from] merman_core::theme_color::ColorError),
423 #[error("semantic model JSON error: {0}")]
424 Json(#[from] serde_json::Error),
425 #[error(transparent)]
426 OperationTimingUnavailable(#[from] merman_core::runtime::OperationTimingUnavailable),
427}
428
429pub type Result<T> = std::result::Result<T, Error>;
430
431impl From<dugong::LayoutError> for Error {
432 fn from(error: dugong::LayoutError) -> Self {
433 Self::InvalidModel {
434 message: error.to_string(),
435 }
436 }
437}
438
439impl From<crate::resources::OperationWorkError> for Error {
440 fn from(error: crate::resources::OperationWorkError) -> Self {
441 match error {
442 crate::resources::OperationWorkError::Cancelled(error) => Self::Cancelled(error),
443 crate::resources::OperationWorkError::ResourceLimitExceeded(error) => {
444 Self::ResourceLimitExceeded(error)
445 }
446 crate::resources::OperationWorkError::ForeignResourceTerminal(error) => {
447 Self::OperationResourceTerminal(error)
448 }
449 }
450 }
451}
452
453impl Error {
454 pub fn svg_postprocess(pass: impl Into<String>, message: impl Into<String>) -> Self {
455 Self::SvgPostprocess {
456 pass: pass.into(),
457 message: message.into(),
458 }
459 }
460
461 #[cfg_attr(
462 not(feature = "all-diagrams"),
463 allow(
464 dead_code,
465 reason = "Shared operation support has different consumers in each diagram selection."
466 )
467 )]
468 pub(crate) fn invalid_icon_output(message: impl Into<String>) -> Self {
469 Self::InvalidIconOutput {
470 message: message.into(),
471 }
472 }
473
474 #[cfg_attr(
475 not(feature = "all-diagrams"),
476 allow(
477 dead_code,
478 reason = "Shared operation support has different consumers in each diagram selection."
479 )
480 )]
481 pub(crate) fn icon_processing(message: impl Into<String>) -> Self {
482 Self::IconProcessing {
483 message: message.into(),
484 }
485 }
486
487 pub const fn missing_capability(&self) -> Option<RenderCapability> {
488 match self {
489 Self::MissingCapability { capability, .. } => Some(*capability),
490 _ => None,
491 }
492 }
493}
494
495#[derive(Debug, Clone)]
500#[non_exhaustive]
501pub struct LayoutOptions {
502 pub container_width: f64,
506 pub container_height: f64,
508 pub screen_available_width: Option<f64>,
513}
514
515impl Default for LayoutOptions {
516 fn default() -> Self {
517 Self {
518 container_width: 800.0,
519 container_height: 600.0,
520 screen_available_width: None,
521 }
522 }
523}
524
525impl LayoutOptions {
526 pub fn headless_svg_defaults() -> Self {
528 Self::default()
529 }
530
531 pub fn with_container_size(mut self, width: f64, height: f64) -> Self {
533 self.container_width = width;
534 self.container_height = height;
535 self
536 }
537
538 pub fn with_screen_available_width(mut self, width: f64) -> Self {
540 self.screen_available_width = Some(width);
541 self
542 }
543}
544
545#[cfg_attr(
546 not(feature = "all-diagrams"),
547 allow(
548 dead_code,
549 reason = "Shared operation support has different consumers in each diagram selection."
550 )
551)]
552pub(crate) struct LayoutExecution<'a> {
553 request: &'a LayoutOptions,
554 session: &'a RenderSession,
555 text_measurer: RoutedTextMeasurer<'a>,
556}
557
558#[cfg_attr(
559 not(feature = "all-diagrams"),
560 allow(
561 dead_code,
562 reason = "Shared operation support has different consumers in each diagram selection."
563 )
564)]
565impl<'a> LayoutExecution<'a> {
566 pub(crate) fn new(request: &'a LayoutOptions, session: &'a RenderSession) -> Self {
567 Self {
568 request,
569 session,
570 text_measurer: session
571 .controlled_text_measurer(TextMeasurementPhase::Layout, OperationPhase::Layout),
572 }
573 }
574
575 pub(crate) fn text_measurer(&self) -> &dyn crate::text::TextMeasurer {
576 &self.text_measurer
577 }
578
579 pub(crate) fn math_renderer(&self) -> Option<&(dyn crate::math::MathRenderer + Send + Sync)> {
580 self.session.math_renderer()
581 }
582
583 pub(crate) fn work_meter(&self) -> std::sync::Arc<crate::resources::OperationWorkMeter> {
584 std::sync::Arc::clone(self.session.work_meter())
585 }
586
587 pub(crate) fn work_meter_ref(&self) -> &crate::resources::OperationWorkMeter {
588 self.session.work_meter().as_ref()
589 }
590
591 pub(crate) fn local_time_zone(&self) -> &merman_core::time::LocalTimeZone {
592 self.session.local_time_zone()
593 }
594
595 #[cfg(feature = "layout-cytoscape")]
596 pub(crate) fn operation_seed(&self) -> u64 {
597 self.session.render_seed().get()
598 }
599
600 #[cfg(feature = "layout-elk")]
601 pub(crate) fn elk_operation_seed(&self) -> merman_layout_elk::ElkOperationSeed {
602 merman_layout_elk::ElkOperationSeed::from_operation_seed(self.session.render_seed())
606 }
607}
608
609impl std::ops::Deref for LayoutExecution<'_> {
610 type Target = LayoutOptions;
611
612 fn deref(&self) -> &Self::Target {
613 self.request
614 }
615}
616
617#[cfg(feature = "diagram-class")]
618pub(crate) fn layout_class_typed_by_engine(
619 model: &ClassDiagram,
620 effective_config: &merman_core::MermaidConfig,
621 options: &LayoutExecution<'_>,
622) -> Result<model::ClassDiagramLayout> {
623 options
624 .work_meter_ref()
625 .preflight_class_complexity(model, OperationPhase::Layout)?;
626 let mut work_control = layout_work::OperationLayoutWorkControl::new(options.work_meter());
627 let preparation_work = class::class_layout_work_units(model, &work_control)?;
628 work_control.charge_adapter(preparation_work)?;
629 let selection = layout_backend::resolve_graph_layout(effective_config.as_value());
630 selection.validate_rootless_graph()?;
631 #[cfg(feature = "layout-elk")]
632 if selection.backend == layout_backend::GraphLayoutBackend::Elk {
633 return class::layout_class_diagram_elk_typed_with_config_and_operation_seed(
634 model,
635 effective_config,
636 options.text_measurer(),
637 options.math_renderer(),
638 options.elk_operation_seed(),
639 &mut work_control,
640 );
641 }
642 class::layout_class_diagram_typed_with_config(
643 model,
644 effective_config,
645 options.text_measurer(),
646 options.math_renderer(),
647 &mut work_control,
648 )
649}
650
651#[cfg(all(test, feature = "layout-elk"))]
652#[cfg(any(
653 feature = "diagram-flowchart",
654 feature = "diagram-swimlane",
655 feature = "diagram-agentflow"
656))]
657pub(crate) fn layout_flowchart_typed_by_engine(
658 model: &FlowchartModel,
659 effective_config: &merman_core::MermaidConfig,
660 options: &LayoutExecution<'_>,
661) -> Result<model::FlowchartLayout> {
662 layout_flowchart_typed_with_render_labels_by_engine(
663 model,
664 &merman_core::diagrams::flowchart::FlowchartRenderContext::default(),
665 effective_config,
666 options,
667 None,
668 )
669}
670
671#[cfg(any(
672 feature = "diagram-flowchart",
673 feature = "diagram-swimlane",
674 feature = "diagram-agentflow"
675))]
676pub(crate) fn layout_flowchart_typed_with_render_labels_by_engine(
677 model: &FlowchartModel,
678 render_label_sources: &merman_core::diagrams::flowchart::FlowchartRenderContext,
679 effective_config: &merman_core::MermaidConfig,
680 options: &LayoutExecution<'_>,
681 svg_label_sidecar: Option<&flowchart::FlowchartSvgLabelSidecarBuilder>,
682) -> Result<model::FlowchartLayout> {
683 options.session.checkpoint(OperationPhase::Layout)?;
684 let selection = layout_backend::resolve_graph_layout(effective_config.as_value());
685 selection.validate_rootless_graph()?;
686 #[cfg(feature = "layout-elk")]
687 if selection.backend == layout_backend::GraphLayoutBackend::Elk {
688 return flowchart::elk::layout_flowchart_elk_typed_with_render_labels_and_operation_seed(
689 model,
690 render_label_sources,
691 effective_config,
692 flowchart::elk::FlowchartElkLayoutExecution::new(
693 options.text_measurer(),
694 options.math_renderer(),
695 options.elk_operation_seed(),
696 svg_label_sidecar,
697 options.work_meter(),
698 ),
699 );
700 }
701
702 flowchart::layout_flowchart_typed_with_render_labels_and_work_meter_and_svg_label_sidecar(
703 model,
704 render_label_sources,
705 effective_config,
706 options.text_measurer(),
707 options.math_renderer(),
708 svg_label_sidecar,
709 options.work_meter(),
710 )
711}
712
713#[cfg(all(test, feature = "all-diagrams"))]
714mod tests {
715 use super::*;
716 #[cfg(feature = "layout-elk")]
717 use merman_core::ParsedDiagramRender;
718 #[cfg(feature = "layout-elk")]
719 use merman_core::RenderSemanticModel;
720 use merman_core::{Engine, ParseOptions};
721
722 #[test]
723 fn render_capability_ids_and_error_accessor_are_stable() {
724 for (capability, stable_id) in [
725 (RenderCapability::LayoutCytoscape, "layout-cytoscape"),
726 (RenderCapability::LayoutElk, "layout-elk"),
727 (RenderCapability::Math, "math"),
728 ] {
729 assert_eq!(capability.id(), stable_id);
730 assert_eq!(capability.to_string(), stable_id);
731
732 let error = Error::MissingCapability {
733 capability,
734 diagram_type: "contract-test".to_string(),
735 };
736 assert_eq!(error.missing_capability(), Some(capability));
737 assert_eq!(
738 error.to_string(),
739 format!(
740 "render session lacks capability `{stable_id}` required by diagram `contract-test`"
741 )
742 );
743 }
744 }
745
746 #[test]
747 fn render_capability_policy_is_an_explicit_allow_mask() {
748 let denied = RenderCapabilityPolicy::deny_all();
749 for capability in [
750 RenderCapability::LayoutCytoscape,
751 RenderCapability::LayoutElk,
752 RenderCapability::Math,
753 ] {
754 assert!(!denied.allows(capability));
755 assert!(
756 RenderCapabilityPolicy::unrestricted().allows(capability),
757 "unrestricted policy denied {}",
758 capability.id()
759 );
760 }
761
762 let math_only = denied.with_allowed(RenderCapability::Math);
763 assert!(math_only.allows(RenderCapability::Math));
764 assert!(!math_only.allows(RenderCapability::LayoutCytoscape));
765 assert!(!math_only.allows(RenderCapability::LayoutElk));
766 }
767
768 #[cfg(feature = "layout-elk")]
769 fn flowchart_layout(
770 parsed: &ParsedDiagramRender,
771 options: &LayoutOptions,
772 session: &RenderSession,
773 ) -> model::FlowchartLayout {
774 let RenderSemanticModel::Flowchart(model) = parsed.model() else {
775 panic!("expected flowchart render model");
776 };
777 layout_flowchart_typed_by_engine(
778 model,
779 &parsed.metadata().effective_config,
780 &LayoutExecution::new(options, session),
781 )
782 .expect("flowchart layout")
783 }
784
785 #[cfg(feature = "layout-elk")]
786 fn class_layout(
787 parsed: &ParsedDiagramRender,
788 options: &LayoutOptions,
789 session: &RenderSession,
790 ) -> model::ClassDiagramLayout {
791 let RenderSemanticModel::Class(model) = parsed.model() else {
792 panic!("expected class render model");
793 };
794 layout_class_typed_by_engine(
795 model,
796 &parsed.metadata().effective_config,
797 &LayoutExecution::new(options, session),
798 )
799 .expect("class layout")
800 }
801
802 fn render_source(
803 source: &str,
804 layout_options: &LayoutOptions,
805 svg_options: &crate::svg::SvgRenderOptions,
806 ) -> String {
807 let parsed = Engine::new()
808 .parse_diagram_for_render_model_sync(source, ParseOptions::strict())
809 .expect("parse")
810 .expect("diagram");
811 let session = crate::environment::RenderEnvironment::deterministic()
812 .begin_session()
813 .unwrap();
814 crate::family::prepare(parsed, layout_options, session)
815 .expect("prepare")
816 .render_svg(svg_options, &crate::svg::SvgDebugOptions::default())
817 .expect("render")
818 .svg()
819 .to_owned()
820 }
821
822 #[cfg(feature = "layout-elk")]
823 #[test]
824 fn elk_operation_seed_is_captured_once_per_render_operation() {
825 fn capture(seed: u64) -> merman_layout_elk::ElkOperationSeed {
826 let session = crate::environment::RenderEnvironment::deterministic()
827 .with_runtime_policy(
828 merman_core::runtime::RuntimePolicy::deterministic().with_fixed_seed(seed),
829 )
830 .begin_session()
831 .expect("render session");
832 LayoutExecution::new(&LayoutOptions::default(), &session).elk_operation_seed()
833 }
834
835 assert_eq!(capture(17), capture(17));
836 assert_ne!(capture(17), capture(18));
837 }
838
839 #[cfg(feature = "layout-elk")]
840 #[test]
841 fn render_model_dispatch_accepts_diagram_type_aliases() {
842 let session = crate::environment::RenderEnvironment::deterministic()
843 .begin_session()
844 .unwrap();
845 let parsed = Engine::new()
846 .parse_diagram_for_render_model_with_type_sync(
847 "flowchart-elk",
848 "flowchart-elk TD\nA-->B;",
849 ParseOptions::strict(),
850 )
851 .unwrap()
852 .unwrap();
853
854 let artifact = crate::family::prepare(parsed, &LayoutOptions::default(), session).unwrap();
855 assert_eq!(
856 artifact.family_kind(),
857 crate::family::RenderFamilyKind::Flowchart
858 );
859 }
860
861 #[cfg(feature = "layout-elk")]
862 #[test]
863 fn render_model_dispatch_uses_elk_for_scoped_flowchart_layout() {
864 let session = crate::environment::RenderEnvironment::deterministic()
865 .begin_session()
866 .unwrap();
867 let parsed = Engine::new()
868 .parse_diagram_for_render_model_sync(
869 r#"---
870config:
871 flowchart:
872 layout: elk
873---
874flowchart TD
875A-->B
876"#,
877 ParseOptions::strict(),
878 )
879 .unwrap()
880 .unwrap();
881
882 assert_eq!(parsed.metadata().diagram_type, "flowchart-v2");
883 let layout = flowchart_layout(&parsed, &LayoutOptions::default(), &session);
884 assert!(layout.uses_elk_adapter_dom);
885 let a = layout.nodes.iter().find(|node| node.id == "A").unwrap();
886 let b = layout.nodes.iter().find(|node| node.id == "B").unwrap();
887 assert!(b.y > a.y);
888 }
889
890 #[cfg(feature = "layout-elk")]
891 #[test]
892 fn render_model_dispatch_rejects_flowchart_over_node_resource_limit() {
893 let parsed = Engine::new()
894 .parse_diagram_for_render_model_with_type_sync(
895 "flowchart-elk",
896 "flowchart-elk TD\nA-->B;",
897 ParseOptions::strict(),
898 )
899 .unwrap()
900 .unwrap();
901 let options = LayoutOptions::default();
902 let session = crate::environment::RenderEnvironment::deterministic()
903 .with_resource_policy(
904 RenderResourcePolicy::unbounded_for_trusted_input()
905 .with_limit(ResourceLimitId::MaxModelItems, 1)
906 .unwrap(),
907 )
908 .begin_session()
909 .unwrap();
910
911 let err = match crate::family::prepare(parsed, &options, session) {
912 Err(error) => error,
913 Ok(_) => panic!("expected resource limit error"),
914 };
915
916 let Error::ResourceLimitExceeded(limit) = err else {
917 panic!("expected resource limit error");
918 };
919 assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
920 assert_eq!(limit.limit, "max_model_items");
921 }
922
923 #[test]
924 fn render_model_dispatch_rejects_flowchart_dagre_work_during_its_first_owner_phase() {
925 let parsed = Engine::new()
926 .parse_diagram_for_render_model_sync(
927 "flowchart TD\nsubgraph Cluster\nA\nend\nA-->B",
928 ParseOptions::strict(),
929 )
930 .unwrap()
931 .unwrap();
932 let session = crate::environment::RenderEnvironment::deterministic()
933 .with_resource_policy(
934 RenderResourcePolicy::unbounded_for_trusted_input()
935 .with_limit(ResourceLimitId::MaxLayoutWorkUnits, 1)
936 .unwrap(),
937 )
938 .begin_session()
939 .unwrap();
940
941 let err = match crate::family::prepare(parsed, &LayoutOptions::default(), session) {
942 Err(error) => error,
943 Ok(_) => panic!("expected flowchart layout work resource limit"),
944 };
945
946 let Error::ResourceLimitExceeded(limit) = err else {
947 panic!("expected resource limit error");
948 };
949 assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
950 assert_eq!(limit.limit, "max_layout_work_units");
951 assert!(limit.actual > 1);
952 }
953
954 fn assert_class_layout_work_limit(source: &str) {
955 let parsed = Engine::new()
956 .parse_diagram_for_render_model_sync(source, ParseOptions::strict())
957 .unwrap()
958 .expect("class source should produce a render model");
959 let session = crate::environment::RenderEnvironment::deterministic()
960 .with_resource_policy(
961 RenderResourcePolicy::unbounded_for_trusted_input()
962 .with_limit(ResourceLimitId::MaxLayoutWorkUnits, 1)
963 .unwrap(),
964 )
965 .begin_session()
966 .unwrap();
967
968 let error = match crate::family::prepare(parsed, &LayoutOptions::default(), session) {
969 Err(error) => error,
970 Ok(_) => panic!("Class layout unexpectedly bypassed the work budget"),
971 };
972 let Error::ResourceLimitExceeded(limit) = error else {
973 panic!("expected Class layout work resource limit error");
974 };
975 assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
976 assert_eq!(limit.limit, "max_layout_work_units");
977 assert!(limit.actual > 1);
978 assert_eq!(limit.max, 1);
979 }
980
981 #[test]
982 fn dagre_class_layout_honors_the_public_work_budget() {
983 assert_class_layout_work_limit("classDiagram\nA --> B\nA --> C\nA --> D\nB --> C\nC --> D");
984 }
985
986 #[cfg(feature = "layout-elk")]
987 #[test]
988 fn elk_class_layout_honors_the_public_work_budget() {
989 assert_class_layout_work_limit(
990 r#"---
991config:
992 layout: elk
993---
994classDiagram
995A --> B
996A --> C
997A --> D
998B --> C
999C --> D"#,
1000 );
1001 }
1002
1003 #[cfg(feature = "layout-elk")]
1004 #[test]
1005 fn render_model_dispatch_uses_elk_for_class_layout_config() {
1006 let session = crate::environment::RenderEnvironment::deterministic()
1007 .begin_session()
1008 .unwrap();
1009 let parsed = Engine::new()
1010 .parse_diagram_for_render_model_sync(
1011 r#"---
1012config:
1013 layout: elk
1014---
1015classDiagram
1016direction LR
1017Animal <|-- Duck
1018"#,
1019 ParseOptions::strict(),
1020 )
1021 .unwrap()
1022 .unwrap();
1023
1024 assert_eq!(parsed.metadata().diagram_type, "classDiagram");
1025 let layout = class_layout(&parsed, &LayoutOptions::default(), &session);
1026 let animal = layout
1027 .nodes
1028 .iter()
1029 .find(|node| node.id == "Animal")
1030 .unwrap();
1031 let duck = layout.nodes.iter().find(|node| node.id == "Duck").unwrap();
1032 assert!(
1033 duck.x > animal.x,
1034 "ELK LR class layout should place Duck to the right of Animal; Animal={}, Duck={}",
1035 animal.x,
1036 duck.x
1037 );
1038 }
1039
1040 #[cfg(feature = "layout-elk")]
1041 #[test]
1042 fn render_model_dispatch_rejects_class_over_node_resource_limit() {
1043 let parsed = Engine::new()
1044 .parse_diagram_for_render_model_sync(
1045 "classDiagram\nAnimal <|-- Duck",
1046 ParseOptions::strict(),
1047 )
1048 .unwrap()
1049 .unwrap();
1050 let options = LayoutOptions::default();
1051 let session = crate::environment::RenderEnvironment::deterministic()
1052 .with_resource_policy(
1053 RenderResourcePolicy::unbounded_for_trusted_input()
1054 .with_limit(ResourceLimitId::MaxModelItems, 1)
1055 .unwrap(),
1056 )
1057 .begin_session()
1058 .unwrap();
1059
1060 let err = match crate::family::prepare(parsed, &options, session) {
1061 Err(error) => error,
1062 Ok(_) => panic!("expected resource limit error"),
1063 };
1064
1065 let Error::ResourceLimitExceeded(limit) = err else {
1066 panic!("expected resource limit error");
1067 };
1068 assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
1069 assert_eq!(limit.limit, "max_model_items");
1070 }
1071
1072 #[cfg(feature = "layout-elk")]
1073 #[test]
1074 fn typed_dispatch_rejects_flowchart_over_edge_resource_limit() {
1075 let parsed = Engine::new()
1076 .parse_diagram_for_render_model_sync(
1077 "flowchart TD\nA-->B\nB-->C\nC-->D",
1078 ParseOptions::strict(),
1079 )
1080 .unwrap()
1081 .unwrap();
1082 let options = LayoutOptions::default();
1083 let session = crate::environment::RenderEnvironment::deterministic()
1084 .with_resource_policy(
1085 RenderResourcePolicy::unbounded_for_trusted_input()
1086 .with_limit(ResourceLimitId::MaxModelItems, 2)
1087 .unwrap(),
1088 )
1089 .begin_session()
1090 .unwrap();
1091
1092 let err = match crate::family::prepare(parsed, &options, session) {
1093 Err(error) => error,
1094 Ok(_) => panic!("expected resource limit error"),
1095 };
1096
1097 let Error::ResourceLimitExceeded(limit) = err else {
1098 panic!("expected resource limit error");
1099 };
1100 assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
1101 assert_eq!(limit.limit, "max_model_items");
1102 }
1103
1104 #[cfg(feature = "layout-elk")]
1105 #[test]
1106 fn typed_dispatch_rejects_class_over_edge_resource_limit() {
1107 let parsed = Engine::new()
1108 .parse_diagram_for_render_model_sync(
1109 "classDiagram\nAnimal <|-- Duck\nDuck <|-- Mallard",
1110 ParseOptions::strict(),
1111 )
1112 .unwrap()
1113 .unwrap();
1114 let options = LayoutOptions::default();
1115 let session = crate::environment::RenderEnvironment::deterministic()
1116 .with_resource_policy(
1117 RenderResourcePolicy::unbounded_for_trusted_input()
1118 .with_limit(ResourceLimitId::MaxModelItems, 1)
1119 .unwrap(),
1120 )
1121 .begin_session()
1122 .unwrap();
1123
1124 let err = match crate::family::prepare(parsed, &options, session) {
1125 Err(error) => error,
1126 Ok(_) => panic!("expected resource limit error"),
1127 };
1128
1129 let Error::ResourceLimitExceeded(limit) = err else {
1130 panic!("expected resource limit error");
1131 };
1132 assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
1133 assert_eq!(limit.limit, "max_model_items");
1134 }
1135
1136 #[cfg(feature = "layout-elk")]
1137 #[test]
1138 fn canonical_svg_preserves_flowchart_elk_roledescription() {
1139 let svg = render_source(
1140 "flowchart-elk TD\nA-->B;",
1141 &LayoutOptions::default(),
1142 &crate::svg::SvgRenderOptions {
1143 diagram_id: Some("elk-smoke".to_string()),
1144 ..Default::default()
1145 },
1146 );
1147
1148 assert!(svg.contains(r#"aria-roledescription="flowchart-elk""#));
1149 assert!(svg.contains("elk-smoke_flowchart-elk-pointEnd"));
1150 assert!(!svg.contains(r#"aria-roledescription="flowchart-v2""#));
1151 assert!(svg.contains(r#"<g class="root""#));
1152
1153 let marker_pos = svg
1154 .find(r#"<g><marker id="elk-smoke_flowchart-elk-pointEnd""#)
1155 .expect("ELK marker group");
1156 let root_pos = svg.find(r#"<g class="root">"#).expect("ELK root group");
1157 let clusters_pos = svg
1158 .find(r#"<g class="clusters"/>"#)
1159 .expect("ELK clusters group");
1160 let edges_pos = svg
1161 .find(r#"<g class="edges edgePaths">"#)
1162 .expect("ELK edge paths group");
1163 let labels_pos = svg
1164 .find(r#"<g class="edgeLabels">"#)
1165 .expect("ELK edge labels group");
1166 let nodes_pos = svg.find(r#"<g class="nodes">"#).expect("ELK nodes group");
1167 let defs_pos = svg
1168 .find(r#"<defs><filter id="elk-smoke-drop-shadow""#)
1169 .expect("ELK shadow defs");
1170
1171 assert!(marker_pos < root_pos);
1172 assert!(root_pos < clusters_pos);
1173 assert!(clusters_pos < edges_pos);
1174 assert!(edges_pos < labels_pos);
1175 assert!(labels_pos < nodes_pos);
1176 assert!(nodes_pos < defs_pos);
1177 }
1178
1179 #[cfg(feature = "layout-elk")]
1180 #[test]
1181 fn canonical_svg_uses_elk_adapter_dom_for_flowchart_layout_elk() {
1182 let svg = render_source(
1183 r#"---
1184config:
1185 layout: elk
1186---
1187flowchart LR
1188A{A} --> B & C
1189"#,
1190 &LayoutOptions::default(),
1191 &crate::svg::SvgRenderOptions {
1192 diagram_id: Some("layout-elk-smoke".to_string()),
1193 ..Default::default()
1194 },
1195 );
1196
1197 assert!(svg.contains(r#"aria-roledescription="flowchart-v2""#));
1198 assert!(svg.contains("layout-elk-smoke_flowchart-v2-pointEnd"));
1199 assert!(svg.contains(r#"<g class="root""#));
1200
1201 let marker_pos = svg
1202 .find(r#"<g><marker id="layout-elk-smoke_flowchart-v2-pointEnd""#)
1203 .expect("ELK marker group");
1204 let root_pos = svg.find(r#"<g class="root">"#).expect("ELK root group");
1205 let clusters_pos = svg
1206 .find(r#"<g class="clusters"/>"#)
1207 .expect("ELK clusters group");
1208 let edges_pos = svg
1209 .find(r#"<g class="edges edgePaths">"#)
1210 .expect("ELK edge paths group");
1211 let labels_pos = svg
1212 .find(r#"<g class="edgeLabels">"#)
1213 .expect("ELK edge labels group");
1214 let nodes_pos = svg.find(r#"<g class="nodes">"#).expect("ELK nodes group");
1215 let defs_pos = svg
1216 .find(r#"<defs><filter id="layout-elk-smoke-drop-shadow""#)
1217 .expect("ELK shadow defs");
1218
1219 assert!(marker_pos < root_pos);
1220 assert!(root_pos < clusters_pos);
1221 assert!(clusters_pos < edges_pos);
1222 assert!(edges_pos < labels_pos);
1223 assert!(labels_pos < nodes_pos);
1224 assert!(nodes_pos < defs_pos);
1225 }
1226
1227 #[cfg(feature = "layout-elk")]
1228 #[test]
1229 fn canonical_svg_uses_right_angle_edges_for_flowchart_elk() {
1230 let svg = render_source(
1231 "flowchart-elk LR\nA --> B\nA --> C",
1232 &LayoutOptions::default(),
1233 &crate::svg::SvgRenderOptions::default(),
1234 );
1235
1236 let path = edge_path_chunk(&svg, "L_A_B_0");
1237 let d = edge_path_d(path);
1238 assert!(
1239 d.contains('L') && !d.contains('C'),
1240 "expected ELK edges to use right-angle paths without smooth curves by default: {d}"
1241 );
1242 }
1243
1244 #[cfg(feature = "layout-elk")]
1245 #[test]
1246 fn canonical_svg_keeps_source_ported_elk_rect_edge_boundary_points() {
1247 let svg = render_source(
1248 r#"---
1249config:
1250 htmlLabels: true
1251 flowchart:
1252 htmlLabels: true
1253 securityLevel: loose
1254---
1255flowchart-elk LR
1256id1(Start)-->id2(Stop)
1257"#,
1258 &LayoutOptions::default(),
1259 &crate::svg::SvgRenderOptions::default(),
1260 );
1261
1262 let path = edge_path_chunk(&svg, "L_id1_id2_0");
1263 let d = edge_path_d(path);
1264 assert!(
1265 !d.contains('Q'),
1266 "straight ELK roundedRect edge should not gain a rounded corner: {d}"
1267 );
1268 let points = edge_data_points(path);
1269 assert_eq!(
1270 points.len(),
1271 2,
1272 "unexpected ELK edge data-points: {points:?}"
1273 );
1274 assert_eq!(points[0].1, points[1].1);
1275 assert!(
1276 points[0].0 < points[1].0,
1277 "ELK edge must travel left to right: {points:?}"
1278 );
1279 assert!(
1280 (points[1].0 - points[0].0 - 40.0).abs() < 1.0e-9,
1281 "ELK straight edge must retain the source-backed node span: {points:?}"
1282 );
1283 }
1284
1285 #[cfg(feature = "layout-elk")]
1286 #[test]
1287 fn canonical_svg_keeps_source_ported_elk_self_loop_edges() {
1288 let svg = render_source(
1289 "flowchart-elk TD\nA --> A",
1290 &LayoutOptions::default(),
1291 &crate::svg::SvgRenderOptions::default(),
1292 );
1293
1294 let path = edge_path_chunk(&svg, "L_A_A_0");
1295 let d = edge_path_d(path);
1296 assert!(
1297 d.contains('Q'),
1298 "ELK self-loop path should be rendered from the source-backed edge: {d}"
1299 );
1300 let points = edge_data_points(path);
1301 assert_eq!(
1302 points.len(),
1303 4,
1304 "unexpected ELK self-loop data-points: {points:?}"
1305 );
1306 assert!(
1307 !svg.contains("A---A---1") && !svg.contains("cyclic-special"),
1308 "ELK renderer must not reuse Dagre self-loop helper nodes: {svg}"
1309 );
1310 assert_eq!(edge_attr_value(path, "data-id"), "L_A_A_0");
1311 }
1312
1313 #[test]
1314 fn graph_layout_unregistered_loader_falls_back_to_dagre() {
1315 let mut requests = vec!["unknown", "ELK", "elk.layered", "elk.radial"];
1316 if !cfg!(feature = "layout-elk") {
1317 requests.extend([
1318 "elk",
1319 "elk.stress",
1320 "elk.force",
1321 "elk.mrtree",
1322 "elk.sporeOverlap",
1323 "elk.box",
1324 "elk.rectpacking",
1325 ]);
1326 }
1327 for body in [
1328 "flowchart TD\nA[Alpha] --> B[Beta]\nB --> C[Gamma]",
1329 "classDiagram\nAnimal <|-- Duck",
1330 "erDiagram\nCUSTOMER ||--o{ ORDER : places",
1331 ] {
1332 let render = |layout: &str| {
1333 let source = format!("---\nconfig:\n layout: {layout}\n---\n{body}\n");
1334 let parsed = Engine::new()
1335 .parse_diagram_for_render_model_sync(&source, ParseOptions::strict())
1336 .unwrap()
1337 .unwrap();
1338 let session = crate::environment::RenderEnvironment::deterministic()
1339 .begin_session()
1340 .unwrap();
1341 let plan = crate::family::plan_render(&parsed, &session).unwrap();
1342 assert!(
1343 !plan
1344 .required_capabilities()
1345 .contains(&RenderCapability::LayoutElk)
1346 );
1347 assert!(plan.missing_capabilities().is_empty());
1348 let artifact = crate::family::prepare(parsed, &LayoutOptions::default(), session)
1349 .expect("unregistered loader falls back before capability admission");
1350 let geometry = artifact.layout_json().unwrap()["layout"].clone();
1351 let svg = artifact
1352 .render_svg(
1353 &crate::svg::SvgRenderOptions {
1354 diagram_id: Some("fallback-test".into()),
1355 ..Default::default()
1356 },
1357 &crate::svg::SvgDebugOptions::default(),
1358 )
1359 .unwrap()
1360 .svg()
1361 .to_owned();
1362 (geometry, svg)
1363 };
1364 let expected = render("dagre");
1365 for request in &requests {
1366 assert_eq!(render(request), expected, "{request}: {body}");
1367 }
1368 }
1369 }
1370
1371 #[cfg(not(feature = "layout-elk"))]
1372 #[test]
1373 fn flowchart_elk_alias_uses_dagre_geometry_when_loader_is_absent() {
1374 let parsed = Engine::new()
1375 .parse_diagram_for_render_model_with_type_sync(
1376 "flowchart-elk",
1377 "flowchart-elk TD\nA-->B;",
1378 ParseOptions::strict(),
1379 )
1380 .unwrap()
1381 .unwrap();
1382 let session = crate::environment::RenderEnvironment::deterministic()
1383 .begin_session()
1384 .unwrap();
1385 let artifact = crate::family::prepare(parsed, &LayoutOptions::default(), session).unwrap();
1386 let geometry = artifact.layout_json().unwrap()["layout"].clone();
1387 let dagre_parsed = Engine::new()
1388 .parse_diagram_for_render_model_sync(
1389 "---\nconfig:\n layout: dagre\n---\nflowchart TD\nA-->B;",
1390 ParseOptions::strict(),
1391 )
1392 .unwrap()
1393 .unwrap();
1394 let dagre_session = crate::environment::RenderEnvironment::deterministic()
1395 .begin_session()
1396 .unwrap();
1397 let dagre_artifact =
1398 crate::family::prepare(dagre_parsed, &LayoutOptions::default(), dagre_session).unwrap();
1399 assert_eq!(geometry, dagre_artifact.layout_json().unwrap()["layout"]);
1400 let svg = artifact
1401 .render_svg(
1402 &crate::svg::SvgRenderOptions::default(),
1403 &crate::svg::SvgDebugOptions::default(),
1404 )
1405 .unwrap()
1406 .svg()
1407 .to_owned();
1408 assert!(svg.contains("edgePath"), "{svg}");
1409 }
1410
1411 #[test]
1412 fn registered_cose_graph_failure_is_not_replaced_by_dagre() {
1413 for body in [
1414 "flowchart LR\nA-->B\nA-->C",
1415 "agentflow-beta\nA-->B",
1416 "usecase-beta\nactor A\nA --> B",
1417 "classDiagram\nA --> B",
1418 "stateDiagram-v2\nA --> B",
1419 "erDiagram\nA ||--o{ B : relates",
1420 "requirementDiagram\nrequirement r {\nid: 1\ntext: Demo\nrisk: low\nverifymethod: test\n}",
1421 ] {
1422 for layout in ["cose-bilkent", "unknown-layout-probe"] {
1423 let source = format!("---\nconfig:\n layout: {layout}\n---\n{body}\n");
1424 let parsed = Engine::new()
1425 .parse_diagram_for_render_model_sync(&source, ParseOptions::strict())
1426 .unwrap()
1427 .unwrap();
1428 let session = crate::environment::RenderEnvironment::deterministic()
1429 .begin_session()
1430 .unwrap();
1431 let result = crate::family::prepare(parsed, &LayoutOptions::default(), session);
1432 if layout == "cose-bilkent" && cfg!(feature = "layout-cytoscape") {
1433 assert!(
1434 matches!(result, Err(Error::InvalidModel { message }) if message.contains("Root node is required")),
1435 "{body}"
1436 );
1437 } else {
1438 result.unwrap_or_else(|error| panic!("{body}: {layout}: {error}"));
1439 }
1440 }
1441 }
1442 }
1443
1444 #[cfg(feature = "layout-cytoscape")]
1445 #[test]
1446 fn registered_cose_host_denial_precedes_missing_root_failure() {
1447 let source = "---\nconfig:\n layout: cose-bilkent\n---\nusecase-beta\nactor A\nA --> B\n";
1448 let parsed = Engine::new()
1449 .parse_diagram_for_render_model_sync(source, ParseOptions::strict())
1450 .unwrap()
1451 .unwrap();
1452 let session = crate::environment::RenderEnvironment::deterministic()
1453 .with_capability_policy(RenderCapabilityPolicy::deny_all())
1454 .begin_session()
1455 .unwrap();
1456 let plan = crate::family::plan_render(&parsed, &session).unwrap();
1457 assert_eq!(
1458 plan.missing_capabilities(),
1459 &[RenderCapability::LayoutCytoscape]
1460 );
1461 assert!(matches!(
1462 crate::family::prepare(parsed, &LayoutOptions::default(), session),
1463 Err(Error::MissingCapability {
1464 capability: RenderCapability::LayoutCytoscape,
1465 ..
1466 })
1467 ));
1468 }
1469
1470 #[cfg(feature = "layout-elk")]
1471 #[test]
1472 fn registered_elk_denied_by_host_policy_does_not_fall_back() {
1473 for body in [
1474 "flowchart TD\nA-->B",
1475 "classDiagram\nAnimal <|-- Duck",
1476 "erDiagram\nCUSTOMER ||--o{ ORDER : places",
1477 ] {
1478 for layout in [
1479 "elk",
1480 "elk.stress",
1481 "elk.force",
1482 "elk.mrtree",
1483 "elk.sporeOverlap",
1484 "elk.box",
1485 "elk.rectpacking",
1486 ] {
1487 let source = format!("---\nconfig:\n layout: {layout}\n---\n{body}\n");
1488 let parsed = Engine::new()
1489 .parse_diagram_for_render_model_sync(&source, ParseOptions::strict())
1490 .unwrap()
1491 .unwrap();
1492 let session = crate::environment::RenderEnvironment::deterministic()
1493 .with_capability_policy(RenderCapabilityPolicy::deny_all())
1494 .begin_session()
1495 .unwrap();
1496 let plan = crate::family::plan_render(&parsed, &session).unwrap();
1497 assert_eq!(plan.missing_capabilities(), &[RenderCapability::LayoutElk]);
1498 assert!(matches!(
1499 crate::family::prepare(parsed, &LayoutOptions::default(), session),
1500 Err(Error::MissingCapability {
1501 capability: RenderCapability::LayoutElk,
1502 ..
1503 })
1504 ));
1505 }
1506 }
1507 }
1508
1509 #[test]
1510 fn render_model_dispatch_renders_cynefin_svg() {
1511 let source = r#"cynefin-beta
1512 title Team Practices
1513 accTitle: Cynefin map
1514 accDescr: Practice movement
1515 complex
1516 "Pair programming"
1517 complicated
1518 "Architecture review"
1519 complex --> complicated : "Pattern emerges"
1520"#;
1521 let svg = render_source(
1522 source,
1523 &LayoutOptions::default(),
1524 &crate::svg::SvgRenderOptions {
1525 diagram_id: Some("cynefin-test".to_string()),
1526 ..Default::default()
1527 },
1528 );
1529
1530 assert!(svg.contains(r#"aria-roledescription="cynefin""#));
1531 assert!(svg.contains(r#"<g class="cynefin-backgrounds">"#));
1532 assert!(svg.contains(r#"class="cynefinDomain""#));
1533 assert!(svg.contains(r#"class="cynefinBoundary""#));
1534 assert!(svg.contains(r#"class="cynefinCliff""#));
1535 assert!(svg.contains(r#"class="cynefinItem""#));
1536 assert!(svg.contains("Pair programming"));
1537 assert!(svg.contains(r#"class="cynefinArrowLine""#));
1538 assert!(svg.contains("Pattern emerges"));
1539 assert!(svg.contains(r#"<title id="chart-title-cynefin-test">Cynefin map</title>"#));
1540 assert!(svg.contains(r#"<desc id="chart-desc-cynefin-test">Practice movement</desc>"#));
1541 assert!(svg.contains("#cynefin-test .cynefinDomain{stroke:none;}"));
1542 assert_eq!(svg.matches("<title").count(), 2, "{svg}");
1543 assert_eq!(svg.matches("<desc").count(), 2, "{svg}");
1544
1545 let scoped_title = svg
1546 .find(r#"<title id="chart-title-cynefin-test">"#)
1547 .expect("scoped accessibility title");
1548 let scoped_descr = svg
1549 .find(r#"<desc id="chart-desc-cynefin-test">"#)
1550 .expect("scoped accessibility description");
1551 let style = svg.find("<style>").expect("style");
1552 let framework_group = svg.find("<g/>").expect("Mermaid framework group");
1553 let renderer_title = svg
1554 .find("<title>Cynefin map</title>")
1555 .expect("renderer accessibility title");
1556 let renderer_descr = svg
1557 .find("<desc>Practice movement</desc>")
1558 .expect("renderer accessibility description");
1559 let root_group = svg
1560 .find(r#"<g transform="translate("#)
1561 .expect("cynefin root group");
1562 let defs = svg.find("<defs>").expect("transition marker defs");
1563
1564 assert!(scoped_title < scoped_descr, "{svg}");
1565 assert!(scoped_descr < style, "{svg}");
1566 assert!(style < framework_group, "{svg}");
1567 assert!(framework_group < renderer_title, "{svg}");
1568 assert!(renderer_title < renderer_descr, "{svg}");
1569 assert!(renderer_descr < root_group, "{svg}");
1570 assert!(root_group < defs, "{svg}");
1571 }
1572
1573 #[test]
1574 fn render_model_dispatch_keeps_whitespace_cynefin_transition_labels() {
1575 let source = r#"cynefin-beta
1576 complex
1577 complicated
1578 complex --> complicated : " "
1579"#;
1580 let svg = render_source(
1581 source,
1582 &LayoutOptions::default(),
1583 &crate::svg::SvgRenderOptions {
1584 diagram_id: Some("cynefin-whitespace".to_string()),
1585 ..Default::default()
1586 },
1587 );
1588
1589 assert!(
1590 svg.contains(r#"class="cynefinArrowLabel""#),
1591 "a whitespace-only label is truthy in JavaScript and must emit a text node: {svg}"
1592 );
1593 }
1594
1595 #[test]
1596 fn render_model_dispatch_renders_railroad_svg() {
1597 let source = r#"railroad-beta
1598accTitle: Railroad grammar
1599accDescr: Expression grammar
1600expr = sequence(nonterminal("term"), optional(special("guard")), zeroOrMore(terminal("+"))) ;
1601"#;
1602 let svg = render_source(
1603 source,
1604 &LayoutOptions::default(),
1605 &crate::svg::SvgRenderOptions {
1606 diagram_id: Some("railroad-test".to_string()),
1607 ..Default::default()
1608 },
1609 );
1610
1611 assert!(svg.contains(r#"aria-roledescription="railroad""#));
1612 assert!(svg.contains(r#"class="railroad-diagram""#));
1613 assert!(svg.contains(r#"class="railroad-rule""#));
1614 assert!(svg.contains(r#"class="railroad-rule-name""#));
1615 assert!(svg.contains(r#"class="railroad-nonterminal""#));
1616 assert!(svg.contains(r#"class="railroad-special""#));
1617 assert!(svg.contains(r#"class="railroad-terminal""#));
1618 assert!(svg.contains(r#"class="railroad-line""#));
1619 assert!(svg.contains("term"));
1620 assert!(svg.contains("? guard ?"));
1621 assert!(svg.contains("+"));
1622 assert!(svg.contains(r#"<title id="chart-title-railroad-test">Railroad grammar</title>"#));
1623 assert!(svg.contains(r#"<desc id="chart-desc-railroad-test">Expression grammar</desc>"#));
1624 assert!(
1625 svg.contains("</style><g/><g class=\"railroad-rule\""),
1626 "{svg}"
1627 );
1628 }
1629
1630 #[cfg(feature = "layout-elk")]
1631 fn edge_path_chunk<'a>(svg: &'a str, edge_id: &str) -> &'a str {
1632 let id_attr = format!(r#"id="merman-{edge_id}""#);
1633 let id_start = svg.find(&id_attr).expect("edge id");
1634 let path_start = svg[..id_start].rfind("<path ").expect("edge path start");
1635 let path_end = svg[id_start..].find("/>").expect("edge path end") + id_start;
1636 &svg[path_start..path_end]
1637 }
1638
1639 #[cfg(feature = "layout-elk")]
1640 fn edge_path_d(path: &str) -> &str {
1641 let d_start = path.find(r#"d=""#).expect("edge path d") + r#"d=""#.len();
1642 let d_end = path[d_start..].find('"').expect("edge path d end") + d_start;
1643 &path[d_start..d_end]
1644 }
1645
1646 #[cfg(feature = "layout-elk")]
1647 fn edge_attr_value<'a>(path: &'a str, attr: &str) -> &'a str {
1648 let needle = format!(r#"{attr}=""#);
1649 let start = path.find(&needle).expect("edge attr") + needle.len();
1650 let end = path[start..].find('"').expect("edge attr end") + start;
1651 &path[start..end]
1652 }
1653
1654 #[cfg(feature = "layout-elk")]
1655 fn edge_data_points(path: &str) -> Vec<(f64, f64)> {
1656 use base64::Engine as _;
1657
1658 let b64 = edge_attr_value(path, "data-points");
1659 let bytes = base64::engine::general_purpose::STANDARD
1660 .decode(b64.as_bytes())
1661 .expect("data-points base64");
1662 let json: serde_json::Value =
1663 serde_json::from_slice(&bytes).expect("data-points JSON payload");
1664 json.as_array()
1665 .expect("data-points array")
1666 .iter()
1667 .map(|point| {
1668 (
1669 point.get("x").and_then(serde_json::Value::as_f64).unwrap(),
1670 point.get("y").and_then(serde_json::Value::as_f64).unwrap(),
1671 )
1672 })
1673 .collect()
1674 }
1675}
1676
1677#[cfg(feature = "diagram-class")]
1678pub use resources::ClassComplexity;
1679
1680#[cfg(any(
1681 feature = "diagram-flowchart",
1682 feature = "diagram-swimlane",
1683 feature = "diagram-agentflow"
1684))]
1685pub use resources::FlowchartComplexity;
1686
1687#[cfg(feature = "diagram-mindmap")]
1688pub use resources::MindmapComplexity;
1689
1690#[cfg(feature = "diagram-sequence")]
1691pub use resources::SequenceComplexity;
1692
1693#[cfg(feature = "diagram-zenuml")]
1694pub use resources::ZenumlComplexity;