eulumdat 0.7.0

Eulumdat (LDT) and IES photometric file parser, writer, and validator for Rust
Documentation
//! Typst-source generator for the street-design report.
//!
//! Produces a self-contained `.typ` file that the user can compile to PDF
//! locally with `typst compile street-report.typ`. Pre-rendered SVGs are
//! embedded inline via Typst's `image(bytes(...))` form, so the report is
//! reproducible without re-running the layout solver.
//!
//! The generator is intentionally split out from the (heavy) `eulumdat-typst`
//! crate so it stays available in `no_std`-friendly callers — most
//! importantly the WASM editor, which can't pull in 50 MB of typst
//! dependencies just to emit a string.
//!
//! # Example
//!
//! ```ignore
//! use eulumdat::area::AreaResult;
//! use eulumdat::street::{StreetLayout, report::{generate_street_report, StreetReportInput}};
//! let layout = StreetLayout::default();
//! let area: AreaResult = /* from layout.compute(&ldc, mf) */;
//! let input = StreetReportInput {
//!     luminaire_name: "BIOLUX HCL DL DN150",
//!     standard_name: "ANSI/IES RP-8 (Major/Medium)",
//!     layout: &layout,
//!     area: &area,
//!     compliance: &[],
//!     plan_svg: Some("<svg/>"),
//!     principal_planes_svg: None,
//!     tradeoff_svg: None,
//!     optimizer_top: &[],
//! };
//! let typ = generate_street_report(&input);
//! assert!(typ.starts_with("// Street designer report"));
//! ```
//!
//! # PDF compilation
//!
//! The output of this function is *Typst source*, not PDF bytes. To get a
//! PDF, run `typst compile street-report.typ` (the typst CLI is a single
//! ~30 MB binary). The WASM editor surfaces the `.typ` download alongside
//! the SVG/CSV/JSON exports — compiling locally avoids shipping a typst
//! engine to every visitor.

use crate::area::AreaResult;
use crate::standards::ComplianceResult;
use crate::street::{Arrangement, OptimizationCandidate, StreetLayout};

/// All inputs needed to build a street report. Borrowed (no allocation)
/// so callers can hand over their own state without cloning.
pub struct StreetReportInput<'a> {
    pub luminaire_name: &'a str,
    pub standard_name: &'a str,
    pub layout: &'a StreetLayout,
    pub area: &'a AreaResult,
    pub compliance: &'a [ComplianceResult],
    pub plan_svg: Option<&'a str>,
    pub principal_planes_svg: Option<&'a str>,
    pub tradeoff_svg: Option<&'a str>,
    /// Top-N candidates (typically the table the user sees in-app).
    pub optimizer_top: &'a [OptimizationCandidate],
}

/// Generate a complete Typst report as a string.
pub fn generate_street_report(input: &StreetReportInput<'_>) -> String {
    let mut out = String::new();
    out.push_str(&preamble(input.luminaire_name));
    out.push_str(&title_page(input));
    out.push_str(&design_summary(input));
    out.push_str(&compliance_section(input));
    if let Some(svg) = input.plan_svg {
        out.push_str(&plan_section(svg));
    }
    if let Some(svg) = input.principal_planes_svg {
        out.push_str(&principal_planes_section(svg));
    }
    if let Some(svg) = input.tradeoff_svg {
        out.push_str(&tradeoff_section(svg));
    }
    if !input.optimizer_top.is_empty() {
        out.push_str(&optimizer_section(input.optimizer_top));
    }
    out
}

fn preamble(luminaire: &str) -> String {
    let title = typst_string_escape(&format!("Street design report — {luminaire}"));
    format!(
        r#"// Street designer report
// Generated by eulumdat::street::report

#set document(title: "{title}", author: "eulumdat-rs")
#set page(paper: "a4", margin: (x: 2cm, y: 2.5cm))
#set text(size: 10pt)
#set heading(numbering: "1.1")
#set par(justify: true)

"#,
    )
}

