Skip to main content

kymostudio_core/
lib.rs

1//! kymostudio core — pure-Rust SVG rasterization (PNG) and vector PDF, built on
2//! [`resvg`] / [`svg2pdf`].
3//!
4//! No browser, no headless Chrome, no C dependencies. This mirrors what the
5//! Python package does via `resvg-py` (`to_webp.py`) so SVG→PNG output stays
6//! consistent across implementations — `resvg` is CSS-class-aware, which is why
7//! the project avoids cairosvg. PDF goes through `svg2pdf` (same usvg lineage),
8//! keeping vector output CSS-class-aware too.
9
10use resvg::{tiny_skia, usvg};
11
12// The shared diagram engine — pure Rust, no SVG deps. Mermaid import parses
13// into [`model::Diagram`], lays it out, and serializes to the `.kymo.json`
14// interchange format the Python/JS front-ends consume.
15pub mod d2;
16pub mod dot;
17pub mod drawio;
18pub mod flowchart;
19pub mod flowchart_svg;
20pub mod kymojson;
21pub mod layout;
22pub mod math;
23pub mod mermaid;
24pub mod model;
25pub mod sequence;
26
27// Language-binding facades — each compiled only when its feature is on.
28#[cfg(feature = "python")]
29mod python;
30#[cfg(feature = "wasm")]
31mod wasm;
32
33// BPMN stack (import / export / layout / shapes) — the cross-language single
34// source of truth. Pure Rust; compiled under the `bpmn` feature. Mirrors the
35// Python pipeline module-for-module (see each submodule's header).
36#[cfg(feature = "bpmn")]
37pub mod bpmn;
38
39/// Fonts registered at runtime for font-less builds. The wasm build has no
40/// system fonts (`system-fonts` is off — no fs/mmap) and resvg does not honor
41/// `@font-face`, so without this every `<text>` element silently disappears
42/// from PNG/PDF output. Registered fonts are loaded into the fontdb on each
43/// render; the first registered face also becomes the generic-family fallback
44/// (sans-serif &c.) so the renderers' CSS stacks resolve.
45static EXTRA_FONTS: std::sync::OnceLock<std::sync::Mutex<Vec<Vec<u8>>>> =
46    std::sync::OnceLock::new();
47
48/// Register a font (TTF/OTF bytes) for `<text>` rendering in [`svg_to_png`] /
49/// [`svg_to_pdf`]. Cumulative; intended for wasm/Workers where no system
50/// fonts exist. On native builds system fonts still load — registered fonts
51/// take over only the generic families.
52pub fn register_font(bytes: Vec<u8>) {
53    EXTRA_FONTS
54        .get_or_init(|| std::sync::Mutex::new(Vec::new()))
55        .lock()
56        .unwrap()
57        .push(bytes);
58}
59
60// One macro, two fontdb crates: resvg 0.47's usvg and svg2pdf's usvg 0.45
61// each re-export their own `fontdb`, so this can't be a typed fn. Faces are
62// scanned past the pre-load count so the fallback family is a *registered*
63// face, not whatever system font happens to sort first on native.
64macro_rules! load_extra_fonts {
65    ($db:expr) => {
66        if let Some(fonts) = EXTRA_FONTS.get() {
67            let fonts = fonts.lock().unwrap();
68            if !fonts.is_empty() {
69                let db = $db;
70                let before = db.faces().count();
71                for data in fonts.iter() {
72                    db.load_font_data(data.clone());
73                }
74                let family = db
75                    .faces()
76                    .skip(before)
77                    .find_map(|f| f.families.first().map(|(name, _)| name.clone()));
78                if let Some(family) = family {
79                    db.set_sans_serif_family(family.clone());
80                    db.set_serif_family(family.clone());
81                    db.set_cursive_family(family.clone());
82                    db.set_fantasy_family(family.clone());
83                    db.set_monospace_family(family);
84                }
85            }
86        }
87    };
88}
89
90/// Something went wrong turning SVG bytes into PNG or PDF bytes.
91#[derive(Debug)]
92pub enum RenderError {
93    /// The SVG could not be parsed.
94    Parse(usvg::Error),
95    /// The requested raster size was degenerate (zero / overflow).
96    Size { width: u32, height: u32 },
97    /// PNG encoding failed.
98    Encode(String),
99    /// SVG→PDF conversion failed (svg2pdf parse or encode).
100    Pdf(String),
101}
102
103impl std::fmt::Display for RenderError {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            RenderError::Parse(e) => write!(f, "invalid SVG: {e}"),
107            RenderError::Size { width, height } => {
108                write!(f, "invalid raster size {width}x{height}")
109            }
110            RenderError::Encode(e) => write!(f, "PNG encoding failed: {e}"),
111            RenderError::Pdf(e) => write!(f, "SVG→PDF conversion failed: {e}"),
112        }
113    }
114}
115
116impl std::error::Error for RenderError {}
117
118impl From<usvg::Error> for RenderError {
119    fn from(e: usvg::Error) -> Self {
120        RenderError::Parse(e)
121    }
122}
123
124/// Render SVG bytes to PNG bytes at the given `scale` (1.0 = intrinsic size).
125///
126/// On native builds (`system-fonts` feature, the default) system fonts are
127/// loaded so `<text>` elements rasterize correctly. On wasm that feature is
128/// off — call [`register_font`] first, or text is dropped.
129pub fn svg_to_png(svg: &[u8], scale: f32) -> Result<Vec<u8>, RenderError> {
130    let mut opt = usvg::Options::default();
131    #[cfg(feature = "system-fonts")]
132    opt.fontdb_mut().load_system_fonts();
133    load_extra_fonts!(opt.fontdb_mut());
134
135    let tree = usvg::Tree::from_data(svg, &opt)?;
136    let size = tree.size();
137
138    let width = ((size.width() * scale).round() as i64).clamp(1, u32::MAX as i64) as u32;
139    let height = ((size.height() * scale).round() as i64).clamp(1, u32::MAX as i64) as u32;
140
141    let mut pixmap =
142        tiny_skia::Pixmap::new(width, height).ok_or(RenderError::Size { width, height })?;
143
144    let transform = tiny_skia::Transform::from_scale(scale, scale);
145    resvg::render(&tree, transform, &mut pixmap.as_mut());
146
147    pixmap
148        .encode_png()
149        .map_err(|e| RenderError::Encode(e.to_string()))
150}
151
152/// Convert SVG bytes to a vector PDF (one page, intrinsic SVG size, 72 dpi).
153///
154/// Vector — strokes and text stay crisp at any zoom. Parsing uses `svg2pdf`'s
155/// own bundled usvg (0.45), independent of the `resvg` used by [`svg_to_png`],
156/// so there is no `scale`: PDF is resolution-independent. On native builds
157/// (`system-fonts`) system fonts are loaded so `<text>` renders; the wasm build
158/// keeps this path (for the JS CLI / Workers) but, with no system fonts, needs
159/// [`register_font`] to be called for text to appear.
160#[cfg(feature = "pdf")]
161pub fn svg_to_pdf(svg: &[u8]) -> Result<Vec<u8>, RenderError> {
162    use svg2pdf::usvg as pdf_usvg;
163
164    let mut opt = pdf_usvg::Options::default();
165    #[cfg(feature = "system-fonts")]
166    opt.fontdb_mut().load_system_fonts();
167    load_extra_fonts!(opt.fontdb_mut());
168
169    let tree = pdf_usvg::Tree::from_data(svg, &opt).map_err(|e| RenderError::Pdf(e.to_string()))?;
170
171    svg2pdf::to_pdf(
172        &tree,
173        svg2pdf::ConversionOptions::default(),
174        svg2pdf::PageOptions::default(),
175    )
176    .map_err(|e| RenderError::Pdf(e.to_string()))
177}
178
179/// Parse Mermaid source (flowchart) into the `.kymo.json` interchange string.
180///
181/// The shared engine entry point: parse → layered layout → serialize. Python
182/// (PyO3) and JS (wasm) call this and feed the result to their `from_kymojson`
183/// loaders. Errors describe the unsupported diagram type or the syntax problem.
184pub fn mermaid_to_kymojson(src: &str) -> Result<String, mermaid::MermaidError> {
185    let fc = mermaid::parse(src)?;
186    let diagram = layout::layout_flowchart(&fc);
187    Ok(kymojson::export(&diagram))
188}
189
190/// Convert Mermaid flowchart source to another text DSL via the flowchart IR.
191///
192/// `mmd → {mermaid, d2, dot}` is a parse-then-emit with no layout in between —
193/// the target lays the graph out itself. `to_mermaid` round-trips/normalizes the
194/// source. See [`flowchart::emit`].
195pub fn mermaid_to_d2(src: &str) -> Result<String, mermaid::MermaidError> {
196    Ok(flowchart::emit::to_d2(&mermaid::parse(src)?))
197}
198
199/// Convert Mermaid flowchart source to Graphviz DOT (via the flowchart IR).
200pub fn mermaid_to_dot(src: &str) -> Result<String, mermaid::MermaidError> {
201    Ok(flowchart::emit::to_dot(&mermaid::parse(src)?))
202}
203
204/// Round-trip / normalize Mermaid flowchart source through the IR.
205pub fn mermaid_to_mermaid(src: &str) -> Result<String, mermaid::MermaidError> {
206    Ok(flowchart::emit::to_mermaid(&mermaid::parse(src)?))
207}
208
209/// Convert a Mermaid `sequenceDiagram` to OMG XMI 2.5.1 (a UML 2.5.1
210/// `Interaction` — lifelines, messages, activations, combined fragments, notes).
211///
212/// Parse-then-emit through the [`sequence`] IR; no layout (XMI carries no
213/// geometry). Flowchart sources are rejected with [`mermaid::MermaidError`].
214pub fn mermaid_to_xmi(src: &str) -> Result<String, mermaid::MermaidError> {
215    Ok(sequence::emit::to_xmi(&mermaid::parse_sequence(src)?))
216}
217
218/// Render a Mermaid `sequenceDiagram` to SVG (kymo own renderer: real
219/// `<text>`, so PNG/PDF keep their labels). Notes/activations not yet drawn.
220pub fn mermaid_to_sequence_svg(src: &str) -> Result<String, mermaid::MermaidError> {
221    let mut seq = mermaid::parse_sequence(src)?;
222    for item in &mut seq.items {
223        render_sequence_item_math(item);
224    }
225    Ok(sequence::svg::render(&seq))
226}
227
228/// Render `$…$` TeX math in a sequence item's text (recursing into fragments).
229fn render_sequence_item_math(item: &mut sequence::Item) {
230    match item {
231        sequence::Item::Message(m) => m.text = clean_label(&m.text),
232        sequence::Item::Note(n) => n.text = clean_label(&n.text),
233        sequence::Item::Fragment(f) => {
234            for op in &mut f.operands {
235                op.guard = clean_label(&op.guard);
236                for it in &mut op.items {
237                    render_sequence_item_math(it);
238                }
239            }
240        }
241        sequence::Item::Activate(_)
242        | sequence::Item::Deactivate(_)
243        | sequence::Item::Autonumber(_) => {}
244    }
245}
246
247/// Convert a Mermaid `sequenceDiagram` to a StarUML native `.mdj` (metadata-
248/// JSON) carrying a laid-out sequence diagram.
249///
250/// Unlike [`mermaid_to_xmi`] (model only), the `.mdj` includes the diagram
251/// *views* with geometry, so opening it in StarUML (File → Open) draws the
252/// diagram. See [`sequence::mdj`].
253pub fn mermaid_to_mdj(src: &str) -> Result<String, mermaid::MermaidError> {
254    Ok(sequence::mdj::to_mdj(&mermaid::parse_sequence(src)?))
255}
256
257/// Convert a Mermaid `sequenceDiagram` to a Gaphor native `.gaphor` file
258/// (XML v3.0) carrying a laid-out sequence diagram.
259///
260/// Like [`mermaid_to_mdj`] but for Gaphor. Note Gaphor cannot represent
261/// combined fragments (alt/loop/opt/par) — they are flattened to their inner
262/// messages. See [`sequence::gaphor`].
263pub fn mermaid_to_gaphor(src: &str) -> Result<String, mermaid::MermaidError> {
264    Ok(sequence::gaphor::to_gaphor(&mermaid::parse_sequence(src)?))
265}
266
267/// Convert Mermaid flowchart source → draw.io (mxGraph XML).
268///
269/// Unlike the D2/DOT/Mermaid spokes (which emit the positionless IR), draw.io
270/// needs geometry, so this lays the graph out first: parse → `layout_flowchart`
271/// → the [`drawio`] encoder. The encoder itself is source-agnostic — any
272/// resolved [`model::Diagram`] can be encoded.
273pub fn mermaid_to_drawio(src: &str) -> Result<String, mermaid::MermaidError> {
274    let fc = mermaid::parse(src)?;
275    Ok(drawio::to_drawio(&layout::layout_flowchart(&fc)))
276}
277
278/// Render Mermaid flowchart source → SVG (parse → layout → the pure-Rust
279/// [`flowchart_svg`] renderer). The Rust core's own flowchart SVG (its own look,
280/// not byte-identical to the Python/JS renderers).
281pub fn mermaid_to_svg(src: &str) -> Result<String, mermaid::MermaidError> {
282    let mut fc = mermaid::parse(src)?;
283    render_flowchart_math(&mut fc);
284    Ok(flowchart_svg::render(&layout::layout_flowchart(&fc)))
285}
286
287/// Normalise a Mermaid label for rendering: `<br>` line breaks become spaces,
288/// then `$…$` TeX math is rendered to Unicode.
289fn clean_label(s: &str) -> String {
290    // Render `$…$` math FIRST (so TeX commands like \\text / \\nabla are mapped to
291    // Unicode), then collapse `<br>` and literal `\\n` / `\\t` breaks — otherwise
292    // the break-stripper would eat the `\\t` in `\\text`, the `\\n` in `\\nabla`, etc.
293    math::strip_br(&math::render(s))
294}
295
296/// Apply [`clean_label`] to every flowchart label (nodes, edges, subgraph titles).
297fn render_flowchart_math(fc: &mut flowchart::Flowchart) {
298    for n in &mut fc.nodes {
299        n.label = clean_label(&n.label);
300    }
301    for e in &mut fc.edges {
302        e.label = clean_label(&e.label);
303    }
304    for g in &mut fc.subgraphs {
305        g.title = clean_label(&g.title);
306    }
307}
308
309/// Render a Mermaid state diagram → SVG via the flowchart layout + renderer.
310pub fn mermaid_state_to_svg(src: &str) -> Result<String, mermaid::MermaidError> {
311    let mut fc = mermaid::parse_state(src)?;
312    render_flowchart_math(&mut fc);
313    Ok(flowchart_svg::render(&layout::layout_flowchart(&fc)))
314}
315
316/// Render D2 flowchart source → SVG, fully in Rust: parse D2 → flowchart IR →
317/// `layout_flowchart` → the [`flowchart_svg`] renderer. No external `d2` binary.
318pub fn d2_to_svg(src: &str) -> Result<String, d2::D2Error> {
319    let fc = d2::parse(src)?;
320    Ok(flowchart_svg::render(&layout::layout_flowchart(&fc)))
321}
322
323/// Import D2 flowchart source → the resolved `.kymo.json` model (D2 as a kymo
324/// source format — the inverse of `mermaid_to_d2`).
325pub fn d2_to_kymojson(src: &str) -> Result<String, d2::D2Error> {
326    let fc = d2::parse(src)?;
327    Ok(kymojson::export(&layout::layout_flowchart(&fc)))
328}
329
330/// Render Graphviz DOT flowchart source → SVG, fully in Rust: parse DOT →
331/// flowchart IR → `layout_flowchart` → the [`flowchart_svg`] renderer. No external
332/// `dot` binary.
333pub fn dot_to_svg(src: &str) -> Result<String, dot::DotError> {
334    let fc = dot::parse(src)?;
335    Ok(flowchart_svg::render(&layout::layout_flowchart(&fc)))
336}
337
338/// Import Graphviz DOT flowchart source → the resolved `.kymo.json` model.
339pub fn dot_to_kymojson(src: &str) -> Result<String, dot::DotError> {
340    let fc = dot::parse(src)?;
341    Ok(kymojson::export(&layout::layout_flowchart(&fc)))
342}
343
344/// Encode **any** resolved diagram (a `.kymo.json` model body or full envelope) to
345/// draw.io — the source-agnostic encoder surface used by the Python/JS `--drawio`
346/// flag. Needs the JSON reader, so it ships with the `bpmn` feature (which carries
347/// `serde_json`), like the other model-JSON entries.
348#[cfg(feature = "bpmn")]
349pub fn drawio_from_kymojson(json: &str) -> Result<String, String> {
350    drawio::to_drawio_kymojson(json)
351}
352
353#[cfg(test)]
354mod tests {
355    const SVG: &[u8] =
356        br##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#09f"/></svg>"##;
357
358    #[test]
359    fn png_has_magic() {
360        let png = super::svg_to_png(SVG, 1.0).expect("render png");
361        assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
362    }
363
364    #[cfg(feature = "pdf")]
365    #[test]
366    fn pdf_has_magic() {
367        let pdf = super::svg_to_pdf(SVG).expect("render pdf");
368        assert_eq!(&pdf[..5], b"%PDF-");
369    }
370
371    #[test]
372    fn autonumber_off_keeps_counting() {
373        // `off` hides but keeps advancing; bare `autonumber` resumes from the
374        // running count (5,10, hidden 15, then 20) — matching mermaid.
375        let svg = super::mermaid_to_sequence_svg(
376            "sequenceDiagram\nautonumber 5 5\nA->>B: a\nA->>B: b\nautonumber off\nA->>B: c\nautonumber\nA->>B: d",
377        )
378        .unwrap();
379        assert!(svg.contains(">5 a<") && svg.contains(">10 b<"));
380        assert!(svg.contains(">c<") && !svg.contains(">15 c<")); // hidden but counted
381        assert!(svg.contains(">20 d<")); // resumes at 20, not 1
382    }
383
384    #[test]
385    fn multiline_node_data_and_continuation() {
386        // Multi-line `@{ ... }` node-data block (YAML newline-separated fields).
387        let svg = super::mermaid_to_svg(
388            "flowchart TB\nA@{\n  shape: circle\n  label: \"Hi\"\n}\nA --> B",
389        )
390        .expect("node-data block");
391        assert!(svg.starts_with("<?xml") && svg.contains(">Hi<"));
392
393        // Line continuation: the edge on the second line attaches to `A`.
394        let svg = super::mermaid_to_svg("flowchart TB\nA[One]\n--> B[Two]").expect("continuation");
395        assert!(svg.contains(">One<") && svg.contains(">Two<"));
396
397        // A dangling trailing edge (`g-->`) is tolerated, not an error.
398        super::mermaid_to_svg("flowchart LR\na-->b\nb-->").expect("dangling edge");
399    }
400
401    #[test]
402    fn nested_subgraph_titles_render() {
403        // An outer subgraph that only contains another subgraph still shows its
404        // title (it used to be dropped for having no direct node members).
405        let svg = super::mermaid_to_svg(
406            "flowchart TD\nsubgraph Wrapper\n subgraph Inner\n  A --> B\n end\nend",
407        )
408        .unwrap();
409        assert!(svg.contains(">Wrapper<") && svg.contains(">Inner<"));
410    }
411
412    #[test]
413    fn self_loops_and_cycles_terminate() {
414        // Self-loops and predecessor cycles must not hang layout (they used to
415        // spin the trunk walk forever). Each of these must render and return.
416        for src in [
417            "flowchart TD\nA --> A",
418            "flowchart TD\na --> b\nb --> c\nc --> b\nb --> b",
419            "flowchart\nA --> A\nsubgraph B\nB1 --> B1\nend",
420        ] {
421            let svg = super::mermaid_to_svg(src).expect("render");
422            assert!(svg.starts_with("<?xml"), "{src:?}");
423        }
424    }
425
426    #[test]
427    fn mermaid_and_d2_to_svg() {
428        // mmd → SVG and the equivalent D2 → SVG both render the diamond + label.
429        let mmd = super::mermaid_to_svg("flowchart TD\nA[Go] --> B{ok?}").unwrap();
430        assert!(mmd.starts_with("<?xml") && mmd.contains("fc-shape") && mmd.contains(">ok?<"));
431        let d2src = "direction: down\nA: Go\nB: \"ok?\" { shape: diamond }\nA -> B";
432        let d2 = super::d2_to_svg(d2src).unwrap();
433        assert!(d2.contains("<polygon class=\"fc-shape\"") && d2.contains(">ok?<"));
434        // D2 import → kymo.json carries the diamond shape.
435        assert!(super::d2_to_kymojson(d2src)
436            .unwrap()
437            .contains("\"shape\": \"diamond\""));
438        // Graphviz DOT → SVG (same graph) renders the diamond too.
439        let dotsrc =
440            "digraph G {\n A [label=\"Go\"];\n B [label=\"ok?\", shape=diamond];\n A -> B;\n}";
441        let dot = super::dot_to_svg(dotsrc).unwrap();
442        assert!(dot.contains("<polygon class=\"fc-shape\"") && dot.contains(">ok?<"));
443    }
444}