merman-render 0.8.0

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use super::super::*;
use super::SequenceEmitCheckpoints;
use crate::sequence::{SequenceMathHeightMode, measure_sequence_math_label};

pub(super) struct SequenceKatexLabel {
    pub(super) html: String,
    pub(super) width: f64,
    pub(super) height: f64,
}

pub(super) fn sequence_katex_label(
    text: &str,
    measurer: &dyn TextMeasurer,
    style: &TextStyle,
    config: &merman_core::MermaidConfig,
    math_renderer: Option<&(dyn crate::math::MathRenderer + Send + Sync)>,
    height_mode: SequenceMathHeightMode,
    checkpoints: SequenceEmitCheckpoints<'_>,
) -> Result<Option<SequenceKatexLabel>> {
    checkpoints.checkpoint()?;
    if !text.contains("$$") {
        return Ok(None);
    }
    let Some(renderer) = math_renderer else {
        return Ok(None);
    };
    let Some((width, height)) = measure_sequence_math_label(
        measurer,
        text,
        style,
        config,
        Some(renderer),
        height_mode,
        checkpoints.text(),
    )?
    else {
        return Ok(None);
    };
    checkpoints.checkpoint()?;
    let html = renderer.render_sequence_html_label(text, config);
    checkpoints.checkpoint()?;
    let Some(html) = html else {
        return Ok(None);
    };
    let html = xhtml_fix_fragment(&merman_core::sanitize::sanitize_text(&html, config));
    Ok(Some(SequenceKatexLabel {
        html,
        width,
        height,
    }))
}

pub(super) fn write_sequence_katex_foreign_object(
    out: &mut String,
    label: &SequenceKatexLabel,
    x: f64,
    y: f64,
) {
    let _ = write!(
        out,
        r#"<foreignObject height="{h}" width="{w}" x="{x}" y="{y}"><div style="width: fit-content;" xmlns="http://www.w3.org/1999/xhtml">{html}</div></foreignObject>"#,
        h = fmt(label.height),
        w = fmt(label.width),
        x = fmt(x),
        y = fmt(y),
        html = label.html,
    );
}

fn xhtml_fix_fragment(input: &str) -> String {
    input
        .replace("<br>", "<br />")
        .replace("<br/>", "<br />")
        .replace("<br >", "<br />")
        .replace("</br>", "<br />")
        .replace("</br/>", "<br />")
        .replace("</br />", "<br />")
        .replace("</br >", "<br />")
}

#[cfg(test)]
mod tests {
    use super::sequence_katex_label;
    use crate::math::MathRenderer;
    use crate::resources::{OperationWorkMeter, RenderResourcePolicy};
    use crate::sequence::SequenceMathHeightMode;
    use crate::text::{DeterministicTextMeasurer, TextMetrics, TextStyle};
    use merman_core::{MermaidConfig, OperationControl, OperationPhase};
    use std::sync::atomic::{AtomicUsize, Ordering};

    #[derive(Debug)]
    struct CancellingMathRenderer {
        control: OperationControl,
        measurement_calls: AtomicUsize,
        render_calls: AtomicUsize,
    }

    impl MathRenderer for CancellingMathRenderer {
        fn render_html_label(&self, text: &str, _config: &MermaidConfig) -> Option<String> {
            self.render_calls.fetch_add(1, Ordering::Relaxed);
            Some(text.to_string())
        }

        fn measure_sequence_html_label(
            &self,
            _text: &str,
            _config: &MermaidConfig,
        ) -> Option<TextMetrics> {
            let calls = self.measurement_calls.fetch_add(1, Ordering::Relaxed) + 1;
            if calls == 1 {
                self.control.cancel();
            }
            Some(TextMetrics {
                width: 20.0,
                height: 20.0,
                line_count: 1,
            })
        }
    }

    #[test]
    fn sequence_math_emit_stops_after_measurement_callback_cancels() {
        let control = OperationControl::new();
        let meter = OperationWorkMeter::new_with_control(
            RenderResourcePolicy::unbounded_for_trusted_input(),
            control.clone(),
        );
        let renderer = CancellingMathRenderer {
            control,
            measurement_calls: AtomicUsize::new(0),
            render_calls: AtomicUsize::new(0),
        };
        let text = std::iter::repeat_n("$$x$$", 130)
            .collect::<Vec<_>>()
            .join(" ");

        let result = sequence_katex_label(
            &text,
            &DeterministicTextMeasurer::default(),
            &TextStyle::default(),
            &MermaidConfig::default(),
            Some(&renderer),
            SequenceMathHeightMode::Draw,
            super::SequenceEmitCheckpoints::new(&meter),
        );
        let error = match result {
            Err(error) => error,
            Ok(_) => panic!("expected Sequence emit cancellation"),
        };
        let crate::Error::Cancelled(error) = error else {
            panic!("expected Sequence emit cancellation");
        };

        assert_eq!(error.phase, OperationPhase::Emit);
        assert_eq!(renderer.measurement_calls.load(Ordering::Relaxed), 1);
        assert_eq!(renderer.render_calls.load(Ordering::Relaxed), 0);
    }
}