Project

przn

0.0
The project is in a healthy, maintained state
A terminal-based presentation tool that renders Markdown slides with Kitty text sizing protocol support for beautifully scaled headers
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 0
 Project Readme

przn

A terminal-based presentation tool written in Ruby. Renders Markdown slides with Kitty text sizing protocol support for beautifully scaled headings.

For the full feature set, it is strongly recommended to run przn inside Echoes. Features that depend on Echoes' OSC 7772 extensions, proportional-font measurement, or vector-capture pipeline are marked (Echoes only) below; everything else works on any Kitty-graphics-compatible terminal (silently degrading where the protocol isn't available).

Installation

% gem install przn

Usage

% przn your_slides.md

To open the presentation directly at a specific slide, append @N (1-based):

% przn your_slides.md @42

Out-of-range numbers are clamped to the last slide, so @9999 jumps to the end.

The repository ships with sample/sample.md — a 30-slide demo deck that exercises every feature documented below (slide splitting, lists, code blocks, tables, sized text, colours, fonts, alignment, backgrounds, absolute-position text, images, shapes, layouts, step builds, actions, animation, comments, notes). Clone the repo and run przn sample/sample.md to page through it; each section below has a slide you can jump to with @N for hands-on reference.

Extended-display presenter mode (Echoes only)

% przn --present your_slides.md

On a setup with a secondary display (projector / external monitor) and running inside Echoes, --present auto-spawns an audience window on the second display showing the clean current slide, while the laptop pane becomes the presenter view:

  • Current slide rendered as normal
  • Speaker notes ({::note} / <note> markup) shown in a side strip — stripped from the audience view
  • Next slide's title hint
  • Elapsed-time clock (or, when counter.duration is set in the theme, the runner-bar visualization)

If only one display is attached or Echoes isn't the host terminal, --present falls back to today's mirror mode with a one-line warning on stderr.

Implementation: the two przn processes coordinate over a Unix socket. The presenter forwards every slide navigation as a goto message; the audience renders and otherwise stays silent. Notes are not transmitted to the audience side.

PDF export

% przn --export your_slides.md
% przn --export -o output.pdf your_slides.md

--export pdf (default) (Echoes only) drives the live renderer for each slide and asks the terminal to save the rendered pane as a one-page vector PDF, then concatenates the per-slide PDFs into a single multi-page PDF. Output is an exact match of what's on screen — gradients, proportional fonts, OSC 66 sized text, custom bullets, all show up exactly as you'd see them — but vector, so text remains selectable and scales infinitely. Requires running inside a terminal that implements the OSC 7772 capture command to a .pdf path (currently Echoes). The slides flicker through the visible pane during export.

Echoes embeds <img> content at its source resolution (a 24-megapixel phone photo lands in the PDF as 24 megapixels), so image-heavy decks balloon. If Ghostscript is on $PATH przn pipes the merged file through gs -dPDFSETTINGS=… to downsample-and-recompress those rasters as JPEG — typically 5-20× smaller on photo-bearing decks. Tune with --pdf-quality:

Value gs preset Use for
lossless (skip gs) Image fidelity matters more than file size; keeps Echoes' originals byte-for-byte
low /screen (~72 DPI) Email-friendly, presentations viewed on phones
medium (default) /ebook (~150 DPI) General-purpose; visually indistinguishable at projection distances
high /printer (~300 DPI) Print handouts
max /prepress Maximum fidelity, color preservation

Without gs on $PATH the file is left at its lossless-only size and a one-line install hint is printed on stderr (brew install ghostscript on macOS). Set PRZN_GS=/custom/path/gs to point at a non-default install, or PRZN_GS= (empty) / --pdf-quality lossless to opt out explicitly.

--export prawn is the headless fallback: it renders the deck directly into a vector PDF via Prawn, without touching the terminal. Useful for CI or environments where Echoes isn't available, but diverges from the on-screen rendering for any feature the live renderer adds (OSC 66 sized text, OSC 7772 backgrounds, proportional fonts). Requires a TrueType font (with glyf outlines) for proper rendering — Prawn does not support CFF-based fonts (most .otf files). Fonts are auto-detected in this order: NotoSansJP TTF, HackGen, Arial Unicode.

Key bindings

Key Action
l j Space Next slide
h k Previous slide
g First slide
G Last slide
r Reload the deck (and theme.yml) from disk, keeping the current slide index
q Ctrl-C Quit

Hot reload

The deck file and the sibling theme.yml are watched in the background; saving either re-renders automatically. A source save lands you on the slide you just edited (the first chunk whose markdown differs from the prior snapshot); a theme save re-renders in place. Bad syntax mid-edit is swallowed silently — the previous slide stays on screen until the next save parses cleanly. Pass --no-watch to disable; the manual r binding still works in either mode.

Markdown format

przn's Markdown format is based on Rabbit's Markdown mode with some extensions with HTML-ish tags.