fn title_page(input: &StreetReportInput<'_>) -> String {
    let lum = typst_string_escape(input.luminaire_name);
    let std = typst_string_escape(input.standard_name);
    format!(
        r#"#align(center + horizon)[
  #text(size: 22pt, weight: "bold")[Street Lighting Design Report]
  #v(1em)
  #text(size: 14pt)[Luminaire: {lum}]
  #v(0.4em)
  #text(size: 12pt)[Reference standard: {std}]
]
#pagebreak()

"#,
    )
}

fn design_summary(input: &StreetReportInput<'_>) -> String {
    let layout = input.layout;
    let area = input.area;
    let arr = match layout.arrangement {
        Arrangement::SingleSide => "Single-side",
        Arrangement::Opposite => "Opposite",
        Arrangement::Staggered => "Staggered",
    };
    format!(
        r#"= Design summary

#table(
  columns: 2,
  stroke: 0.5pt,
  [*Length*],            [{length:.1} m],
  [*Lane width*],        [{lane:.2} m],
  [*Number of lanes*],   [{num_lanes}],
  [*Pole spacing*],      [{spacing:.1} m],
  [*Mounting height*],   [{height:.1} m],
  [*Arrangement*],       [{arr}],
  [*Overhang*],          [{overhang:.2} m],
  [*Tilt*],              [{tilt:.1}°],
  [*Avg illuminance*],   [{avg:.1} lx],
  [*Min illuminance*],   [{min:.1} lx],
  [*Max illuminance*],   [{max:.1} lx],
  [*Uniformity (min/avg)*], [{u:.2}],
)

#v(1em)

"#,
        length = layout.length_m,
        lane = layout.lane_width_m,
        num_lanes = layout.num_lanes,
        spacing = layout.pole_spacing_m,
        height = layout.mounting_height_m,
        overhang = layout.overhang_m,
        tilt = layout.tilt_deg,
        avg = area.avg_lux,
        min = area.min_lux,
        max = area.max_lux,
        u = area.uniformity_min_avg,
    )
}

fn compliance_section(input: &StreetReportInput<'_>) -> String {
    if input.compliance.is_empty() {
        return String::new();
    }
    let mut s = String::from("= Compliance\n\n");
    for r in input.compliance {
        let badge = if r.passed() { "āœ“ PASS" } else { "āœ— FAIL" };
        s.push_str(&format!(
            "== {} Ā· {} Ā· {}\n\n",
            typst_inline_escape(&r.region.to_string()),
            typst_inline_escape(&r.standard),
            badge,
        ));
        if r.items.is_empty() {
            s.push_str("_No criteria evaluated._\n\n");
            continue;
        }
        s.push_str("#table(\n  columns: 4,\n  stroke: 0.5pt,\n  [*Criterion*], [*Required*], [*Achieved*], [*Status*],\n");
        for it in &r.items {
            let status = if it.passed { "āœ“" } else { "āœ—" };
            s.push_str(&format!(
                "  [{}], [{}], [{}], [{}],\n",
                typst_inline_escape(&it.parameter),
                typst_inline_escape(&it.required),
                typst_inline_escape(&it.achieved),
                status,
            ));
        }
        s.push_str(")\n\n");
    }
    s
}

fn plan_section(svg: &str) -> String {
    format!(
        r#"#pagebreak()
= Plan view

#align(center)[
  #image(bytes("{svg}"), width: 100%)
]

"#,
        svg = typst_string_escape(svg),
    )
}

fn principal_planes_section(svg: &str) -> String {
    format!(
        r#"#pagebreak()
= PV / PC distribution

The principal-planes diagram shows the longitudinal (along-road, C0–C180)
and transverse (across-road, C90–C270) intensity cuts at the same scale.

#align(center)[
  #image(bytes("{svg}"), width: 90%)
]

"#,
        svg = typst_string_escape(svg),
    )
}

fn tradeoff_section(svg: &str) -> String {
    format!(
        r#"#pagebreak()
= Layout trade-off

Each marker is a passing layout from the optimizer. The Pareto frontier
identifies designs where you cannot reduce poles per km without losing
average illuminance.

#align(center)[
  #image(bytes("{svg}"), width: 95%)
]

"#,
        svg = typst_string_escape(svg),
    )
}

