# Images, diagrams, and export
> Part of the edamame contributor deep-dives. Index and project-wide conventions: [`AGENTS.md`](../../AGENTS.md). Sibling docs live in [`docs/dev/`](.).
The security side of every path here — decode limits, remote consent and SSRF filtering, SVG parsing, the export sanitizer and image handling — is in [security-invariants.md](security-invariants.md). This page covers what keeps the subsystems working.
## Dependencies
- **The accepted image-format list is a security boundary, and Cargo feature unification is what breaks it.** `Cargo.toml` declares `image` with exactly the formats edamame accepts (`png`, `jpeg`, `gif`, `bmp`, `webp`; SVG goes through `usvg`), and `loader::decode` refuses anything else as an ordinary `Unsupported` error. Cargo unions features graph-wide, so the list holds only if *no other crate* asks `image` for more: `ratatui-image`'s `image-defaults` feature forwards `image`'s full defaults (AVIF encoder included). So `ratatui-image` is declared `default-features = false, features = ["crossterm"]`, and a new dependency wanting `image` must be checked the same way: `cargo tree -e features -i image`. Widening the list is a deliberate decision recorded in `docs/editing.md`, never a side effect.
- **Advisory policy is a checked-in file, not CI flags.** `.cargo/audit.toml` denies informational advisories (unmaintained / unsound / yanked) alongside vulnerabilities and carries the reasoned `ignore` list, so `cargo audit` from the repo root gives the same verdict as CI. An advisory belongs in `ignore` only when the fix is genuinely unreachable (upstream pin, or our own deliberate `=` pin); each entry records why and what would let us delete it. If a bump can clear it, take the bump.
## Consent prompts, clipboard, and per-document state
- **The media prompts are per *document*, not per launch — and one method owns that.** Under the default `"ask"` policies the prompt is the *only* thing that sets `session_images_enabled`, and `effective_images_enabled` is false until it does, so a question never asked means images never decode. All three prompts (images, diagrams, remote) are built from the open document, so every path replacing the document's *contents* must re-evaluate them: `App::on_document_contents_swapped` (marks `images_dirty`, queues the three), called by `load_file_into_editor`, `reload_buffer_from_disk` and `apply_diff_resolution` — the last being the *default* external-change path, since a clean buffer with `diff_on_change` goes to diff review rather than a reload (issue #30, which three separate copies missed). A new contents-replacing path owes the call, placed **after** `refresh_parsed`, since the prompts read `editor.parsed`.
- Each `queue_*_prompt` helper carries its own gates, so every queue site — the document swap and the three settings-overlay `apply_*` handlers — inherits them: `media_renderable()` (below truecolor nothing is asked, since `Always` / `Never` would persist a choice made where the result can't be seen), an answer already given this session, and a prompt already on the stack. The session-answer gate is why `session_remote_declined` exists: images and diagrams record a decline in their `Option<bool>`, while `session_allow_remote` is a bare bool, so without the companion flag a dismissed remote prompt is indistinguishable from "never asked". The two remote flags are mutually exclusive (the `Yes` / `Always` arms clear the decline), and a persisted policy change supersedes both (`apply_remote_policy_change`).
- **A document that gains its first image mid-session asks the images question then.** `dispatch_visible_image_decodes` queues the prompt when an in-view image appears with the ask gate open — but not while the user is typing (`IMAGES_PROMPT_TYPING_PAUSE`, with a wake-up parked in `images_prompt_due`) or a modal is open, because the prompt takes bare `n`, `y` and Space as answers.
- **A new document means a new `EditorState`; `app::configure_new_editor` re-applies everything App-level it needs.** `load_file_into_editor` builds a whole new `EditorState` (and `ImageCache`) per document, so anything wired onto the startup editor in `App::new` or `spawn_event_threads` is gone for every file opened by link, back-navigation or `$EDITOR` return. Missing the encoder's `ResizeRequest` sender makes `get_protocol_pair` return `None`, so `paint_images` draws `[Image: alt]` while images cache fine in the background (`App::resize_tx` keeps the clone to re-attach). A new setting an `EditorState` reads out of `Config` belongs in `configure_new_editor`; the `resize_tx` re-attach stays separate only because `App::new` has no sender yet when it builds its editor.
- **`clipboard::source` is the only code that touches the OS clipboard — reads *and* writes.** `App` holds a `Box<dyn ClipboardSource>` that starts as `NullClipboard`; only `main` installs the real one, so no test races another over the process-wide clipboard or reads the developer's. Writes also go out as OSC 52, so a copy works over SSH and without the `clipboard` feature. `edit_ops` never reaches the port: Copy / Cut stage their text in `EditorState::pending_clipboard_write`, and every path that runs one through `edit_ops::apply` owes an `App::flush_clipboard_write` — a `debug_assert!` in `App::dispatch_action` catches a missed one. The flush is also what flashes "Copied", so a copy that staged nothing says nothing.
- A plain `Ctrl-V` reads text first (cheap, UI thread) and a bitmap only when a *reachable* clipboard holds no text (a decode, on a worker), because spreadsheets and rich-text editors put a picture beside the text. `read_text` returns a `TextRead` rather than an `Option` to tell `Unreachable` from `NoText`: over SSH, on Wayland without data-control, or with a source whose `can_read_bitmaps` is false, the bitmap read is skipped and `Ctrl-V` pastes the kill-ring synchronously.
- **A pasted image touches the disk only after its path is confirmed, and is always referenced relatively.** The bitmap is read and PNG-encoded on a worker behind `ClipboardReadModal`; a result whose modal was `Esc`'d is dropped by read id, and that is the whole cleanup. The modal captures input at once but is drawn only after `READ_MODAL_DELAY` (`Modal::is_shown`, flipped by `App::tick_clipboard_read`), so a quick read doesn't flash it; a hidden modal neither dims the screen nor sets `editor.modal_open`, which would downgrade images for that instant. `PasteImageModal` re-checks the block before `image::paste::write`, since the buffer can change under an open modal and a write followed by a refused insert would orphan the file. Outside a container the reference is its own paragraph so it parses as a block image; inside a table, list, quote or footnote it goes in inline (blank lines would end the container), with `|` escaped by `markdown::escape_destination_in_table` because the block lookup can't see a table nested in one. Deliberately absent: a global image directory (an absolute link breaks once the document moves) and pasting files copied in a file manager (arbitrary file reads and per-platform formats, for a copy the user can make in one step). Path rules: [security-invariants.md](security-invariants.md).
## Decode and encode workers
- **Decode happens off the UI thread.** `image::loader` runs in a worker; results arrive as `AppEvent::ImageReady`. Failures are memoized so a reparse won't re-issue a doomed request every frame.
- **ratatui-image encode uses a second worker.** `ResizeRequest` / `ResizeResponse` route through `AppEvent::ProtocolReady` and a pending-request FIFO; failures still pop the queue so a placeholder stays visible until a later frame re-enqueues. `ThreadProtocol::resize_encode` *moves* the inner `StatefulProtocol` to the worker, so `render()` draws nothing until the response lands, while `ProtocolPair::native_ready` latches on the first successful encode and is never cleared. `paint_native` must therefore gate on `native.protocol_type().is_some()` as well as `native_ready`, falling back to the scratch otherwise; gating on `native_ready` alone leaves a hole where `clear_visible_reserved_rect` just blanked the rect.
## Terminal image protocols
- **A native transmission still on screen must not be re-sent (iTerm2).** `Iterm2::render` puts the whole base64 payload in one cell's `symbol`, and `Buffer::diff` sets `invalidated = max(symbol.width(), invalidated) - 1` per cell, so that payload's width keeps `invalidated` positive for the rest of the buffer and **every** later cell is re-emitted — one image retransmits every image below it on every frame, a ~2 Hz flicker at the cursor-blink cadence. Kitty (transmit once, cheap placeholder rows) and Sixel (band path: an unchanged band is dropped from the diff) are immune.
- `paint_native` records each transmission as an `image::cache::NativePaint { rect, generation, frame }` and, when the *immediately preceding* frame left that same encoding at that same rect, marks the whole rect `skip` so ratatui emits nothing. Two things keep the record honest: the one-frame adjacency rule (any scratch / suppressed / off-screen frame invalidates it — the scratch paths clear it explicitly, the others rely on the frame gap, pinned by `a_suppressed_frame_forces_the_next_native_frame_to_retransmit`), and `ImageCache::invalidate_native_paints()` on the two events that repaint the screen behind our back: `App::on_resize` and the `terminal.clear()` after the external editor returns. `ProtocolPair::native_generation` is defense in depth only.
- **The skip marking makes the frame buffer lie** — it records blanks over rows still showing an image. What keeps that from stranding a ghost is that ratatui's `impl PartialEq for Cell` compares the `skip` flag, so the first frame that stops skipping diffs unequal and emits the blanks (`skipped_rect_still_diffs_against_the_same_cells_unskipped`). The frame counter is bumped in `App::draw_frame`, **not** `paint_images`: Raw and Diff draw frames without painting images, and counting only painted frames would make a pre-Raw transmission look adjacent to the first frame back in Rendered. A new path that repaints the terminal outside the paint pass must call `invalidate_native_paints`.
- **A band protocol paints the visible slice, so "partially visible" is not a reason to downgrade** (issue #50: clipped images went blurry). Kitty's unicode placeholders address image rows by index, so a clipped image is a *row offset* into a payload that already holds every row. Sixel has no image store, so its band is a re-slice of the encoded 6-px bands — a shorter payload drawn at the band's first cell. `image::build_sliced` builds the protocol on the decode worker (Kitty's transmit is megabytes of base64 formatted synchronously by `SlicedProtocol::new_with_resize`; Sixel's is a full re-encode) and carries it on `LoadedImage::sliced`, claimed by `ImageCache::prebuilt_sliced` like the scratch; `ui::image_view::paint_sliced` paints the band computed by `image_band`. Consequences:
- The pair's `native` stays `None`, since a threaded protocol would duplicate bytes nothing reads.
- `fully_visible` no longer gates either protocol, but `is_scrolling` and `modal_open` still do (placeholders re-composite wherever they move, Sixel is re-rasterized by the terminal, and `dim_area` cannot recess an image that writes past the cell buffer).
- `ProtocolPair::sliced` is keyed by the same `(url, width, height)` as everything else, so scrolling never rebuilds it — a rebuild mints a fresh Kitty image id that upstream never deletes.
- The protocol-type check in `build_sliced` is load-bearing: `SlicedProtocol::new_with_resize` dispatches on the picker and would otherwise hand back a per-row iTerm2 backend or a halfblocks row copy that `paint_images` would paint as a band.
- **Sixel's band must be a band, and the terminal does the clipping.** The rasterized rows live in the terminal's buffer and are erased by any text written over their cells (the same erasure the scratch path and `clear_area` rely on). A sixel sequence is emitted from the cursor, and a terminal that cannot fit it scrolls the text buffer to make room, which in a TUI would push the whole document up.
- **Sixel's band granularity is 6 px, not one row**, so a skip rounds to a whole band and a clipped image can sit up to 6 px off. Kitty and the `a=p` route are exact.
- **Sixel's settle frame is the expensive one, so it waits longer and yields to input.** With no image store, the first frame after a scroll quiesces re-sends every visible image in full (measured in foot on `tests/fixtures/general.md`: 1.3–2.2 MB, a 110–270 ms blocking write, and a similar stall on the next write), during which no input is read. Mitigations: `SIXEL_SCROLL_QUIESCE` (400 ms, against 150 ms elsewhere) so a slow scroll's short pauses don't each pay it, and `App::defer_settle_frame`, which holds that frame while a terminal event is already queued. The payload is `icy_sixel`'s default encode (256 colors, Floyd–Steinberg), hard-coded by `ratatui-image`; the dithering leaves run-length compression little to gain.
- **WezTerm gets its own backend, because it places images but not placeholders.** It answers the Kitty capability query yet implements no `U=1` unicode placeholders — the only extension `ratatui-image`'s Kitty path renders through — so the probe lands on `Iterm2`. `image::kitty_direct` writes all three escapes itself: a one-time `a=t` transmit (raw RGBA, base64-chunked at upstream's 4096-char limit), a per-frame `a=p` naming a **source rectangle in image pixels**, and a placements-only `a=d,d=i` to remove it. `image::build_direct_placement` builds it on the decode worker and `ImageCache::prebuilt_direct` claims it like the scratch. Routing is an environment hint (`is_wezterm`, distrusted inside tmux like the iTerm2 one), because no capability query can detect a missing sub-feature. Consequences:
- `ProtocolPair::native` stays `None`: there is no PNG to encode or re-send (that re-send is the flash `Iterm2::encode`'s `clear_area` sweep produces).
- The carrying cell's symbol is marked `CellDiffOption::ForcedWidth(1)`. `Buffer::diff` advances by `cell_width()`, and a symbol holding a transmit measures tens of thousands of columns — without it the rest of the row leaves the update stream.
- The symbol starts with an **ECH sweep of the band**, not blanked cells: `[Image: alt]` is a glyph, which draws above an image, and a blank cell marked `Skip` is dropped from the update stream, so it could not erase anything.
- The symbol ends at `(x+1, y)`, one cell right of where it started. `ratatui-crossterm` tracks a cell coordinate, not a display column, so a symbol that left the cursor elsewhere corrupts the next cell that gets no `MoveTo` — which is also why the escape opens with an absolute move.
- **The image id names one stored bitmap; the placement id names one block.** `kitty_direct::image_id` hashes the URL *and* the cell geometry, or one size's transmit would overwrite another's pixels; `kitty_direct::placement_id` is the block index, or the second block of a repeated image would *replace* the first. A re-place replaces exactly this block's previous placement, and a delete must name both.
- **A placement is anchored to screen cells, not content**, so anything that stops painting one must delete it. `ImageCache::reconcile_placements` diffs the `(image_id, placement_id)` pairs placed this frame against the previous frame's — which also catches a block that was edited away, and keeps a delete from touching a sibling block showing the same image — and the queued deletes ride the document area's first cell, the one cell a painted frame always has. The delete is placements-only, so an image scrolling back into view is re-placed for free; the resident-image leak on eviction is the same as the Kitty path's.
- **Direct placement is also the opt-in route for genuine kitty/Ghostty**, gated on `images.sharp_scrolling` (default on) and refused under tmux. These terminals render placeholders fine, but a placement is one short escape where a placeholder grid re-composites wherever it moves, so `a=p` is the only path that stays sharp *while scrolling*. `resolve_direct_placement` (pure, in `terminal::capabilities`) upgrades a `KittyGraphics` probe to `KittyDirect` on the `kitty_direct_hint_is_trustworthy` gate (tmux-only, since the probe already established genuineness), leaving WezTerm's `ITerm2`/`KittyGraphics` → `KittyDirect` arm intact; turning the setting off reverts kitty/Ghostty to placeholders and WezTerm to iTerm2. A kitty/Ghostty picker routed here still reports `Kitty`, so both `build_sliced` (worker) and `get_protocol_pair` (cold path) gate the sliced build on `!direct`, or each would build a megabytes placeholder backend nothing paints. Under tmux the placeholder backend renders through tmux's own passthrough, which direct placement cannot use without a subprocess edamame won't spawn.
- **`Capabilities::halfblocks_picker` must be forced to `ProtocolType::Halfblocks`.** Both ratatui-image constructors are wrong: `Picker::halfblocks()` hardcodes `font_size` to (10, 20), so an image changes aspect ratio crossing the native↔halfblocks boundary; `Picker::from_fontsize()` keeps the probed size but *infers a protocol from the environment*, returning an **iTerm2** picker whenever `$TERM_PROGRAM`/`$LC_TERMINAL` names iTerm2, WezTerm, VS Code, Warp, Hyper, Tabby, rio or mintty. `terminal::capabilities::halfblocks_from` takes the font size from the first and stamps the protocol itself; `image::render_halfblocks_scratch` re-forces it locally. Getting this wrong is silent and severe: the scratch holds one cell carrying a whole base64-PNG escape amid `skip` cells, which `paint_halfblocks_partial` cannot clip by row — the image flashes, re-transmits on every scroll frame, and punches through the modal dim.
- **iTerm2 answers the Kitty capability query but can't do unicode placeholders**, so a `Kitty` probe there transmits an image that is never placed. `detect_image_protocol` overrides `Kitty` → `Iterm2` when `iterm2_hint_is_trustworthy()`, factored into the pure `resolve_protocol(probed, iterm2)` for testing; only `Kitty` is overridden. **The override is suppressed inside tmux**, because tmux makes the env hint stale (`TERM_PROGRAM` / `LC_TERMINAL` are not in its default `update-environment`, so a session created from iTerm2 and reattached from Ghostty still advertises iTerm2), while the stdio probe asks the live terminal. The case given up — tmux inside iTerm2 — gets blank image rows, recoverable via halfblocks, unlike a wrong-protocol pin on Ghostty.
## Diagrams and math
- **Mermaid diagrams use synthetic cache keys.** `diagram::mermaid` renders Mermaid → SVG → PNG via `mermaid-rs-renderer` + `resvg`/`usvg`, keyed `diagram-mermaid-<sha256>` so a rendered diagram survives reparses. `mermaid-rs-renderer` is pinned exactly (`=0.2.2`: pre-1.0, known panics, rapid churn); `resvg`/`usvg` versions must match what it pulls in transitively so cargo doesn't compile two copies.
- **Display math shares that pipeline.** `diagram::math` renders `$$...$$` via RaTeX, keyed `diagram-math-<sha256>`, through the same rasterizer and `DiagramSource`/decode-dispatch machinery (shared bits in `diagram::common`). It uses `render_to_svg(embed_glyphs = false)` — `<text>` output, **not** the `standalone`/`embed-fonts` features — so there is one font subsystem: the KaTeX faces are bundled (`src/assets/katex/`, SIL OFL) and registered into the shared fontdb in `image::svg`. `pin_generic_families` there is load-bearing for math too: RaTeX emits `\text{…}` as `font-family="sans-serif"`, and an unresolved generic (fontdb's default `Arial`, absent on Linux) renders that prose blank; once the generic resolves, usvg's per-glyph fallback reaches a system CJK font on its own. After rasterizing, `fit_latex_to_cell_grid` adds breathing room, flattens transparency onto the document background (halfblocks drop alpha), and centers the formula on a whole number of cell rows.
## Raw reveal of image blocks
- **A revealed image block reserves one row per *source* line, not its image's rows.** A `Block::ImageBlock` otherwise reserves as many rows as the decoded image occupies, which clipped a short-wide diagram's source (issue #24), padded out a tall one, and left a one-line `` floating in blanks. **The gate is `is_image_block`, not `is_mermaid_block`** — both cases are the same bug. `EditorState::image_reveal` stashes an `ImageReveal { ordinal, url, rows }` for the block the cursor rests in, and `refresh_parsed`'s row override consults it **first** — ahead of the decode cache and the images-disabled collapse to 1 row — so the document reflows.
- The transition is resolved once per frame by `sync_image_reveal` from `App::prepare_viewport`, because `RAW_REVEAL_DELAY` elapses with no event of its own; the call is a no-op when the target hasn't moved. **A sync returning `true` owes `ensure_cursor_visible`**: the reflow moves rendered rows under a cursor that never moved, which can carry it off screen or strand it above `scroll` (`revealing_a_diagram_at_the_fold_keeps_the_cursor_on_screen`). The `dims` given to that call were measured before the reflow, so a reveal changing the line count can paint one frame with a stale gutter width; the `needs_draw` it sets re-measures. It holds the current reservation while `parsed_dirty`, since a block's URL (a diagram's source hash, or an image's source text) changes mid-typing, and an in-line edit can't change the line count anyway.
- **The row count is `rendered_view::revealed_source_line_count`, not the raw line count**, because a block's *extended* byte range absorbs the blank line after a paragraph (the promoted `` paragraph included), while a fence's range stops at its closing ```. That blank is a virtual block with its own row, so counting it reserves one too many. It otherwise counts through the same split the reveal paints with (`raw_source_lines`), so reserved and painted lines can't disagree.
- **The block is named by ordinal *and* URL, and `ImageRowOverride` takes both.** A URL alone would collapse every copy of a repeated image (`revealing_one_image_leaves_a_duplicate_of_it_alone`). The ordinal counts `Block::ImageBlock`s in document order (`Renderer::image_block_seq` and the index into `ParsedDoc::image_blocks`, both walking the same top-level list). The URL is a staleness check for the `parsed_dirty` hold: a block that stopped being an image matches nothing, instead of the ordinal sliding onto the next image. `row_map::row_for_line` clamps to the block's last row, which turned a short reservation into a stuck cursor; with the expansion in place that clamp is a no-op.
## Export
- **Images take one of three modes (`export::ImageHandling`)** — Link, Embed, Sealed; what each does with an `<img src>`, and why, is in [security-invariants.md](security-invariants.md#exports-ask-before-embedding-files-from-outside-the-documents-folder). The bundled stylesheet is `config/export/default.css`. A custom format is always Sealed; the modal pins its Inline images toggle on while keeping the toggle's own value for HTML. `ExportJob::seals_images` is the one place that decides which formats seal; `ExportJob::format` copies it into the row's `ExportFormat::seals_images` (both widget constructors leave it `false`), so the form can't show a handling the export doesn't use. The workers call `html::render`, not `render_html`, to get the removed-image count (`Exported::images_left_out`), which the success phase and the hint-line flash both word through `ui::left_out_note`. The flow is submit → outside-images confirm (`ConfirmOutsideImages`) → overwrite confirm → worker, and each submit clears the previous answer; the outside-images check runs at submit on the App thread, since it is only a parse plus a `canonicalize` per image.
- **Figures are event-stream rewrites gated by one flag (`render_figures`).** The exporter runs on pulldown's `Event` stream, not the promoted AST, so two passes carry the figure semantics: `replace_mermaid_with_figure` swaps a ` ```mermaid ` block for a `<figure class="mermaid-diagram">`, and `replace_math` (which needs `Options::ENABLE_MATH`, mirroring `parse_offsets::BASE_OPTIONS`) swaps a display-math **paragraph** — one whose body is *only* `$$…$$` plus whitespace/breaks, the shape `promote_display_math_paragraphs` recognizes in the TUI — for one `<figure class="math-formula">` per formula, dropping the enclosing `<p>` so the figure is valid HTML. Both go through `svg_figure` and the post-sanitize splice described in [security-invariants.md](security-invariants.md#html-export-removes-executable-content). The fallback (render failure, or math with figures off) is a fenced code block — `push_display_math_source_block` synthesizes the `math` one, delimiters stripped, since a `$$…$$` paragraph has no code block to reuse — so an un-inlined figure reads as a padded box. `replace_math` **always runs** when `ENABLE_MATH` is on; only inline `$…$` and math in a mixed paragraph stay literal text. The config key was `[export.html].diagrams`; it is now `figures` (serde `alias = "diagrams"`), since math is rendered too.
- **Code blocks are highlighted by the in-app tokenizer, as an event-stream rewrite.** `highlight_code_blocks` runs last (so `replace_math`'s `math` fallback blocks pass through it too), keeps a fenced block's `Start` / `End` events so pulldown still writes `<pre><code class="language-…">`, and replaces its `Text` with one `Event::Html` of escaped text and `<span class="hl-{class}">` runs, one class per `TokenClass` (`token_css_class`; `builtin_stylesheet_styles_every_token_class` keeps `default.css` in step). Lines are split the way `Renderer::render_code_block` splits them, and the block's trailing newline is preserved, so an unhighlighted block and a highlighted one differ only by the spans. Whatever the highlighter declines — unknown language, over a cap, a grammar error — keeps its original events. The export calls `highlight::highlight_block_unbudgeted`, which compiles a cold grammar on the export thread rather than queuing it to the background worker (whose async result would leave the export plain) and leaves the render thread's grammar budget untouched. The colors live in the stylesheet, not the theme: a terminal theme's colors are chosen for its own code surface, and the export page sets its own. Independent of the `syntax_highlighting` setting, which governs the terminal display only.
- **Headings carry `id`s, from the same slug as the in-app jump.** pulldown emits an `id` only when the `Heading` event carries one, which without `ENABLE_HEADING_ATTRIBUTES` none does. `assign_heading_ids` fills it with `gfm_slug` + `uniquify_slug` (`document::parsed_doc`) over the text `heading_slug_text` would build (no image alt text or inline HTML, and a line break is a `\n`, which drops out of the slug, as on GitHub), so a fragment that resolves in edamame resolves in the export (`heading_ids_match_the_in_app_anchor_table`). It runs before `replace_math`, while heading math is still `InlineMath`. One deliberate difference: every heading gets an id, including ones nested in a list or quote, which `ParsedDoc::heading_anchors` (top-level only) doesn't index — GitHub ids those too. A nested heading also advances its slug's duplicate count, so `- # Intro` then `# Intro` exports as `intro` / `intro-1`, while edamame jumps `#intro` to the top-level one. A heading with nothing to slug gets no `id` but still advances the `""` count, as in-app, so the slugs that follow stay in step (`empty_slug_headings_count_as_in_app`).
- **One modal, one palette entry, and the format is a field inside it.** The palette shows a single `Export…` row (`Action::ExportHtml`); the modal's Format list offers HTML plus every valid `[[export.custom]]` converter. A custom export converts the HTML `export::html` produces, so it shares the whole form — options, overwrite confirmation, `AppEvent::ExportDone` handshake — and only the output extension, image handling, which `spawn_*` runs, and the success-button label differ. `ui::ExportFormat` (`{label, open_result, seals_images}`) is the widget's whole per-format surface; the App adapter holds a parallel `Vec<ExportJob>` in the same order and runs `jobs[state.format_idx]`, so `ui::export_modal` never branches on the backend.
- **The jobs own *clones* of their `CustomExportEntry`, never indices.** `App::open_export_modal` builds them once at open time (HTML first, then each converter whose `config_problem()` is `None`). Returning from the external editor reloads config wholesale, so an index captured at open could name a different converter by the time `[ Export ]` is pressed.
- **`CustomExportEntry`'s fields all default, and the validator enforces them instead.** Required fields meant one typo in an export entry failed the whole `config.toml` parse, and the loader answers that with `Config::default()` — silently resetting everything for that launch. `readers::validate_custom_exports` *reports* an unusable entry (empty name / command / extension, or a path separator in the extension) with an `InvalidValue` warning naming its index. It is also what lets the shipped reference config's commented `[[export.custom]]` block pass `shipped_reference_config_examples_are_all_uncommentable`.
- **A flagged entry is ignored, not deleted.** The loader's result is written back on the next `Config::save` (a bookkeeping save fires within seconds of launch), so `retain`ing the vector would erase the very lines the warning asks the user to fix. The entry stays in `config.export.custom`; `CustomExportEntry::config_problem` (shared by the validator and `App::open_export_modal`) keeps it out of the Format list. `config_problem` reads the extension through `output_extension` (trim + one leading dot), the same normalization `ExportJob::extension` writes into the filename, so a validated `".pdf"` and the resulting `guide.pdf` agree.
- **The intermediate HTML is written into the output directory, not `/tmp`.** `export::custom` renders to a temp file beside `target` (falling back to `source_dir`, then cwd), because converters resolve relative URLs against the input file's location. It also stamps `target`'s pre-run `(mtime, len)`, so the stdout-capture fallback (`!wrote_target`) can't mistake a stale file from a previous export for fresh output on the overwrite path; length is in the stamp because on a 1-second mtime filesystem a same-tick rewrite reads as unchanged, and the fallback would then overwrite real output with the converter's stdout.
- **The `{html}` / `{out}` arguments are absolutized (`custom::absolutize`) before substitution.** The command runs with its cwd set to the document's folder, so a relative `{out}` (what `file_path` holds after `edamame docs/guide.md`) would resolve to `…/docs/docs/guide.pdf`. `std::path::absolute` normalizes without I/O (the target need not exist yet), and the whole runner uses the absolute forms, so the mtime probe, the write and the returned path agree.