use merman::render::{
DeterministicTextMeasurer, LayoutOptions, SvgRenderOptions, TextMeasurer,
VendoredFontMetricsTextMeasurer,
};
use merman::{Engine, MermaidConfig, ParseOptions};
use serde::Serialize;
use serde_json::Value;
use std::io::Read;
use std::str::FromStr;
use std::sync::Arc;
#[derive(Debug)]
enum CliError {
Usage(&'static str),
Io(std::io::Error),
Mermaid(merman::Error),
Headless(merman::render::HeadlessError),
Raster(merman::render::raster::RasterError),
Json(serde_json::Error),
NoDiagram,
}
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CliError::Usage(msg) => write!(f, "{msg}"),
CliError::Io(err) => write!(f, "I/O error: {err}"),
CliError::Mermaid(err) => write!(f, "{err}"),
CliError::Headless(err) => write!(f, "{err}"),
CliError::Raster(err) => write!(f, "{err}"),
CliError::Json(err) => write!(f, "JSON error: {err}"),
CliError::NoDiagram => write!(f, "No Mermaid diagram detected"),
}
}
}
impl From<std::io::Error> for CliError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<merman::Error> for CliError {
fn from(value: merman::Error) -> Self {
Self::Mermaid(value)
}
}
impl From<merman::render::HeadlessError> for CliError {
fn from(value: merman::render::HeadlessError) -> Self {
Self::Headless(value)
}
}
impl From<merman::render::raster::RasterError> for CliError {
fn from(value: merman::render::raster::RasterError) -> Self {
Self::Raster(value)
}
}
impl From<serde_json::Error> for CliError {
fn from(value: serde_json::Error) -> Self {
Self::Json(value)
}
}
#[derive(Debug, Clone, Copy, Default)]
enum Command {
#[default]
Parse,
Detect,
Layout,
Render,
}
#[derive(Debug, Clone, Copy, Default)]
enum TextMeasurerKind {
Deterministic,
#[default]
Vendored,
}
#[derive(Debug, Clone, Copy, Default)]
enum RenderFormat {
#[default]
Svg,
Png,
Jpeg,
Pdf,
}
impl FromStr for RenderFormat {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"svg" => Ok(Self::Svg),
"png" => Ok(Self::Png),
"jpg" | "jpeg" => Ok(Self::Jpeg),
"pdf" => Ok(Self::Pdf),
_ => Err(()),
}
}
}
impl RenderFormat {
fn raster_extension(self) -> Option<&'static str> {
match self {
RenderFormat::Svg => None,
RenderFormat::Png => Some("png"),
RenderFormat::Jpeg => Some("jpg"),
RenderFormat::Pdf => Some("pdf"),
}
}
fn is_raster(self) -> bool {
!matches!(self, RenderFormat::Svg)
}
}
#[derive(Debug, Default)]
struct Args {
command: Command,
input: Option<String>,
pretty: bool,
with_meta: bool,
suppress_errors: bool,
hand_drawn_seed: Option<u64>,
text_measurer: TextMeasurerKind,
render_format: RenderFormat,
render_scale: f32,
background: Option<String>,
viewport_width: f64,
viewport_height: f64,
diagram_id: Option<String>,
out: Option<String>,
}
#[derive(Serialize)]
struct MetaOut<'a> {
diagram_type: &'a str,
config: &'a Value,
effective_config: &'a Value,
title: Option<&'a str>,
}
#[derive(Serialize)]
struct ParseOut<'a> {
meta: MetaOut<'a>,
model: &'a Value,
}
struct RenderRequest<'a> {
args: &'a Args,
engine: &'a Engine,
parse_options: ParseOptions,
}
struct RasterRequest<'a> {
args: &'a Args,
svg: &'a str,
}
fn usage() -> &'static str {
"merman-cli\n\
\n\
USAGE:\n\
merman-cli [parse] [--pretty] [--meta] [--suppress-errors] [<path>|-]\n\
merman-cli detect [<path>|-]\n\
merman-cli layout [--pretty] [--text-measurer deterministic|vendored] [--viewport-width <w>] [--viewport-height <h>] [--suppress-errors] [<path>|-]\n\
merman-cli render [--format svg|png|jpg|pdf] [--scale <n>] [--background <css-color>] [--text-measurer deterministic|vendored] [--viewport-width <w>] [--viewport-height <h>] [--id <diagram-id>] [--out <path>] [--hand-drawn-seed <n>] [--suppress-errors] [<path>|-]\n\
\n\
NOTES:\n\
- If <path> is omitted or '-', input is read from stdin.\n\
- parse prints the semantic JSON model by default; --meta wraps it with parse metadata.\n\
- render prints SVG to stdout by default; use --out to write a file.\n\
- render can also rasterize SVG input when --format is png/jpg/pdf (input starts with '<svg').\n\
- PNG output defaults to writing next to the input file (or ./out.png for stdin).\n\
- JPG output defaults to writing next to the input file (or ./out.jpg for stdin).\n\
- PDF output defaults to writing next to the input file (or ./out.pdf for stdin).\n\
"
}
fn parse_args(argv: &[String]) -> Result<Args, CliError> {
let mut args = Args {
command: Command::Parse,
render_format: RenderFormat::Svg,
render_scale: 1.0,
viewport_width: 800.0,
viewport_height: 600.0,
..Default::default()
};
let mut it = argv.iter().skip(1).peekable();
while let Some(a) = it.next() {
match a.as_str() {
"--help" | "-h" => return Err(CliError::Usage(usage())),
"parse" => args.command = Command::Parse,
"detect" => args.command = Command::Detect,
"layout" => args.command = Command::Layout,
"render" => args.command = Command::Render,
"--pretty" => args.pretty = true,
"--meta" => args.with_meta = true,
"--suppress-errors" => args.suppress_errors = true,
"--text-measurer" => {
let Some(kind) = it.next() else {
return Err(CliError::Usage(usage()));
};
args.text_measurer = match kind.as_str() {
"deterministic" => TextMeasurerKind::Deterministic,
"vendored" => TextMeasurerKind::Vendored,
_ => return Err(CliError::Usage(usage())),
};
}
"--format" => {
let Some(fmt) = it.next() else {
return Err(CliError::Usage(usage()));
};
args.render_format = fmt
.parse::<RenderFormat>()
.map_err(|_| CliError::Usage(usage()))?;
}
"--scale" => {
let Some(scale) = it.next() else {
return Err(CliError::Usage(usage()));
};
args.render_scale = scale.parse::<f32>().map_err(|_| CliError::Usage(usage()))?;
if !(args.render_scale.is_finite() && args.render_scale > 0.0) {
return Err(CliError::Usage(usage()));
}
}
"--background" => {
let Some(bg) = it.next() else {
return Err(CliError::Usage(usage()));
};
if !bg.trim().is_empty() {
args.background = Some(bg.trim().to_string());
}
}
"--viewport-width" => {
let Some(w) = it.next() else {
return Err(CliError::Usage(usage()));
};
args.viewport_width = w.parse::<f64>().map_err(|_| CliError::Usage(usage()))?;
}
"--viewport-height" => {
let Some(h) = it.next() else {
return Err(CliError::Usage(usage()));
};
args.viewport_height = h.parse::<f64>().map_err(|_| CliError::Usage(usage()))?;
}
"--id" => {
let Some(id) = it.next() else {
return Err(CliError::Usage(usage()));
};
args.diagram_id = Some(id.trim().to_string());
}
"--out" => {
let Some(out) = it.next() else {
return Err(CliError::Usage(usage()));
};
args.out = Some(out.trim().to_string());
}
"--hand-drawn-seed" => {
let Some(seed) = it.next() else {
return Err(CliError::Usage(usage()));
};
args.hand_drawn_seed =
Some(seed.parse::<u64>().map_err(|_| CliError::Usage(usage()))?);
}
s if s.starts_with('-') => return Err(CliError::Usage(usage())),
_ => {
args.input = Some(a.to_string());
}
}
}
Ok(args)
}
fn read_input(path: Option<&str>) -> Result<String, CliError> {
let mut buf = String::new();
match path {
None | Some("-") => {
std::io::stdin().read_to_string(&mut buf)?;
}
Some(p) => {
std::fs::File::open(p)?.read_to_string(&mut buf)?;
}
}
Ok(buf)
}
fn write_output(out: Option<&str>, bytes: &[u8]) -> Result<(), CliError> {
match out {
None => {
use std::io::Write as _;
std::io::stdout().write_all(bytes)?;
}
Some(p) => {
std::fs::write(p, bytes)?;
}
}
Ok(())
}
fn default_raster_out_path(input: Option<&str>, ext: &str) -> std::path::PathBuf {
match input {
Some(path) if path != "-" => {
let p = std::path::PathBuf::from(path);
p.with_extension(ext)
}
_ => std::path::PathBuf::from(format!("out.{ext}")),
}
}
fn text_measurer(kind: TextMeasurerKind) -> Arc<dyn TextMeasurer + Send + Sync> {
match kind {
TextMeasurerKind::Deterministic => Arc::new(DeterministicTextMeasurer::default()),
TextMeasurerKind::Vendored => Arc::new(VendoredFontMetricsTextMeasurer::default()),
}
}
fn main() {
let argv: Vec<String> = std::env::args().collect();
let result = (|| -> Result<(), CliError> {
let args = parse_args(&argv)?;
run(args)
})();
match result {
Ok(()) => {}
Err(CliError::Usage(msg)) => {
eprintln!("{msg}");
std::process::exit(2);
}
Err(err) => {
eprintln!("{err}");
std::process::exit(1);
}
}
}
fn run(args: Args) -> Result<(), CliError> {
let text = read_input(args.input.as_deref())?;
let mut engine = Engine::new();
if let Some(seed) = args.hand_drawn_seed {
let mut cfg = MermaidConfig::empty_object();
cfg.set_value("handDrawnSeed", serde_json::json!(seed));
engine = engine.with_site_config(cfg);
}
let request = RenderRequest {
args: &args,
engine: &engine,
parse_options: ParseOptions {
suppress_errors: args.suppress_errors,
},
};
match args.command {
Command::Detect => request.detect(&text),
Command::Parse => request.parse(&text),
Command::Layout => request.layout(&text),
Command::Render => request.render(&text),
}
}
impl<'a> RenderRequest<'a> {
fn layout_options(&self) -> LayoutOptions {
LayoutOptions {
viewport_width: self.args.viewport_width,
viewport_height: self.args.viewport_height,
text_measurer: text_measurer(self.args.text_measurer),
math_renderer: None,
use_manatee_layout: true,
}
}
fn svg_options(&self) -> SvgRenderOptions {
SvgRenderOptions {
diagram_id: self.args.diagram_id.clone(),
..Default::default()
}
}
fn detect(&self, text: &str) -> Result<(), CliError> {
let Some(meta) = self.engine.parse_metadata_sync(text, self.parse_options)? else {
return Err(CliError::NoDiagram);
};
println!("{}", meta.diagram_type);
Ok(())
}
fn parse(&self, text: &str) -> Result<(), CliError> {
let Some(parsed) = self.engine.parse_diagram_sync(text, self.parse_options)? else {
return Err(CliError::NoDiagram);
};
if self.args.with_meta {
let out = ParseOut {
meta: MetaOut {
diagram_type: &parsed.meta.diagram_type,
config: parsed.meta.config.as_value(),
effective_config: parsed.meta.effective_config.as_value(),
title: parsed.meta.title.as_deref(),
},
model: &parsed.model,
};
if self.args.pretty {
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
println!("{}", serde_json::to_string(&out)?);
}
} else if self.args.pretty {
println!("{}", serde_json::to_string_pretty(&parsed.model)?);
} else {
println!("{}", serde_json::to_string(&parsed.model)?);
}
Ok(())
}
fn layout(&self, text: &str) -> Result<(), CliError> {
let layout = self.layout_options();
let Some(layouted) =
merman::render::layout_diagram_sync(self.engine, text, self.parse_options, &layout)?
else {
return Err(CliError::NoDiagram);
};
if self.args.pretty {
println!("{}", serde_json::to_string_pretty(&layouted)?);
} else {
println!("{}", serde_json::to_string(&layouted)?);
}
Ok(())
}
fn render(&self, text: &str) -> Result<(), CliError> {
if text.trim_start().starts_with("<svg") && self.args.render_format.is_raster() {
return RasterRequest {
args: self.args,
svg: text,
}
.write();
}
let layout = self.layout_options();
let svg_opts = self.svg_options();
let Some(svg) = merman::render::render_svg_sync(
self.engine,
text,
self.parse_options,
&layout,
&svg_opts,
)?
else {
return Err(CliError::NoDiagram);
};
match self.args.render_format {
RenderFormat::Svg => {
let out = self.args.out.as_deref();
write_output(out, svg.as_bytes())
}
RenderFormat::Png | RenderFormat::Jpeg | RenderFormat::Pdf => RasterRequest {
args: self.args,
svg: &svg,
}
.write(),
}
}
}
impl<'a> RasterRequest<'a> {
fn raster_options(&self) -> merman::render::raster::RasterOptions {
merman::render::raster::RasterOptions {
scale: self.args.render_scale,
background: self.args.background.clone(),
..Default::default()
}
}
fn rasterize(&self) -> Result<Vec<u8>, CliError> {
let svg = merman::render::svg_resvg_safe(self.svg)?;
match self.args.render_format {
RenderFormat::Svg => Err(CliError::Usage(usage())),
RenderFormat::Png => Ok(merman::render::raster::svg_to_png(
&svg,
&self.raster_options(),
)?),
RenderFormat::Jpeg => Ok(merman::render::raster::svg_to_jpeg(
&svg,
&self.raster_options(),
)?),
RenderFormat::Pdf => Ok(merman::render::raster::svg_to_pdf(&svg)?),
}
}
fn output_path(&self) -> Result<String, CliError> {
let ext = self
.args
.render_format
.raster_extension()
.ok_or(CliError::Usage(usage()))?;
Ok(self.args.out.clone().unwrap_or_else(|| {
default_raster_out_path(self.args.input.as_deref(), ext)
.to_string_lossy()
.into_owned()
}))
}
fn write(&self) -> Result<(), CliError> {
let bytes = self.rasterize()?;
let out = self.output_path()?;
write_output(Some(out.as_str()), &bytes)
}
}