HTML-ish tag attributes — every <tag attr=value> block below (<bg>, <at>, <img>, <font>) accepts three value forms: double-quoted attr="value", single-quoted attr='value', and unquoted attr=value (HTML5-ish — anything that isn't whitespace, =, <, >, a quote, or backtick). Self-closing tags need a space before /> when the last attribute is unquoted (<img src=foo.png />).

Slide splitting

Slides are separated by # (h1) headings.

# Slide 1

content

# Slide 2

more content

Text formatting

*emphasis*
**bold**
~~strikethrough~~
`inline code`

Long lines wrap at whitespace boundaries (not mid-word) for English-style text. A single word that's longer than the line — a URL, a class name — still wraps at the character it has to. CJK runs without inter-character whitespace fall back to per-character splitting.

Lists

* item 1
* item 2
  * nested item

- also works as bullets

1. ordered
2. list

Code blocks

Fenced code blocks render on a dim gray background and are syntax-highlighted via rouge when a language is set on the fence — Ruby, Python, JavaScript, Go, Rust, HTML, JSON, YAML, shell, and ~200 others all work:

```ruby
def hello
  puts "world"  # greet
end
```

Indented code blocks (4 spaces) with an optional kramdown IAL pick up highlighting too:

    def hello
      puts "world"
    end
{: lang="ruby"}

Blocks without a language fall back to the same gray-on-dim plain text. The color scheme is fixed (tuned for the dim background) — comments dim, strings green, keywords cyan, numbers magenta, function / type names yellow / green.

A kramdown-style IAL right after the language overrides the theme's code.* defaults for that one block. Most useful for shrinking a long snippet down to a smaller size without touching the deck-wide default:

```ruby {size=1}
class Slide
  attr_reader :title, :body, :notes
  def initialize(title:, body: "", notes: [])
    #
  end
end
```

size accepts the same forms as <size=…> (numeric 17 or named xx-smallxxxx-large). family, color, and bg are also recognized and override theme.code.family / theme.code.color / theme.code.bg for that one block. Multi-attr forms work with either separator: {size: small, family: Menlo} and {size=2, color=cyan} both parse.

Per-line stepping via lines=… (Echoes only for the dim effect) — pipe-separated step groups walk through which line range is highlighted as the user advances:

```ruby {lines=1-2|3|4}
class Slide
  attr_reader :title, :body
  def initialize(title:, body: "")
    @title, @body = title, body
  end
end
```

On the slide's initial reveal, lines 1-2 are at full opacity and the rest are dimmed (via OSC 7772 cell-alpha); space steps the highlight to line 3, then line 4. Each pipe-separated extra group adds a synthetic step to the slide (the deck's normal step counter walks them with the existing <wait/> infrastructure). Comma-separated ranges inside a group need quoting because , is the multi-attr separator at the IAL level: lines="1-2,4|3,5". lines=all or omitting the directive turns dimming off. Non-Echoes terminals drop the OSC silently — the steps still tick but every line renders at full opacity.

Mermaid diagrams

A mermaid fence renders to an inline image via the mermaid CLI:

```mermaid
graph TD
  A[Idea] --> B{Demo?}
  B -->|yes| C[Run live]
  B -->|no| D[Static slide]
```

The rendered PNG is cached for the session in a tmpdir keyed by SHA256 of the source, so the same diagram repeated across slides (or after a reload) shells out exactly once. Per-block IAL sizes the placement — height / width / relative_height / relative_width work the same as on <img>:

```mermaid {height=70%}

```

Requirements:

  • mmdc on PATH — install via npm i -g @mermaid-js/mermaid-cli.
  • Puppeteer's headless Chrome — mmdc shells out to it. If you see a "Could not find Chrome" error, run npx puppeteer browsers install chrome-headless-shell once.

The diagram is rendered with -b transparent so the slide background (theme bg / gradient / <bg>) shows through. If mmdc is missing or fails, the source falls back to a plain fenced block so the slide still shows the diagram text.

Block quotes

> quoted text
> continues here

Tables

| Header 1 | Header 2 |
|----------|----------|
| cell 1   | cell 2   |

Definition lists

term
:   definition

Text sizing

Supported size names: xx-small, x-small, small, large, x-large, xx-large, xxx-large, xxxx-large, and numeric 1-7.

<size=x-large>Big text</size>
<size=7>Maximum size</size>

Rabbit-compatible kramdown form is also accepted: {::tag name="x-large"}Big text{:/tag}.

On Kitty-compatible terminals, sized text is rendered using the OSC 66 text sizing protocol. On other terminals, the markup is silently ignored.

Color

