pub mod layout;
pub mod parse;
pub mod svg;
#[must_use]
pub fn render(source: &str) -> Option<(String, String)> {
let graph = parse::parse(source)?;
let placed = layout::layout(&graph)?;
Some((svg::render(&graph, &placed), svg::accessible_text(&graph)))
}
#[cfg(test)]
mod readability {
use super::*;
fn boxes(source: &str) -> Vec<(String, f64, f64, f64, f64)> {
let graph = parse::parse(source).expect("parses");
let placed = layout::layout(&graph).expect("lays out");
placed
.placed
.iter()
.map(|p| (graph.nodes[p.node].label.clone(), p.x, p.y, p.w, p.h))
.collect()
}
const SAMPLE: &str =
"flowchart TD\n A[Harness] --> B[Markdown]\n B --> C[Mobile GUI]\n A --> C";
#[test]
fn contrast_is_the_pages_own_and_no_colour_is_hardcoded() {
let (svg, _) = render(SAMPLE).expect("renders");
for (attr, count) in [("stroke=\"currentColor\"", 3), ("fill=\"currentColor\"", 3)] {
assert!(
svg.matches(attr).count() >= count,
"expected at least {count} × {attr} in:\n{svg}"
);
}
assert!(svg.contains(r#"fill="none""#), "boxes must not be filled");
for forbidden in ["#", "rgb(", "hsl(", "black", "white", "red", "blue"] {
assert!(
!svg.contains(forbidden),
"a hardcoded colour ({forbidden}) can disagree with the page: {svg}"
);
}
}
#[test]
fn the_contrast_check_would_notice_a_hardcoded_colour() {
let (svg, _) = render(SAMPLE).expect("renders");
assert!(svg.len() > 200, "the sample must really render: {svg}");
let seeded = svg.replace(r#"fill="none""#, r##"fill="#000000""##);
assert!(
seeded.contains('#'),
"the check cannot see a hardcoded colour even when one is present"
);
}
#[test]
fn every_label_fits_inside_its_box() {
for source in [
SAMPLE,
"flowchart LR\n A[a] --> B[a much longer label than the first]",
"flowchart TD\n X[\u{65e5}\u{672c}\u{8a9e}] --> Y[ok]",
] {
for (label, _x, _y, w, h) in boxes(source) {
let needed = label.chars().count() as f64 * 8.0;
assert!(
needed <= w,
"label {label:?} needs {needed} but its box is {w}"
);
assert!(h >= 20.0, "box too short to hold a line of text");
}
}
}
#[test]
fn no_two_boxes_overlap() {
for source in [
SAMPLE,
"flowchart LR\n A --> B\n A --> C\n B --> D\n C --> D",
"flowchart TD\n A --> B\n A --> C\n A --> D\n A --> E",
] {
let placed = boxes(source);
for (i, a) in placed.iter().enumerate() {
for b in placed.iter().skip(i + 1) {
let apart = a.1 + a.3 <= b.1
|| b.1 + b.3 <= a.1
|| a.2 + a.4 <= b.2
|| b.2 + b.4 <= a.2;
assert!(apart, "{:?} overlaps {:?}", a.0, b.0);
}
}
}
}
#[test]
fn every_node_and_edge_reaches_the_output() {
let (svg, alt) = render(SAMPLE).expect("renders");
for label in ["Harness", "Markdown", "Mobile GUI"] {
assert!(svg.contains(label), "node {label} missing from svg");
assert!(alt.contains(label), "node {label} missing from alt text");
}
assert_eq!(svg.matches("<rect").count(), 3, "one box per node");
assert_eq!(svg.matches("<line").count(), 3, "one line per edge");
assert_eq!(svg.matches("<polygon").count(), 3, "one arrowhead per edge");
}
#[test]
fn author_text_is_escaped_before_it_reaches_the_svg() {
let (svg, alt) =
render("flowchart TD\n A[<script>alert(1)</script>] --> B[\"quoted\" & 'apostrophe']")
.expect("renders");
assert!(!svg.contains("<script>"), "raw script tag in svg: {svg}");
assert!(
svg.contains("<script>"),
"escaped form missing: {svg}"
);
assert!(!svg.contains("alert(1)</"), "unescaped close tag: {svg}");
assert!(alt.contains("script"), "alt keeps the text: {alt}");
assert!(svg.contains("&"), "ampersand escaped: {svg}");
assert!(
svg.contains("'") || svg.contains("""),
"quotes escaped"
);
}
#[test]
fn a_cycle_declines_rather_than_guessing() {
assert!(render("flowchart TD\n A --> B\n B --> A").is_none());
assert!(render("flowchart TD\n A --> B\n B --> C\n C --> A").is_none());
assert!(render("flowchart TD\n A --> B\n A --> C\n B --> C").is_some());
}
#[test]
fn rendering_is_deterministic() {
let once = render(SAMPLE).expect("renders");
for _ in 0..8 {
assert_eq!(render(SAMPLE).expect("renders"), once);
}
}
}