fn optimizer_section(top: &[OptimizationCandidate]) -> String {
    let mut s = String::from("#pagebreak()\n= Optimizer top picks\n\n#table(\n  columns: 7,\n  stroke: 0.5pt,\n  [*Spacing*], [*Height*], [*Arrangement*], [*Poles/km*], [*Avg lux*], [*Uā‚€*], [*Flux/km*],\n");
    for c in top {
        let arr = match c.arrangement {
            Arrangement::SingleSide => "Single",
            Arrangement::Opposite => "Opposite",
            Arrangement::Staggered => "Staggered",
        };
        s.push_str(&format!(
            "  [{spacing:.1} m], [{height:.1} m], [{arr}], [{poles:.1}], [{avg:.1}], [{u:.2}], [{flux:.0}],\n",
            spacing = c.pole_spacing_m,
            height = c.mounting_height_m,
            poles = c.poles_per_km,
            avg = c.design.avg_illuminance_lux,
            u = c.design.uniformity_overall,
            flux = c.flux_per_km,
        ));
    }
    s.push_str(")\n\n");
    s
}

/// Escape a string for embedding inside a Typst quoted string literal.
/// Typst strings use the same escaping as Rust: backslash escapes
/// backslash, doublequote, and is the prefix for unicode escapes.
fn typst_string_escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

/// Escape text inside `[...]` content cells. The dangerous characters are
/// `[`, `]`, `#`, and `*` (markup). We round-trip them as `\[`, `\]`, `\#`,
/// `\*` so they render literally.
fn typst_inline_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '[' | ']' | '#' | '*' | '\\' => {
                out.push('\\');
                out.push(c);
            }
            _ => out.push(c),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::area::AreaResult;
    use crate::street::StreetLayout;

    fn dummy_input() -> (StreetLayout, AreaResult) {
        let area = AreaResult {
            lux_grid: Vec::new(),
            min_lux: 5.0,
            avg_lux: 12.5,
            max_lux: 20.0,
            uniformity_min_avg: 0.4,
            uniformity_avg_min: 2.5,
            uniformity_min_max: 0.25,
            area_width: 7.0,
            area_depth: 30.0,
            grid_resolution: 10,
            mask: None,
        };
        (StreetLayout::default(), area)
    }

    #[test]
    fn report_renders_minimal_input() {
        let (layout, area) = dummy_input();
        let input = StreetReportInput {
            luminaire_name: "Test \"luminaire\"",
            standard_name: "RP-8",
            layout: &layout,
            area: &area,
            compliance: &[],
            plan_svg: None,
            principal_planes_svg: None,
            tradeoff_svg: None,
            optimizer_top: &[],
        };
        let s = generate_street_report(&input);
        assert!(s.starts_with("// Street designer report"));
        assert!(s.contains("Street Lighting Design Report"));
        assert!(s.contains("RP-8"));
        // Doublequotes in the luminaire name must be escaped.
        assert!(s.contains(r#"Test \"luminaire\""#));
    }

    #[test]
    fn report_includes_plan_svg_when_provided() {
        let (layout, area) = dummy_input();
        let input = StreetReportInput {
            luminaire_name: "X",
            standard_name: "Y",
            layout: &layout,
            area: &area,
            compliance: &[],
            plan_svg: Some(r#"<svg id="plan"/>"#),
            principal_planes_svg: None,
            tradeoff_svg: None,
            optimizer_top: &[],
        };
        let s = generate_street_report(&input);
        assert!(s.contains("= Plan view"));
        assert!(s.contains(r#"<svg id=\"plan\"/>"#));
    }

    #[test]
    fn typst_escape_handles_brackets_and_hash() {
        let s = typst_inline_escape("a [b] #c *d* \\e");
        assert_eq!(s, r"a \[b\] \#c \*d\* \\e");
    }
}