Named ANSI colors (red, green, yellow, blue, magenta, cyan, white, plus bright_* variants) and 6-digit hex (with or without a leading #). The value is forgiving about quoting — bare, single-quoted, or double-quoted — and the same engine resolves color names for the standalone <color> tag and for the color attribute on <font>, so the two stay in sync.

<color=red>warning</color>
<color=ff5555>custom hex</color>
<color="#ff5555">CSS-style hex</color>

For combined styling, the color attribute on <font> works too (see Font). Rabbit-compatible kramdown form is also accepted: {::tag name="red"}warning{:/tag}.

Font

HTML4-style <font> tag with face, size, and color attributes. Any subset, in any order. The face attribute is (Echoes only) — other terminals silently ignore it and fall back to the body font.

<font face="Helvetica Neue">Title</font>
<font face="Menlo" size="3">code</font>
<font face="Menlo" size="3" color="red">flagged</font>

Rabbit-compatible kramdown form is also accepted: {::font name="Helvetica Neue"}Title{:/font} (name maps to face).

face requires a terminal that honors the OSC 66 f= extension (e.g. Echoes). For PDF export, the family is registered with Prawn via fontconfig — families that can't be found fall through to the default font.

Alignment

{:.center}
centered text

{:.right}
right-aligned text

XML form (single-line, paragraph-level):

<center>centered <size=3>text</size></center>
<right>right-aligned</right>

Slide background

Set a per-slide background — solid color, linear gradient, or image — via a self-closing block-level directive. Solid color and gradient backgrounds are (Echoes only); image backgrounds work on any Kitty-graphics terminal.

# Title

<bg color="#1a1a2e"/>

content...

# Second slide

<bg from="#1a1a2e" to="#16213e" angle="90"/>

content...

# Third slide

<bg image="cover.png"/>

content layered on top of the image...
  • color / from / to / angle use Echoes' OSC 7772 extension; other terminals ignore the escape sequence.
  • image (PNG, path relative to the deck) uses the Kitty Graphics Protocol at z: -1 so text and <img> content layer on top. Works on every kitty-graphics terminal (Kitty, Ghostty, Wezterm, Echoes…); silently no-ops elsewhere. image wins when set alongside color / from / to.
  • For a deck-wide default, set background.image: (or color: / gradient keys) in theme.yml.

The previous slide's background — color, gradient, and image placement — is cleared on every navigation, and on przn exit, so your shell isn't left tinted or covered.

Absolute-position text

Place text at an arbitrary (column, row) on the slide, escaping the normal top-down paragraph flow:

# Layout test

<at x="10" y="5">top-left ish</at>
<at x="40" y="15"><size=3>BIG</size></at>
<at x="80" y="25"><color=red>warn</color></at>
<at x="50%" y="50%">dead center</at>

Rabbit-compatible kramdown form is also accepted: {::at x="10" y="20"}content{:/at}.

  • x / y accept five forms:
    • Plain integer — 1-based terminal cells (cells are the default for <at> since it places text). x="1" y="1" is the very top-left of the slide pane.
    • Nc — same as plain integer; the c is explicit cells, useful when sitting next to an <img> that defaults the other way.
    • Npx — pixels. Converted to the cell containing that pixel (text can only land on cell boundaries, so a px value is rounded to its enclosing cell).
    • N% — percent of the terminal's current width / height. Auto-adjusts when the pane is resized.
    • Alignment keywordsx="left" / "center" / "right", y="top" / "center" / "bottom" (middle is accepted as a synonym of center on both axes). The position resolves against the content's own rendered footprint — x="center" measures the widest <br>-split line at its actual rendered cell width and centres the whole block; y="bottom" sums each line's height (respecting inline <size> / <font size>) and pins the last line to the bottom edge. Mix and match: <at x="center" y="bottom">caption</at> drops the caption centered on the bottom row.
  • Content is parsed inline, so all the usual styling works inside an <at><size>, <color>, <font>, **bold**, *italic*, etc.
  • The block doesn't take up vertical space in the slide's layout — paragraphs around it render in their normal positions and the absolute placement layers on top. Useful for overlaying labels on a <bg .../> gradient or pinning annotations to specific cells.
  • Out-of-range coordinates clamp into the visible area; missing / unparseable coordinates skip silently.

Image

Embed an image with the standard markdown form, or the <img> XML form when you want to absolute-position it. Both produce identical output — <img> just opens the door to extra attributes like x / y.

![](ruby.png)
<img src="ruby.png"/>

<img src="ruby.png" relative_height="70"/>
<img src="ruby.png" x="200" y="100" relative_height="40"/>     <!-- 200 px from left, 100 px from top -->
<img src="ruby.png" x="5c"  y="3c"  relative_height="40"/>     <!-- cell column 5, row 3 -->
<img src="ruby.png" x="50%" y="50%" height="40%"/>
  • src is required; alt and title are accepted and ignored at render time (kept for accessibility / future use).
  • By default the image renders at its intrinsic size — no auto-shrink to fit. If it's bigger than the visible pane, the overflowing edge is clipped (same as a tall list of paragraphs scrolling off the bottom).
  • relative_height="N" caps the image at N % of the terminal height (no default — without it, intrinsic size). Aspect ratio is preserved. relative_width="N" is the same for the horizontal dimension. Caps shrink, never grow.
  • height="N%" / width="N%" are short-form aliases for relative_height / relative_width (both forms — <img> and ![]{:...} — accept the alias). An explicit relative_* on the same block wins.
  • height="N" / width="N" (plain integer, with optional px suffix) target an exact pixel size on that axis — aspect ratio is preserved, and the other axis is derived from it. Unlike the relative_* caps, pixel values can scale the image up past intrinsic size as well as down. Setting both pixel attrs fits the image inside the smaller of the two scales. relative_* caps still apply on top of a pixel target (width="500" relative_width="40" shrinks the 500-pixel result if it would exceed 40 % of the terminal).
  • x / y (optional) anchor the image's top-left. Same suffix vocabulary as <at>, but <img> flips the default: a bare number means pixels, since that's the image's native unit.
    • Plain integer — pixels (x="200" = exactly 200 px from the left edge of the slide pane). True 1-px precision via Kitty Graphics' X= / Y= sub-cell offsets — x="201" and x="202" land at distinct pixels, not snapped to the cell grid. Backend-dependent: Echoes honors sub-cell offsets reliably; pure-Kitty implementations vary.
    • Npx — same as plain integer; the px is explicit.
    • Nc — 1-based terminal cell, when you want grid alignment instead of pixel alignment.
    • N% — percent of the terminal's current width / height.
    • Alignment keywordsx="left" / "center" / "right" and y="top" / "center" / "bottom" (with middle as a synonym of center on either axis) resolve against the image's rendered cell footprint (after relative_* / width / height sizing). <img src="logo.png" x="center" y="bottom"/> drops the rendered logo centered on the bottom row of the pane; x="right" anchors flush to the rightmost column. Sub-cell pixel offsets reset to 0 for keyword axes (they're cell-grid choices by nature).
    • Either / both axes pin — setting x only pins the horizontal column (vertical falls back to the flow row); setting y only pins the row (horizontal falls back to the centered flow position); setting both pins both. As soon as either is set, the image contributes 0 to the layout flow — paragraphs around it render in their normal positions, exactly like <at>. With neither x nor y, the image stays horizontally centered and takes up its natural height in the flow.
  • Z-order: z="N" lets you put the image above or below cell text. A pinned <img x y/> defaults to z="-1" (behind text) so paragraphs and headings layered on the same cells stay readable; flow <img> (no x / y) stays at the Kitty default of z=0 (on top of cells) because that's almost always what a standalone image wants. Pass z="0" / z="1" etc. to put a pinned image on top.
  • Rendering backend: Kitty Graphics Protocol on terminals that support it (PNG uploaded once and reused; JPG goes through kitten icat), Sixel as a fallback. Other terminals show nothing in place of the image.

