0.0
The project is in a healthy, maintained state
The merman Mermaid renderer (Rust) compiled to wasm and converted to pure Ruby by dewasm. It renders Mermaid text to SVG or to terminal text without a browser, a native extension, or a wasm runtime.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

dewasm-merman

Mermaid diagrams rendered in pure Ruby.

The renderer is merman, a headless Rust implementation of Mermaid, compiled to wasm32-wasip1 and converted to Ruby source by dewasm. There is no browser, no native extension, and no wasm runtime involved: the gem is Ruby code that a stock ruby executes.

The gem is built from merman 0.8.0-alpha.5 on crates.io, pinned in wasm/Cargo.toml and surfaced as Dewasm::Merman::MERMAN_VERSION. Two cargo features are enabled: complete-svg and ascii.

Install

$ gem install dewasm-merman

Or in Gemfile:

gem "dewasm-merman"

Ruby 3.4 or newer is required, because the converted module stores WebAssembly linear memory in an IO::Buffer.

Usage

require "dewasm/merman"

svg = Dewasm::Merman.render_svg(<<~MERMAID)
  flowchart TD
    A[Start] --> B[Done]
MERMAID

File.write("flowchart.svg", svg)

Terminal text instead of SVG:

puts Dewasm::Merman.render_ascii(<<~MERMAID)
  flowchart LR
    A[Start] --> B[Done]
MERMAID
┌───────┐     ┌──────┐
│       │     │      │
│ Start ├────►│ Done │
│       │     │      │
└───────┘     └──────┘
puts Dewasm::Merman.render_ascii(<<~MERMAID, charset: :ascii)
  flowchart TD
    A[Start] --> B[Done]
MERMAID
+-------+
|       |
| Start |
|       |
+-------+
    |
    |
    |
    |
    v
+-------+
|       |
|  Done |
|       |
+-------+

Diagram types

The enabled features are merman's full SVG capability set: the Cytoscape and ELK layout engines and the RaTeX math backend are all compiled in, so no diagram type and no layout: or $$...$$ construct is turned off by the feature selection. render_ascii covers the subset merman renders as terminal text; asking it for another type raises Dewasm::Merman::Error.

Railroad grammar diagrams take one of four dialect headers: railroad-beta for merman's own grammar function syntax, railroad-ebnf-beta for EBNF, railroad-abnf-beta for ABNF, and railroad-peg-beta for PEG.

svg = Dewasm::Merman.render_svg(<<~MERMAID)
  railroad-beta
  expr = sequence(nonterminal("term"), zeroOrMore(terminal("+"))) ;
MERMAID

The result carries aria-roledescription="railroad" and the railroad-rule, railroad-nonterminal, and railroad-terminal classes that Mermaid's own railroad output uses.

Diagram metadata, mirroring Mermaid's mermaidAPI.parse() return shape:

metadata = Dewasm::Merman.parse_metadata(<<~MERMAID)
  ---
  title: My Chart
  ---
  pie title Pets
    "Dogs" : 3
MERMAID

metadata["diagram_type"]               # => "pie"
metadata["title"]                      # => "My Chart"
metadata["config"]                     # => {} (front-matter and directive overrides)
metadata["effective_config"]["theme"]  # => "default"

Mermaid site configuration is a Hash, serialized to JSON and applied as merman's site config:

svg = Dewasm::Merman.render_svg(<<~MERMAID, site_config: { "theme" => "dark" })
  flowchart TD
    A --> B
MERMAID

API

A single call renders the given diagram text to SVG or to terminal text. The table maps each function to the merman API it wraps.

Ruby merman
Dewasm::Merman.render_svg(text, **options) HeadlessRenderer::render_svg_sync
Dewasm::Merman.render_svg_readable(text, **options) HeadlessRenderer::render_svg_readable_sync
Dewasm::Merman.render_svg_resvg_safe(text, **options) HeadlessRenderer::render_resvg_compatible_svg_sync
Dewasm::Merman.render_ascii(text, **options) HeadlessAsciiRenderer::render_ascii_sync
Dewasm::Merman.parse_metadata(text, **options) HeadlessRenderer::parse_metadata_sync

