Sheet R3 · Reference

Export formats

How to add a format to the Export dialog.

An export format adds an item to the Export dialog. Hammer gives your plugin the story, and your plugin returns the file's bytes.

Every request is a JSON object. It includes locale, the BCP 47 tag of Hammer's UI language (such as fr-CA), or null; write your messages in this language. Don't confuse it with the project's language, which is the language the story is written in. Every request also includes settings, with the current value of each setting your plugin declares. Every key is present. See the manifest.

Like every export, export takes no arguments and returns an i32: 0 for success, anything else for failure. A call also fails if it traps. Either way, the writer sees the message your plugin set with error_set. See the host functions.

Manifest

[[exporters]]
format = "wordfreq.csv"
extension = "csv"
mime = "text/csv"
label = "Word frequency (CSV)"
input = "prose"
Key Meaning
format The format's id. It must start with your plugin's id and a dot, such as plaintext.txt; Hammer won't install a plugin whose format lacks the prefix. It's sent as the request's format
extension The file extension, without a dot. The desktop save dialog uses it
mime The file's MIME type. The Android save picker uses it
label The name shown in the Export dialog. Translate it in locales/ under [exporters."<format>"]
input How each scene arrives. With markdown, the default, the scene's text arrives in scenes. With prose, Hammer's parse of it arrives in prose
images Whether the request carries the files of the images the story shows, in images. False by default. See Images
cover Whether the request carries the project cover too, with its id in coverImageId. False by default, and it needs images
output What your plugin returns: file, the default, for the file's bytes, or archive, for a list of files that Hammer zips. See Archives

Hammer won't install a plugin that declares a format twice, gives an unknown input or output, or sets cover without images.

A plugin can declare several formats. A single export function renders all of them and tells them apart by the request's format.

Your formats are also available in other places, with no extra code:

  • through the project.export operation
  • on the command line, as hammer project export --format plaintext.txt
  • through the MCP plugin

If a project has turned your plugin off, it doesn't list your formats, and project.export rejects them in that project.

Request

{"format": "wordfreq.csv", "projectName": "Storm", "language": "en",
 "topLevelAsChapters": true,
 "chapters": [{"name": "Chapter 1", "scenes": ["Rain fell *hard*.", "..."], "prose": []}],
 "images": [], "coverImageId": null,
 "settings": {"min_length": 3}, "locale": "en-GB"}
Key Contains
format The format of the manifest entry being exported
projectName The project's name
language The project's language, a BCP 47 tag
topLevelAsChapters Whether the story's top-level groups are chapters. When it's false, chapters contains a single chapter, named after the project, with every scene in it
chapters The chapters in story order. Each has a name and its scenes in order: in scenes as markdown, or in prose as blocks when the format sets input = "prose". The other list is empty
images The files of the images the story shows, when the format sets images = true. Empty otherwise. See Images
coverImageId The cover's id in images, when the format sets cover = true. Otherwise null, and also null when the project has no cover or Hammer couldn't read its file
settings The value of every declared setting
locale The UI's BCP 47 tag, or null

Prose input

With input = "prose", each scene arrives already parsed into blocks by Hammer's own markdown parser. The built-in formats render from this same parse, so your plugin doesn't need a markdown parser of its own, and it reads the text exactly as they do. A chapter's prose is a list with one entry per scene, and each entry is a list of blocks.

{"name": "Chapter 1", "scenes": [], "prose": [
  [{"type": "heading", "level": 2, "spans": [{"text": "Morning"}]},
   {"type": "paragraph", "spans": [{"text": "Rain fell "}, {"text": "hard", "italic": true}, {"text": "."}]},
   {"type": "blank"},
   {"type": "rule"}]
]}

Each block has a type:

type Keys Meaning
paragraph spans One line of prose, as the author typed it
blank A blank line between two passages
image id, name A line holding only a project image, when the format sets images = true. Its id is always one in the request's images
heading level, spans A heading. level is 1 to 6, the number of #s
list ordered, items A list. Nested items are flattened into reading order. Each item is {"level", "ordered", "spans"}, where level counts from 0 at the outermost level
quote paragraphs A block quote: a list of paragraphs, each of which is a list of spans
code code The text of a code block, as a string
rule A horizontal rule, which the author uses as a scene break of their own
table header, rows header is a list of cells, and rows is a list of rows, each a list of cells. A cell is a list of spans