Shapes and Lines

Keynote-style vector shapes — <rect>, <circle>, <ellipse>, <line>, <polyline>, <polygon>, <arrow>, <path> — drawn natively by the terminal. Each tag is self-closing, absolute-positioned (contributes 0 to the layout flow), and accepts geometry attrs in slide cells or N% of the terminal width / height.

<line   x1="10" y1="5"  x2="70" y2="5"  stroke="white" stroke-width="0.3"/>
<arrow  x1="10" y1="10" x2="70" y2="10" stroke="cyan"  stroke-width="0.5"/>
<rect   x="10" y="13" width="20" height="6" rx="1" fill="tomato"/>
<circle cx="50%" cy="15" r="5" fill="cyan"/>
<ellipse cx="50" cy="15" rx="20" ry="6" fill="none" stroke="gold" stroke-width="0.5"/>
<polyline points="10,5 30,15 50,5 70,15" stroke="lime" stroke-width="0.4"/>
<polygon points="50,2 60,15 40,15" fill="gold"/>
<path d="M 10 20 C 30 12 50 12 70 20" stroke="orange" stroke-width="0.4"/>
  • Geometry attrs per shape:
    • <rect>: x, y, width, height, optional rx, ry for rounded corners.
    • <circle>: cx, cy, r (radius is a length, resolved against terminal width when given as N%).
    • <ellipse>: cx, cy, rx, ry.
    • <line>, <arrow>: x1, y1, x2, y2. The arrow grows a filled triangular head at (x2, y2); the head sizes scale with stroke-width (length 4×, width 3×). The head's color defaults to stroke; an explicit fill="..." recolors only the head (handy for two-tone arrows).
    • <polyline> / <polygon>: points="x1,y1 x2,y2 ..." (space- or comma-separated; each coord can be N%).
    • <path>: d="..." — SVG path data using M / L / H / V / C / S / Q / T / A / Z commands (uppercase = absolute, lowercase = relative). Coordinates are in slide cells exactly like every other shape (<path d="M 10 5 L 70 5"/> runs a line from cell (10, 5) to (70, 5)); under the hood the renderer rewrites d into pixel coords so the stroke renders crisply at the cell aspect ratio. Percents inside d aren't supported (only plain numbers); the bbox is computed from every endpoint and control point in d, which slightly over-estimates curves (control points often sit outside the visible curve) — safe but means the placement reserves a few extra cells in that direction.
  • Paint attrs pass through to SVG verbatim: fill, stroke, stroke-width, opacity, fill-opacity, stroke-opacity, stroke-linecap, stroke-linejoin, stroke-dasharray, stroke-miterlimit, fill-rule, transform.
  • Colors: the full 147-name CSS / SVG color set is supported — red, tomato, gold, lavender, rebeccapurple, etc. The renderer expands every named color to #rrggbb before shipping the SVG, so Echoes' built-in named-color list (which is smaller) doesn't matter. Hex codes (#rrggbb / #rgb) and rgb(...) / rgba(...) pass through unchanged; none, currentColor, transparent work too.
  • Defaults: closed shapes (rect, circle, ellipse, polygon) fill white; open shapes (line, polyline, arrow, path) stroke white at stroke-width="0.2" (a cell-width hairline). Override via explicit fill= / stroke= when your slide background is light. <path> can be either open (default) or closed via Z plus an explicit fill="...".
  • Coordinate semantics: positional attrs (x, y, cx, cy, x1, y1, x2, y2, points) are 1-indexed slide cells, matching <at> and <img> (so x="10" y="5" lands at column 10, row 5). Size attrs (width, height, rx, ry) are cell counts on the respective axis. The shape is composed in pixel coords behind the scenes using the terminal's actual cell pixel size, so a circle r="5" renders as a true circle even though terminal cells are typically ~1:2 wide-to-tall.
  • Stroke widths are in cell-widths (typically ~12 px each) — stroke-width="0.3" is roughly a 3–4 pixel hairline regardless of cell aspect.
  • Stroke is rendered inside the shape's padded bounding box, so the stroke won't get clipped.
  • Z-order: shapes are registered before any text in the slide so that subsequent text writes re-fill the cell buffer at the cells the placement overlapped. Where the SVG is transparent (e.g., outside a thin <line> stroke or an unfilled outline), text shows through; where the SVG is opaque (a <rect fill="..."/>, a filled <circle>, etc.) the shape still covers any text underneath. Note: stock Echoes (today) ignores the z= parameter and always draws placements on top of cells — until z-index support lands upstream, solid-filled shapes that overlap text cells will occlude that text. z="N" is still accepted on the tag for forward compatibility.
  • Rendering backend: Kitty Graphics Protocol direct-data mode. Echoes content-sniffs the payload and rasterizes via its native CoreGraphics fast path (sub-millisecond, synchronous) — these shapes always hit the fast path. On terminals that don't speak Kitty graphics the shape silently renders nothing.