Options on the three SVG functions (render_svg, render_svg_readable, and render_svg_resvg_safe):

Option Default merman
site_config: nil with_site_config, a Hash carried as JSON
diagram_id: nil with_diagram_id
deterministic_text_measurer: false with_deterministic_text_measurer
random: Random the source of the render's hash seed

parse_metadata takes site_config: and random:.

Options on render_ascii:

Option Default merman
charset: :unicode AsciiRenderOptions#charset, :unicode or :ascii
strict_parsing: nil nil keeps merman's own parse default; with_strict_parsing when true, with_lenient_parsing when false
fixed_today: nil RuntimePolicy#with_fixed_today, a Date
fixed_local_offset_minutes: nil RuntimePolicy#try_with_fixed_local_offset_minutes
site_config: nil with_site_config
random: Random the source of the render's hash seed

render_ascii also takes the remaining AsciiRenderOptions fields as keywords, each with merman's default:

  • default_direction: (:left_right or :top_down),
  • color_mode: (:plain, :ansi16, :ansi256, :true_color, :html),
  • color_theme: (:light or :dark),
  • box_border_padding:,
  • graph_padding_x:, graph_padding_y:,
  • sequence_participant_spacing:, sequence_message_spacing:, sequence_self_message_width:, sequence_mirror_actors:,
  • xychart_vertical_plot_height:, xychart_category_band_width:, xychart_horizontal_plot_width:,
  • max_grid_cells:,
  • relation_summary_diagnostics:.

An unknown keyword raises ArgumentError.

random: accepts anything that responds to bytes(n) and returns that many bytes. It supplies the hash seed the render starts from, which merman's hash maps derive their own seeds from. No output in this build depends on it: two renders of the same text with different seeds agree byte for byte.

Errors from merman are raised as Dewasm::Merman::Error carrying merman's message, for example Diagram parse error (flowchart-v2): Unexpected character at 15 or ASCII rendering does not support diagram type `pie`. Text whose diagram type merman cannot detect is one of those errors (No diagram type detected matching given configuration for text: ...), not nil. The functions return nil only where merman itself returns "no diagram" without an error, which in this build's feature set does not occur for text input.

How it is built

wasm/ is a small Rust crate that wraps merman behind a flat wasm ABI: merman_alloc, merman_result_ptr, merman_result_len, and one entry point per function taking a text pointer and an options JSON pointer and returning a status. It is compiled to wasm32-wasip1, post-processed with wasm-opt -Oz, and converted to Ruby by dewasm at the revision recorded in DEWASM_REVISION, in library mode with --no-default-wasi. That flag leaves the WASI imports to the embedder, so the generated file carries no WASI implementation at all.

Neither build product is committed: lib/dewasm/merman/wasm_module.rb and lib/dewasm/merman/snapshot.bin.gz are produced by the build and shipped in the gem. The initialization harness in tools/snapshot_util.rb, which the tests share with the build, supplies the few imports merman's initialization asks for: a recorded seed, the clocks, an empty environment, and standard error for a panic message. It stays out of the gem, which supplies stubs that raise instead.

wasm/Cargo.lock is committed because merman's sibling crates publish alpha versions that move independently, and a mixed set does not compile.

Snapshot

merman builds its font metrics table and its theme configuration on the first render of an instance, and that work costs far more than the render itself. The build does one render and ships the state it leaves behind as snapshot.bin.gz: the module's linear memory and its one mutable global, which is all the wasm module has, so a restored instance renders exactly what a freshly initialized one renders. Every function is a one-shot module function: it creates an instance, restores that snapshot into it, injects a fresh hash seed, renders, and returns. Nothing is retained between calls, so no linear memory is held after a call returns and there is no mutable state to synchronize; the snapshot itself is read once per process and only ever copied from. A missing snapshot file, or one of another version, is an error asking for rake generate: there is no second path that would render the same output more slowly.

