# mdcast
Markdown → **DOCX · ODT · PDF · PDF-presentation · PPTX · reveal.js HTML**, in
one async Rust library and a thin CLI on top of it.
The pitch:
- **One markdown source, six outputs.** Write once, render to whatever the
audience reads.
- **Per-page layout system.** Tag a page `hero`, `image-full`, `callout`,
`thanks` — and have it honoured across every output format.
- **Pluggable everything.** Templates, images, reveal.js distribution — all
fetched through one async `AssetProvider` trait. Your app feeds bytes from
a DB, S3, an in-memory map, whatever.
- **Single self-contained HTML.** Reveal.js dist is bundled; with
`--embed-resources` (default) the deck is one file with zero external URLs.
mdcast does **not** try to replace pandoc. Pandoc handles DOCX/PPTX/revealjs
because no Rust crate matches its OOXML fidelity. Typst handles PDF because
the LaTeX toolchain is slow and heavy. The value mdcast adds is the
**branding-and-layout layer** that sits on top of both.
## Quick start
Install the engines you want to use:
```sh
# both optional, depending on which targets you'll render
yay -S pandoc typst # arch
brew install pandoc typst # macos
apt install pandoc # debian/ubuntu (typst: cargo install typst-cli)
```
Build and render:
```sh
cargo build --release
./target/release/mdcast render slides.md \
--target html-reveal \
--out slides.html \
--assets ./my-images/
```
You'll get a single self-contained `slides.html` you can open in any browser.
## A minimal markdown example
```markdown
<page class="hero">
# Q3 Operations Review
*F13 — for board discussion*
</page>
---
# Agenda
- Headlines
- Margins
- Open questions
---
# {.image-full}

---
> A simple plan, decisively executed, beats a perfect plan that ships late.
---
Closing remarks and next steps.
```
What you get with no extra config:
| 1 | `hero` | Explicit `<page class="hero">` wrapper |
| 2 | `content` | Default (no rule matched) |
| 3 | `image-full` | Page body is just one image → shape rule |
| 4 | `callout` | Body is just a blockquote → shape rule |
| 5 | `thanks` | Last page, no explicit class → positional rule |
Run `mdcast explain slides.md` to print this table for any file.
## Page boundaries and classes
Two surface syntaxes, both accepted:
- HTML-style: `<page class="hero">…</page>`
- Pandoc fenced div: `::: {.hero}` … `:::`
Outside an explicit wrapper, **`---` thematic breaks split pages.** The
auto-classifier then fills in a class:
1. **Explicit class** (from a wrapper) — always wins.
2. **Content shape** — `single_h1_only` → `section-divider`,
`single_image_only` → `image-full`, `single_blockquote_only` → `callout`.
3. **Positional** — first page → `hero`, last page → `thanks`.
4. **Default** — `content`.
All rules live in `brand.toml`:
```toml
[auto_layout]
first = "hero"
last = "thanks"
default = "content"
[[auto_layout.rules]]
when = "single_h1_only"
class = "section-divider"
[[auto_layout.rules]]
when = "single_image_only"
class = "image-full"
```
## Built-in classes
| `hero` | Title / cover |
| `content` | Body pages — paragraphs, lists, the usual |
| `thanks` | Closing |
| `image-full` | Full-bleed image |
| `section-divider` | Single-heading section break |
| `callout` | Pull-quote / emphasised single block |
A class name resolves to a *different template per target*. The same
`<page class="hero">` produces:
- a centred large-type cover **in PDF** (via `typst/layouts/pdf/hero.typ`)
- a dark-background title slide **in PDF-presentation** (via
`typst/layouts/pdf-presentation/hero.typ`)
- a `<section class="hero">` **in reveal.js** (styled by the theme CSS)
- a `Hero` paragraph-style **in DOCX/ODT** (from the reference doc)
Missing template for some class? The renderer logs a warning and falls back
to `content`. Authors are never blocked.
## Library usage
```rust
use std::path::Path;
use std::sync::Arc;
use mdcast::backends::Registry;
use mdcast::pages::{auto::classify, splitter::DefaultSplitter};
use mdcast::{
AssetRef, BrandHandle, BrandSpec, DocMeta, EmbeddedAssets, LayeredAssets,
PageSplitter, RenderRequest, ResolvedDoc, Target, async_provider,
};
use bytes::Bytes;
use anyhow::Result;
#[tokio::main]
async fn main() -> Result<()> {
// 1. Parse → pages → classify.
let md = tokio::fs::read_to_string("slides.md").await?;
let raw = DefaultSplitter.split(&md);
let brand = BrandSpec::default();
let pages = classify(raw, &brand.auto_layout);
let doc = ResolvedDoc {
pages,
meta: DocMeta { title: Some("Q3 Review".into()), ..Default::default() },
brand: BrandHandle(Arc::new(brand)),
assets: Vec::<AssetRef>::new(),
};
// 2. Compose an asset provider — fetch images from your app, fall back to
// the built-in templates and reveal.js dist.
let app_provider = async_provider(|key: String| async move {
// your code: hit a DB, S3, an in-memory cache, an image renderer …
if let Some(bytes) = your_app_lookup(&key).await? {
Ok::<_, anyhow::Error>(Some(Bytes::from(bytes)))
} else {
Ok(None)
}
});
let provider = LayeredAssets { over: app_provider, base: EmbeddedAssets };
// 3. Render.
let registry = Registry::with_defaults();
let req = RenderRequest {
doc: &doc,
assets: &provider,
out: Path::new("slides.html"),
};
let artifact = registry.render(Target::HtmlReveal, &req).await?;
println!("wrote {}", artifact.primary.display());
Ok(())
}
# async fn your_app_lookup(_: &str) -> Result<Option<Vec<u8>>> { Ok(None) }
```
Anything the provider returns `None` for falls through to the next layer.
`EmbeddedAssets` is always at the bottom and ships:
- Built-in Typst layouts (`hero`, `content`, `thanks`, `image-full`,
`section-divider`, `callout`) for `pdf` and `pdf-presentation`.
- Minimal reveal.js 4.6.1 distribution (with stripped font imports — falls
back to system sans-serif).
- Pandoc reference-doc placeholders (a real branded set is still TBD).
## Cargo features
```toml
[features]
default = ["pandoc", "typst"]
pandoc = [] # DOCX, ODT, PPTX, html-reveal
typst = [] # PDF, PDF-presentation
```
Build with only what you need:
```sh
cargo build --no-default-features --features pandoc # no typst dep tree
cargo build --no-default-features --features typst # no pandoc backend
```
## Targets
| `docx` | pandoc | Class = paragraph-style name in `reference.docx` |
| `odt` | pandoc | Class = paragraph-style name in `reference.odt` |
| `pptx` | pandoc | Class = slide-layout name in `reference.pptx` |
| `html-reveal` | pandoc | Single self-contained file; reveal.js dist bundled & inlined |
| `pdf` | typst | Per-class typst template under `typst/layouts/pdf/` |
| `pdf-presentation` | typst | Per-class typst template under `typst/layouts/pdf-presentation/` |
## CLI
```
mdcast render INPUT.md --target <T> --out OUTPUT [--assets DIR] [--brand brand.toml]
mdcast explain INPUT.md [--brand brand.toml]
```
Targets: `docx`, `odt`, `pdf`, `pdf-presentation`, `pptx`, `html-reveal`.
## What's deferred
These are not bugs — they're chosen scope cuts. Each lands as an additive
change at a seam that already exists (see `PROJECT_PLAN.md` §10).
- Real branded `reference.{docx,odt,pptx}` assets (`.keep` placeholders for
now; pandoc default styling applies).
- Markdown → Typst body rendering (current templates use
`#raw(body, lang: "markdown")` — bodies render as source until the md→typst
step lands).
- Mermaid → SVG pre-processing (a Rust renderer the team already owns will
plug in as a pre-step).
- Brand projection (one `brand.toml` colour change → propagated to all
outputs).
- Caching (content-hashed diagram + output cache).
## License
MIT OR Apache-2.0