Slide layouts

Layouts let a slide carve itself into named regions ("slots") — title across the top, two columns underneath, image-and-caption side-by-side, etc. Without a layout, slides render in the existing top-down flow (the default behavior is unchanged).

Picking a layout. Use an h1 IAL with the layout name. Three interchangeable spellings:

# Two columns {layout=two-column}     ← plain HTML-attr style
# Two columns {:layout=two-column}    ← kramdown IAL marker
# Two columns {layout: two-column}    ← YAML / JSON flow style

= and : are interchangeable separators. Values may be unquoted (name), single-quoted ('name'), or double-quoted ("name").

Filling slots. The layout's first title slot is auto-filled from the h1. Remaining slots fill in declaration order; a block-level <slot/> advances to the next slot, and <slot name="right"/> jumps to a specific slot by name.

# Two columns {layout=two-column}

left column content
- bullet A
- bullet B

<slot/>

right column content
- bullet C
- bullet D

Defining layouts. Layouts live under the layouts: key in theme.yml. Each layout's value is an ordered list of slots; each slot has name, x, y, width, height, and optional styling: size (OSC 66 scale: 1-7 or named xx-smallxxxx-large), family (Echoes only), and color (named ANSI or 6-digit hex). Coordinates use the same vocabulary as <at> and <img x y> — numeric cells, N%, Nc, Npx, or the alignment keywords left / center / right on x and top / center / bottom on y (with middle accepted as a synonym of center on either axis). When x: is a keyword the slot is positioned so its content lands at the requested alignment AND blocks routed into the slot inherit the same alignment, replacing the old separate align: key. When y: is a keyword the renderer measures the slot's actual content height (via a dry-render pass) and positions the content so its top / centre / bottom edge falls at the slide's top / centre / bottom — regardless of slot.height. That means y: center centres the text content vertically no matter how many lines it has; a single h1 and a six-line paragraph both land on the slide's centre line.

layouts:
  title-content:
    - {name: title,   x: center, y: 3,  width: 90%, height: 6}
    - {name: content, x: 5,      y: 10, width: 90%, height: 80%}
  cover:
    - {name: title,    x: center, y: 35%, width: 100%, height: 25%, size: xxx-large}
    - {name: subtitle, x: center, y: 80%, width: 100%, height: 15%}
  two-column:
    - {name: title, x: center, y: 3,  width: 90%, height: 6}
    - {name: left,  x: 5,      y: 10, width: 45%, height: 80%}
    - {name: right, x: 50%,    y: 10, width: 45%, height: 80%}

x: center on a slot is what makes h1 titles (and any other content routed into the slot) horizontally centered — there's no implicit "h1 always centers" magic. Per-block <center> / {:.center} directives and inline tags (<size>, <font>, <color>) still override the slot's defaults per-segment. The legacy align: center key on a slot is still accepted as a back-compat alias for x: center, so existing custom themes keep working until you migrate them.

