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.durationis 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 contentText 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. listCode 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 1–7 or named xx-small … xxxx-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:
-
mmdconPATH— install vianpm i -g @mermaid-js/mermaid-cli. - Puppeteer's headless Chrome —
mmdcshells out to it. If you see a "Could not find Chrome" error, runnpx puppeteer browsers install chrome-headless-shellonce.
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 hereTables
| Header 1 | Header 2 |
|----------|----------|
| cell 1 | cell 2 |Definition lists
term
: definitionText 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 textXML 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/angleuse Echoes' OSC 7772 extension; other terminals ignore the escape sequence. -
image(PNG, path relative to the deck) uses the Kitty Graphics Protocol atz: -1so text and<img>content layer on top. Works on every kitty-graphics terminal (Kitty, Ghostty, Wezterm, Echoes…); silently no-ops elsewhere.imagewins when set alongsidecolor/from/to. - For a deck-wide default, set
background.image:(orcolor:/ gradient keys) intheme.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/yaccept 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; thecis 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 keywords —
x="left"/"center"/"right",y="top"/"center"/"bottom"(middleis accepted as a synonym ofcenteron 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.
-
Plain integer — 1-based terminal cells (cells are the default for
- 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.

<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%"/>-
srcis required;altandtitleare 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 forrelative_height/relative_width(both forms —<img>and![]{:...}— accept the alias). An explicitrelative_*on the same block wins. -
height="N"/width="N"(plain integer, with optionalpxsuffix) target an exact pixel size on that axis — aspect ratio is preserved, and the other axis is derived from it. Unlike therelative_*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"andx="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; thepxis 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 keywords —
x="left"/"center"/"right"andy="top"/"center"/"bottom"(withmiddleas a synonym ofcenteron either axis) resolve against the image's rendered cell footprint (afterrelative_*/width/heightsizing).<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
xonly pins the horizontal column (vertical falls back to the flow row); settingyonly 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 neitherxnory, the image stays horizontally centered and takes up its natural height in the flow.
-
Plain integer — pixels (
-
Z-order:
z="N"lets you put the image above or below cell text. A pinned<img x y/>defaults toz="-1"(behind text) so paragraphs and headings layered on the same cells stay readable; flow<img>(nox/y) stays at the Kitty default ofz=0(on top of cells) because that's almost always what a standalone image wants. Passz="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, optionalrx,ryfor rounded corners. -
<circle>:cx,cy,r(radius is a length, resolved against terminal width when given asN%). -
<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 withstroke-width(length 4×, width 3×). The head's color defaults tostroke; an explicitfill="..."recolors only the head (handy for two-tone arrows). -
<polyline>/<polygon>:points="x1,y1 x2,y2 ..."(space- or comma-separated; each coord can beN%). -
<path>:d="..."— SVG path data usingM / L / H / V / C / S / Q / T / A / Zcommands (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 rewritesdinto pixel coords so the stroke renders crisply at the cell aspect ratio. Percents insidedaren't supported (only plain numbers); the bbox is computed from every endpoint and control point ind, 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#rrggbbbefore shipping the SVG, so Echoes' built-in named-color list (which is smaller) doesn't matter. Hex codes (#rrggbb/#rgb) andrgb(...)/rgba(...)pass through unchanged;none,currentColor,transparentwork too. - Defaults: closed shapes (
rect,circle,ellipse,polygon) fillwhite; open shapes (line,polyline,arrow,path) strokewhiteatstroke-width="0.2"(a cell-width hairline). Override via explicitfill=/stroke=when your slide background is light.<path>can be either open (default) or closed viaZplus an explicitfill="...". - Coordinate semantics: positional attrs (
x,y,cx,cy,x1,y1,x2,y2,points) are 1-indexed slide cells, matching<at>and<img>(sox="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 acircle 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 thez=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 DDefining 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-small … xxxx-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 totitle-content: centered title band across the top, content below. Override in your owntheme.ymlto 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 (slottitle, y=35%), a smaller subtitle near the bottom (slotsubtitle, 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. -
takahashi— Takahashi-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 viax: 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:
<note> renders as: <note>
2 < 3 renders as: 2 < 3
A & B renders as: A & BA 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>(typicallyx/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 missingid=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'ttargetoverrides the same attr on the target block.x/ymove it (the position resolver — the same one used by<img>and<at>— re-runs at each step);z,width,height, shapefill/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, butx1="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) —
opacityis 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 initialopacity="0"on the target block,<action target="id" opacity="1" duration="500ms"/>(fade-in). The renderer wraps the faded block in OSC 7772cell-alphaso 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 blankParagraphs 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:
-
theme.ymlin the deck's directory — loaded automatically if present. No flag needed. -
--theme path/to/your.yml— overrides step 1 with any other file you point to. -
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 positionNotes:
-
font— body text typography for everything that isn't an h1: paragraphs, lists, h2–h6, blockquotes, code blocks, tables, definition lists. Mirrorstitle's three knobs:-
font.family(Echoes only) — applied via OSC 66f=; PDF: registered via fontconfig. Inline<font face="...">runs override it per-segment. -
font.size— OSC 66 scale; controls the visual size of body text (numeric1–7or namedxx-small…xxxx-large). Default2(small). Inline<size=...>runs override it per-segment. -
font.color— deck-wide default text color. Inline<color=...>/<font color="...">runs still win per-segment.
-
-
bullet—bullet.textis the character;bullet.sizeis the OSC 66 scale used to render it. Whenbullet.sizeis 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.colorsets 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 asfont(family,size,color) but each is independent:title.family(Echoes only) does not inheritfont.family,title.colordoes not inheritfont.color.title.sizedefaults to x-large (OSC 66s=4). Whentitle.familyis 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 66f=face for code (falls back tofont.familywhen unset, so a single body-font override usually styles code too);code.sizeis the OSC 66 scale (default =font.size);code.coloris the foreground colour for all code text — fenced blocks and inline<code>alike — falling back to the body fg when unset;code.bgis the fenced-block background — accepts the same forms asfont.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 readcode.bg.code.highlightpicks the syntax-highlight colour scheme: any Rouge theme name works — common picks aremonokai,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 inbackground.colorwhen set; otherwise it leaves the page Prawn's default (white). -
counter— bottom-of-screen slide counter. Withoutcounter.duration, przn shows a plainN / Mcounter at the bottom-right. Setcounter.durationto 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.rabbitandcounter.turtleare the raw escape sequences the renderer writes at the two runner positions — the default theme ships an Echoes-private OSC 7772;multicellwithflip=hso 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.