Any other project image, such as one inside a line, one whose file Hammer couldn't read, or any image when the format doesn't set images, arrives as its name, as text. An image without a name is left out.

A span is a run of text in a single style: {"text": "...", "bold": true, "italic": true, "strikethrough": true, "code": true, "link": "https://..."}. Styles that are off are left out, and so is a missing link, so a plain span is just {"text": "..."}.

Images

A format that sets images = true gets, in images, each project image the story refers to whose file Hammer could read, sorted by id:

{"id": 5, "name": "Map", "extension": "png", "mimeType": "image/png", "content": "iVBORw0KGgo..."}
Key Contains
id The image's id in the project
name The image's current name
extension The file extension, without a dot
mimeType The file's MIME type, or application/octet-stream when Hammer doesn't know the extension
content The file, in standard base64 with padding (RFC 4648 section 4)

These are the same keys image.get returns, plus mimeType. With cover = true as well, the cover is among images, and coverImageId is its id.

In scenes, an image is a line of its own, ![image](hammer://image/<id>). Only Hammer can resolve that destination, so your plugin looks the id up in images. The alt text means nothing: Hammer shows the image's current name wherever alt text would appear. Hammer builds images from every reference it finds, including one in a code span or a bare link.

Hammer sends every image the story refers to, and the cover, not a selection. An image can be up to 8 MiB, and base64 adds a third. See Rules for what that means for memory.

Reply

With output = "file", the default, your plugin's output is the file's bytes, exactly as they should be written. Don't wrap them in JSON.

Archives

With output = "archive", your plugin returns a list of files as UTF-8 JSON, and Hammer zips them, in order, into the export file. Your plugin doesn't need any zip code. The manifest still gives the format's extension and mime; zip and application/zip suit it.

{"files": [{"path": "story.md", "text": "# Storm\n\n![Map](images/5.png)"},
           {"path": "images/5.png", "image": 5}]}

Each file has a path and exactly one of these:

Key Contains
text The file's text, written as UTF-8
content The file's bytes, in standard base64 with padding, as images sends them
image The id of an image in the request's images, as a JSON number. Hammer writes that image's bytes, so your plugin doesn't send them back. An id that wasn't in this request's images fails the export

A path is relative, with / between its segments:

  • It isn't empty and doesn't start with /.
  • It has no \, no empty segment (so no // and no trailing /), and no . or .. segment.
  • It's valid Unicode, and at most 65,535 bytes as UTF-8, the zip format's limit on an entry name.
  • No two files have the same path.

The reply needs at least one file. Hammer checks the whole reply before it writes any of it. A reply that isn't this JSON, or that breaks a rule, fails the export with a message naming your plugin and the path or problem. The kits' archive builders check the same path rules as each file is added, apart from the length and Unicode rules.

Rules

  • Exports run on a background thread, not the UI thread. If your plugin is removed or replaced while an export is running, that export finishes on the old version of the plugin.
  • If an export fails, Hammer shows the message set with error_set. In the C kit, extism_error_set_buf_from_sz sets it; the Kotlin and Rust kits set it through their error handling. See the host.
  • The request contains the whole story, and its images. It counts against what each call can pass to and from Hammer: 256 MiB, or a sixteenth of the JVM's heap if that's less (16 MiB on a 256 MB Android heap). No manifest key raises that cap. The reply counts too, beside the request. A request that doesn't fit fails the export before your plugin runs, and a reply that doesn't fit fails it with an allocation error. See the host.
  • An image entry in an archive adds only its path to the reply, so an exporter that packages the story's images that way holds them once. Bytes your plugin sends in content cost 4/3 of their size. In a language that uses linear memory (C, Rust, Zig, AssemblyScript, Go), raise [limits] memory. The copy of the request, the decoded images, and the output are live at once, so plan for about three and a half times the size of the story's images; an exporter that writes image entries needs only the request copy and its own text. A Kotlin/Wasm plugin keeps its objects in the interpreter's heap instead, where [limits] memory doesn't apply.

In each kit

Here's the same export in each kit: a text file listing each chapter and how many scenes it has.

import hammer.ExportRequest
import hammer.exporter
import kotlin.wasm.WasmExport

fun helloExport(request: ExportRequest): String = buildString {
    appendLine(request.projectName)
    request.chapters.forEach { appendLine("${it.name}: ${it.scenes.size} scenes") }
}

@WasmExport
fun export(): Int = exporter { helloExport(it).encodeToByteArray() }

exporter reads the request into an ExportRequest and writes out the bytes your lambda returns. If the lambda throws an exception, the export fails with that exception's message. With input = "prose", ExportRequest.Chapter.prose is a List<List<JsonElement>>, one list of blocks per scene.

For images, request.image(id) finds one in images, and request.cover is the cover, or null. An ExportRequest.Image has the file's bytes in content, decoded when first read, and the base64 as Hammer sent it in base64. findImageIds(markdown) lists the ids a scene refers to, parseImageReference reads one hammer://image/<id>, and proseImageId(block) reads an image block. For output = "archive", build the reply with ExportArchive: text, bytes, base64, and image(path, id) or image(path, image) add files, and toJson() gives the reply, which your lambda returns with encodeToByteArray().

use hammer::export::Request;

#[unsafe(no_mangle)]
pub extern "C" fn export() -> i32 {
    hammer::run(|| {
        let request: Request = hammer::input_json()?;
        let mut text = format!("{}\n", request.project_name);
        for chapter in &request.chapters {
            text += &format!("{}: {} scenes\n", chapter.name, chapter.scenes.len());
        }
        Ok(text)
    })
}

hammer::run writes the Ok value as the output. If the closure returns an Err or panics, hammer::run sets that message as the error. With input = "prose", Chapter::prose is a Vec<Vec<Value>>, one list of blocks per scene.

For images, request.image(id) finds one in images, and request.cover() is the cover, if any. An Image has the file's bytes, already decoded, in content. hammer::images::find_ids(markdown) lists the ids a scene refers to, hammer::images::parse_reference reads one hammer://image/<id>, and hammer::export::prose_image_id(block) reads an image block. For output = "archive", build the reply with hammer::export::Archive: text, bytes, base64, and image(path, id), which also takes &image, add files, each returning an error for a path Hammer would refuse, and to_json() gives the reply.

#define HAMMER_IMPLEMENTATION
#include "hammer.h"
#include "hammer_json.h"
#include "hammer_out.h"

int32_t EXTISM_EXPORTED_FUNCTION(export) {
  hammer_alloc_reset();
  uint64_t size = extism_input_length();
  char *input = hammer_must_alloc(size);
  extism_load_input(0, input, size);

  HammerBuffer out = {0};
  HammerJson json;
  const char *name;
  uint64_t name_length;
  if (hammer_json_find(input, size, "projectName", &json) && hammer_json_string(&json, &name, &name_length)) {
    hammer_reserve(&out, name_length);
    out.length += hammer_json_unescape(name, name_length, out.data + out.length);
  }
  hammer_put_char(&out, '\n');
  extism_output_buf(out.data, out.length);
  return 0;
}

hammer_json.h walks the request in place, without allocating. It reads the prose form as well as the markdown one; hammer-plugins' plaintext plugin uses it to render every block type. hammer_json_string gives you a string's raw contents, escapes included, and hammer_json_unescape decodes them.

The C kit has no request type, so read images with hammer_json.h. hammer_base64.h decodes an image's content with hammer_base64_decode, and hammer_image.h finds the ids a scene refers to with hammer_next_image_id, or reads one reference with hammer_image_id. For output = "archive", hammer_archive.h builds the reply: hammer_archive_text, hammer_archive_bytes, hammer_archive_base64, and hammer_archive_image add files, calling hammer_fail for a path Hammer would refuse, and hammer_archive_finish gives the reply for extism_output_buf.