Built-in layouts (shipped in default_theme.yml):

  • default — theme-wide fallback for slides without an {layout=...} IAL. Shipped identical to title-content: centered title band across the top, content below. Override in your own theme.yml to give every plain slide a different layout.
  • cover — auto-applied to slide 0 when it has no {layout=...} IAL. Roughly emulates Keynote's "Title" slide: heading centered near the middle (slot title, y=35%), a smaller subtitle near the bottom (slot subtitle, y=80%). Put the deck title in the h1 and any author/date line in a paragraph after it.
  • title-only — one slot, vertically centered. For section dividers.
  • takahashiTakahashi-method (高橋メソッド) slides: a single very-large phrase per slide, no decoration. Just the words, dropped at the slide's exact centre regardless of phrase length via x: center, y: center.
  • title-content — title across the top, content below.
  • two-column — title across the top, two side-by-side columns.
  • photo-caption — title across the top, image on the left, caption on the right.

Both default and cover are picked automatically when no IAL is set; cover only on slide 0, default everywhere else. To opt out of cover on slide 0, write # Title {layout=default} (or any other explicit layout) on the first slide. To remove the auto-cover behavior deck-wide, delete cover from your theme.

Theme-wide default. Override layouts.default in your own theme.yml to apply a different layout to every plain slide. For example, a full-bleed single-slot layout instead of the shipped title-band default:

layouts:
  default:
    - {name: content, x: 1, y: 2, width: 100%, height: 100%}

A single slide can opt back out of the default layout with {layout=none}:

# This one uses flow rendering {layout=none}

normal top-down content, no slot routing.

Overflow. Content past a slot's height spills downward; there's no clipping or shrink-to-fit. Slot height is a layout guide, not a hard cap — easier to debug while authoring. If you need tighter control, shrink the content or grow the slot.

Comments

HTML-style comments — single-line or multi-line — are stripped at parse time.

<!-- single-line note to self, hidden from the deck -->

<!--
multi-line
hidden block
-->

Rabbit-compatible kramdown form is also accepted:

{::comment}
This text is hidden from the presentation.
{:/comment}

Notes

Visible text <note>(speaker note)</note>

Rabbit-compatible kramdown form is also accepted: {::note}(speaker note){:/note}.

Escaping <, >, &

To show literal markup characters that would otherwise be interpreted as a tag, use HTML-style entity references:

&lt;note&gt;            renders as: <note>
2 &lt; 3                renders as: 2 < 3
A &amp; B               renders as: A & B

A bare < not followed by a recognized tag name renders literally as well, so most accidental < characters are fine. The entities are only needed when you'd otherwise hit one of the tag patterns (<size=...>, <color=...>, <font ...>, <note>, <wait/>, <br>, <center>, <right>, <at ...>, <bg .../>, <img .../>, shape tags like <rect/> / <circle/>, or <!-- ... -->).

Step builds — <wait/>

A <wait/> on its own line is a step boundary: blocks before it are visible immediately, blocks after it stay hidden until you press Space (or Right / Down / j / l). The layout reserves space for the unrevealed blocks so the slide doesn't reflow upward as builds appear — Keynote-style staged reveal.

# Builds

First idea — visible right away.

<wait/>

Second idea — revealed on the first Space press.

<wait/>

Conclusion — revealed on the second Space press.
  • Each <wait/> adds one step. A slide with N waits has N+1 steps; the last Space press flips to the next slide. Left / Up walk backward, with the previous slide re-entered at its last step so backtracking shows full content first.
  • Block-level only — a <wait/> on its own line. Inline <wait/> mid-paragraph is still consumed as a silent no-op (so prose that mentions the marker doesn't accidentally split into steps).
  • Reload (r) rewinds the current slide to step 0 so a re-parsed deck always starts collapsed.
  • Both forms are accepted: <wait/> (XML self-closing), <wait></wait> (paired), and the kramdown spelling {::wait/}.

Cross-slide reference — <ref id="..."/>

Give any positioned block an id="..." and then re-render it on another slide (or twice on the same slide) by writing <ref id="..."/>. The reference looks up the block whose original declaration carries that id anywhere in the deck and dispatches to the same render path, so <at> text, <img> placements, and shapes all reappear at their original spot:

# Slide 1

<at id="lyric" x="center" y="50%">Stand by for Exciter</at>

# Slide 5

<ref id="lyric"/>                <!-- same text, same position -->
<ref id="lyric" y="40%"/>        <!-- same text, moved to y=40% -->
<ref id="lyric" y="80%"/>        <!-- same text, moved to y=80% -->
  • Same-tag attrs on the <ref> (typically x / y, but anything the source's renderer reads) override the source's. Attrs you omit ride through from the source.
  • The synthetic clone the ref renders has no id, so an <action target="..."/> (see below) on the ref slide can't accidentally bind to it. Giving the ref instance its own animatable id is out of scope for v1.
  • Actions stay per-slide. An <action target="lyric"/> on the source slide does not animate the reference; the reference shows the source's declared (not animated) state.
  • First declaration wins on duplicate ids across the deck. Refs are skipped when building the lookup index, so a chain of refs (<ref><ref> → real source) doesn't resolve transitively in v1.
  • Unknown id → silent no-op (no crash, no writes), same fallback as an unknown <action target="...">.
  • PDF parity: refs to flow content (paragraph / list / code / blockquote / table / image) render in the Prawn PDF fallback; refs to <at> or shapes inherit the source's existing PDF limitation (those positioned types aren't drawn in PDF regardless).

Composite reuse — <group id="..."> ... </group>

<ref> re-renders one source block. When the thing you want to reuse is a bundle — a heading + backing rectangle + caption, say — wrap it in a <group id="..."> ... </group> and <ref id="..."/> replays the whole bundle:

# Slide 1

<group id="callout">
<rect x="10" y="20" width="40" height="6" fill="tomato" opacity="0.3"/>
<at x="30c" y="22c">Watch this part →</at>
A caption beneath, in flow.
</group>

# Slide 5

<ref id="callout"/>
  • Children can be anything that already works at slide-body scope — paragraphs, <at>, <img>, shapes, even nested <group>s.
  • Each child's own id stays addressable deck-wide. <ref id="<inner-id>"/> resolves to a child of a group from anywhere; <action target="<inner-id>"/> on the source slide can animate it.
  • When a slide ref's the group, every descendant's id is stripped from the synthetic clone, so per-slide <action target="..."/> on the ref slide can't bind to any child. (Renaming a child inside a ref'd group is a v1 non-goal.)
  • The opening <group ...> and closing </group> tags each have to be alone on their own line — same line-level constraint the rest of the block-level dispatch uses. A missing close tag or a missing id= silently drops the group; the rest of the slide parses normally.
  • <wait/> inside a group does not drive sub-steps in v1. The slide-level step counter only sees waits at slide top level, so all children of a ref'd (or in-place) group render together. Authors who need incremental reveals can use slide-level waits between groups.