A restored instance must not keep the hash seed merman drew while initializing, so every render writes bytes from its random: source over it and hashes with a seed of its own while the tables built around the old one keep working. The build finds that offset by searching for the exact seed bytes it handed out and insisting on exactly one match, so a toolchain that moves the seed elsewhere stops the build instead of shipping instances that share one seed.

The imports the wasm module declares are all resolved to stubs that raise Dewasm::Merman::Error, and none of them fires while rendering: a render reads no clock, no environment, no file, and no operating system randomness, so it reaches nothing outside the artifact.

Size, memory, and speed

Measured on macOS 26.5.2, Apple M1 Pro, Ruby 4.0.4, rendering a two-node flowchart.

Quantity Value
wasm/merman.wasm after wasm-opt -Oz 11.8 MB
Generated wasm_module.rb 48.6 MB
Shipped snapshot.bin.gz 2.7 MB
Packaged .gem 9.5 MB
require "dewasm/merman" 3.7 s
Resident memory after require 1125.0 MB
One module instantiation 29 ms
render_svg, flowchart 56 ms
render_svg, sequence diagram 69 ms
render_svg, railroad diagram 58 ms
render_ascii, flowchart 41 ms
parse_metadata 85 ms

Important

Loading this gem costs over a gigabyte of resident memory and several seconds, paid once per process. A small container will not hold it.

The rows fall into three groups. The first ones are what ships: the wasm module, the Ruby source dewasm generates from it, the state snapshot, and the packaged gem. The next two are that load cost, in time and in resident memory. The rest are per-call costs, one module instantiation and one call of each function.

Three facts hold whatever the magnitudes are. Resident memory after require is dominated by the instruction sequences of the loaded code, not by rendering, so it does not grow with the number of calls. Instantiation is a small part of a one-shot render, so the API that instantiates per call costs little over one that reuses an instance, and it keeps no wasm memory alive between calls. Turning merman features off buys size but not speed: a build of the same release with only the svg feature produces a smaller wasm module and the same time per render, and it pays for that size with the diagram types it drops.

The numbers move with the pinned merman version and with the dewasm revision used to generate the module, so rerun rake measure after changing either.

If those sizes or that resident memory rule this gem out, dewasm-pozeiden is a much smaller Mermaid renderer, but covers fewer diagram types.

Tasks

The Rakefile drives everything, and each task depends on the ones before it, so running a later task runs what it needs first. From a clean checkout rake test is enough; the individual tasks are useful when only one step is in question.

rake wasm:build

$ rake wasm:build

Compiles wasm/ and post-processes it into wasm/merman.wasm. It needs the wasm32-wasip1 Rust target (rustup target add wasm32-wasip1) and wasm-opt from Binaryen.

rake generate

$ rake generate

Converts that module into the Ruby the gem ships, and captures the state snapshot that ships with it. It needs a dewasm binary, its path in DEWASM_BIN.

rake test

$ rake test

Runs test/ against the generated module and the captured snapshot. It needs rake generate.

rake measure

$ rake measure

Refreshes the measurements table in README.md with numbers from this machine. It needs rake generate, and a built gem in the checkout for the .gem row.

rake build

$ rake build

Packages the gem. It needs rake generate.

rake clean

$ rake clean

Removes the build products and wasm/target. It needs nothing.

License

This repository's own code is MIT: see LICENSE.

merman is dual licensed under MIT or Apache-2.0, and this gem takes it under MIT: see LICENSE-MERMAN. The gem also ships merman's THIRD_PARTY_NOTICES-MERMAN.md, upstream's inventory of the projects merman derives from (Mermaid itself among them). That inventory covers every upstream artifact; this gem contains only the complete-svg and ascii feature closure.

merman is not affiliated with or endorsed by Mermaid.