Actions — <action target="..."/>

The same id="..." attribute introduced for <ref> (above) also lets <action> push attribute overrides onto a target block at a later step within a slide. The most common use is moving an element across the slide as builds advance:

# Move demo

<rect id="box" x="5c" y="5c" width="20" height="6" fill="tomato"/>

Now press Space →

<wait/>

<action target="box" x="50c" y="20c"/>

… and again →

<wait/>

<action target="box" x="80%" y="80%"/>
  • Any attr on <action> that isn't target overrides the same attr on the target block. x / y move it (the position resolver — the same one used by <img> and <at> — re-runs at each step); z, width, height, shape fill / stroke / opacity, etc. all work the same way.

  • Actions fire on the step at which they appear in the block sequence (positional, same model as element reveals). The latest action against each target wins at the current step, so a sequence of <action>s walks the target through positions.

  • An action with no target= is silently dropped. An action targeting an id that doesn't exist is a no-op (no crash). An action against an id whose element hasn't been revealed yet still sets the state — when the element later appears, it lands at the action's position.

  • Layer interaction: the target block's flow vs. positioned status doesn't change. A pinned <img x y> stays out of the flow whether moved or not; a flow <img> stays in flow.

  • duration="500ms" (also "0.5s" or plain "500" for ms) animates the action smoothly over that span when its step is revealed, instead of snapping. Numeric attrs blend at 30fps from the target's prior value to the new value. Both sides of each interpolated attr must share a unit — x1="15"x1="50" blends fine, but x1="15"x1="50%" snaps because cells and percentages can't be averaged. Key presses during the animation queue and process after it settles.

  • Fade in / fade out (Echoes only)opacity is one of the attrs <action> can drive, so a fade is just <action target="id" opacity="0" duration="500ms"/> (fade-out) or, paired with an initial opacity="0" on the target block, <action target="id" opacity="1" duration="500ms"/> (fade-in). The renderer wraps the faded block in OSC 7772 cell-alpha so Echoes paints its text at the lerped alpha against whatever's underneath. Scope: every text block fades — paragraphs, lists, code blocks, tables, blockquotes, h1 titles, the runner-bar emojis, and <at> positioned text. <img> blocks (Kitty Graphics placements) don't fade in v1 — Kitty has its own opacity param that the renderer doesn't drive yet. Non-Echoes terminals ignore the OSC, so the block snaps in / out instead of fading.

    <at id="bullet">A fading point</at>
    <wait/>
    <action target="bullet" opacity="0" duration="500ms"/>

Line break

Force a line break inside a paragraph with <br>, <br/>, or <br />. Each break closes the current line — surrounding inline styling (<color>, <font>, <center>, etc.) carries across, so a centered paragraph with explicit breaks stays centered on every line. Two <br>s in a row insert a blank line between the chunks.

<center>title in the middle<br>subtitle on the next line</center>

first line<br><br>second line, after a blank

Paragraphs that would overflow the slide width wrap automatically — <br> is only for the cases where you want a break at a specific spot.

Theming

Theme resolution:

  1. theme.yml in the deck's directory — loaded automatically if present. No flag needed.
  2. --theme path/to/your.yml — overrides step 1 with any other file you point to.
  3. default_theme.yml (the file bundled with the gem) — used when neither of the above is found.

All keys are optional — anything you don't set falls back to the bundled defaults.

font:                     # body text typography (paragraphs, lists, h2–h6, code, tables, …)
  family:                 # font family; Echoes only — OSC 66 f= extension
  size:                   # OSC 66 scale: numeric (1–7) or named (xx-small … xxxx-large); default 2 (small)
  color:                  # named ANSI or 6-digit hex; falls back to terminal default fg when unset

title:                    # h1 typography (slide titles)
  family:                 # font family; Echoes only — OSC 66 f= extension
  size:                   # OSC 66 scale: numeric (1–7) or named (xx-small … xxxx-large); default x-large
  color:                  # named ANSI or 6-digit hex

bullet:                   # unordered-list marker; also h2–h6 prefix
  text: ""              # the glyph
  size:                   # OSC 66 scale (1–7) for the bullet; default = body text's scale
  color:                  # named ANSI or 6-digit hex; falls back to body text color when unset

code:                     # fenced code block (and inline `<code>`) typography
  family:                 # font family (Echoes only); falls back to font.family
  size:                   # OSC 66 scale (1–7); default = body text's scale
  color:                  # fg for ALL code text — fenced blocks and inline; named ANSI or 6-digit hex; falls back to body fg
  bg:                     # block background (fenced only); named / hex / ANSI; default = dim gray (ANSI 256-color 236)

background:               # default slide background; Echoes only — OSC 7772
  color:                  # solid, e.g. "#1a1a2e"
  from:                   # gradient endpoint
  to:                     # gradient endpoint
  angle:                  # gradient angle in degrees

counter:                  # bottom-of-screen slide counter; runner-bar opt-in via duration
  color:                  # named ANSI or 6-digit hex; default = dim
  duration:               # "30m", "1h30m", "1800s", or plain integer seconds; opt in to the 🐇/🐢 runner bar
  rabbit:                 # raw escape sequence written at the slide-progress runner position
  turtle:                 # raw escape sequence written at the elapsed-time runner position

Notes:

  • font — body text typography for everything that isn't an h1: paragraphs, lists, h2–h6, blockquotes, code blocks, tables, definition lists. Mirrors title's three knobs:
    • font.family (Echoes only) — applied via OSC 66 f=; PDF: registered via fontconfig. Inline <font face="..."> runs override it per-segment.
    • font.size — OSC 66 scale; controls the visual size of body text (numeric 17 or named xx-smallxxxx-large). Default 2 (small). Inline <size=...> runs override it per-segment.
    • font.color — deck-wide default text color. Inline <color=...> / <font color="..."> runs still win per-segment.
  • bulletbullet.text is the character; bullet.size is the OSC 66 scale used to render it. When bullet.size is smaller than the body text scale, the bullet is rendered with fractional scaling and vertical centering so it still aligns with the body line. bullet.color sets a dedicated foreground color for the marker (named ANSI or 6-digit hex); when unset, the bullet inherits the body text color.
  • title — h1 typography. Same three knobs as font (family, size, color) but each is independent: title.family (Echoes only) does not inherit font.family, title.color does not inherit font.color. title.size defaults to x-large (OSC 66 s=4). When title.family is set, h1 lines emit as one slot-spanning multicell with halign=2 so Echoes' proportional-font measurement centers the text pixel-precisely instead of using the cell-grid estimate. h2–h6 stay body text.
  • code — fenced code block (and inline <code>) typography. code.family (Echoes only) sets the OSC 66 f= face for code (falls back to font.family when unset, so a single body-font override usually styles code too); code.size is the OSC 66 scale (default = font.size); code.color is the foreground colour for all code text — fenced blocks and inline <code> alike — falling back to the body fg when unset; code.bg is the fenced-block background — accepts the same forms as font.color (CSS / ANSI named or 6-digit hex; default is dim gray, ANSI 256-color 236). Inline <code> segments in the terminal keep the historical gray-background styling for the bg; only fenced blocks read code.bg. code.highlight picks the syntax-highlight colour scheme: any Rouge theme name works — common picks are monokai, gruvbox, github.dark, base16.solarized.dark, tulip. Unset (the default) uses przn's built-in ANSI palette, which maps tokens to ANSI colour names and so picks up the user's terminal palette (handy on light terminals where absolute monokai-style hex would clash). Unknown / mistyped theme names silently fall through to the ANSI palette.
  • background (Echoes only) — the deck-wide default background. A per-slide <bg .../> directive overrides it for that slide. The Prawn fallback paints the PDF page in background.color when set; otherwise it leaves the page Prawn's default (white).
  • counter — bottom-of-screen slide counter. Without counter.duration, przn shows a plain N / M counter at the bottom-right. Set counter.duration to opt into the Rabbit-style runner bar: current slide # at the very left, total at the very right, 🐇 running between them tracking slide progress and 🐢 tracking elapsed time against the goal. counter.color (named ANSI or 6-digit hex) styles both the plain counter and the runner-bar anchor numbers; default is dim ANSI. counter.rabbit and counter.turtle are the raw escape sequences the renderer writes at the two runner positions — the default theme ships an Echoes-private OSC 7772 ;multicell with flip=h so the emojis face rightward (towards the right anchor); terminals that don't speak OSC 7772 silently drop the sequence and no runner glyph appears. To stay portable, override these with a plain glyph or a standard OSC 66 multicell — at the cost of the flip on Echoes.

License

The gem is available as open source under the terms of the MIT License.