Render an SVG to PNG
Give the agent a local vector drawing and an output path. This plugin compiles resvg into WebAssembly, reads SVG text through kcode, and saves PNG bytes without a shell or a rendering service.
The manifest declares filesystem access. The Rust renderer checks the input and produces pixels; the Wasm bindings register the tool and pass bytes to the host. Native tests check pixels and unsupported inputs. A separate kcode runtime test invokes the compiled module repeatedly and checks real PNG output.
This vector-only example rejects text and images: convert text to paths first. It limits input to 1 MiB and output to four million pixels. Existing output files are overwritten. It requires a kcode build with the Wasm binary-write API and read-scope wiring.
svg-renderer/README.md
# Render an SVG to PNG
This Wasm plugin compiles resvg into the module. It registers `render_svg`, reads SVG text through the host, renders locally, and writes PNG bytes through `kcode.fs_write`. It does not invoke a shell or contact a rendering service.
## Build and install
Requires Rust and the new Wasm binary-write host API in kcode (commit `222c2e46` or later). Older kcode builds cannot load the module's `fs_write` import.
```sh
rustup target add wasm32-unknown-unknown
cargo test --locked
cargo build --locked --release --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/release/svg_renderer.wasm main.wasm
```
Copy `plugin.toml` and `main.wasm` into `<project>/.kcode/plugins/svg-renderer/`. Validate with `kcode plugins validate <directory>`. Project trust and plugin policy apply.
Ask the agent to call `render_svg`:
```json
{"source":"drawing.svg","output":"out/drawing.png"}
```
The result includes `output`, `width`, `height`, and `size_bytes`. The host creates output parents and overwrites existing output files. Choose a new path if the original must be preserved.
## What the files do
- `plugin.toml` declares Wasm and filesystem capabilities; writes are limited to `out/**`
- `src/lib.rs` parses and renders the SVG, enforces bounds, and tests pixels and failures
- `src/wasm.rs` implements registration, guest-memory allocation, host reads/writes, and JSON tool results
- `Cargo.lock` pins dependencies for repeatable builds
The Wasm runtime currently registers a tool description, not a detailed per-tool JSON schema. The description names the arguments and the Rust deserializer validates them.
## Limits
This is a vector-only example: text, images, scripts, foreign objects, and external hrefs are rejected. Convert text to paths first. Image resolvers are disabled; no host fonts or external images are loaded. Input is limited to 1 MiB and output to four million pixels. Complex SVGs can still hit the host's fuel, memory, or execution limits and return a failure.
The host's file-write cap is 512 MiB, separate from the default 64 MiB Wasm linear-memory limit. This example deliberately uses a smaller pixel bound; increasing the file-write cap does not increase the module's memory budget.
## Runtime verification
The kcode repository includes an opt-in test against this compiled module:
```sh
KCODE_SVG_PLUGIN_DIR=/absolute/path/to/svg-renderer cargo test -p plugin-runtime-wasm --test svg_renderer -- --ignored --nocapture
```
It invokes the module repeatedly, reads real PNG files back, checks their dimensions, and checks malformed SVG failure. Its filesystem adapters are test ports; the host's project-root and path-scope restrictions have separate filesystem tests.svg-renderer/plugin.toml
name = "svg-renderer"
version = "0.1.0"
description = "Render local vector SVGs to PNG using resvg compiled into Wasm"
kind = "Wasm"
[capabilities]
"fs.read" = ["**/*.svg", "*.svg"]
"fs.write" = ["out/**"]svg-renderer/Cargo.toml
[package]
name = "svg-renderer"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
resvg = { version = "0.45", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
roxmltree = "0.20"
[profile.release]
opt-level = "s"
lto = true
[workspace]svg-renderer/src/lib.rs
//! Bounded vector-only SVG rendering shared by native tests and the Wasm tool.
#[cfg(target_arch = "wasm32")]
mod wasm;
#[cfg(test)]
mod tests {
#[test]
fn renders_pixels_and_rejects_unsupported_inputs() {
let (png, w, h) = super::render(r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><path fill="red" d="M0 0H20V10H0Z"/></svg>"#).unwrap();
assert_eq!((w, h), (20, 10));
let image = resvg::tiny_skia::Pixmap::decode_png(&png).unwrap();
assert_eq!(image.pixel(5, 5).unwrap().red(), 255);
for body in ["<text>Hello</text>", "<image href='file:///etc/passwd'/>", "<use href='other.svg#x'/>"] {
assert!(super::render(&format!("<svg xmlns='http://www.w3.org/2000/svg' width='20' height='10'>{body}</svg>")).is_err());
}
assert!(super::render("not SVG").is_err());
assert!(super::render("<svg xmlns='http://www.w3.org/2000/svg' width='5000' height='5000'/>").is_err());
}
}
pub fn render(source: &str) -> Result<(Vec<u8>, u32, u32), String> {
if source.len() > 1024 * 1024 { return Err("SVG exceeds 1 MiB".into()); }
let doc = roxmltree::Document::parse(source).map_err(|e| e.to_string())?;
for node in doc.descendants().filter(|n| n.is_element()) {
if matches!(node.tag_name().name(), "text" | "image" | "feImage" | "foreignObject" | "script") {
return Err("vector-only example: text, images, scripts and foreignObject are unsupported; convert text to paths".into());
}
for attr in node.attributes() {
if attr.name() == "href" && !attr.value().starts_with('#') {
return Err("external resource references are unsupported".into());
}
}
}
let options = resvg::usvg::Options {
image_href_resolver: resvg::usvg::ImageHrefResolver {
resolve_data: Box::new(|_, _, _| None),
resolve_string: Box::new(|_, _| None),
},
..Default::default()
};
let tree = resvg::usvg::Tree::from_str(source, &options).map_err(|e| e.to_string())?;
let size = tree.size().to_int_size();
if u64::from(size.width()) * u64::from(size.height()) > 4_000_000 {
return Err("SVG exceeds four million pixels".into());
}
let mut image = resvg::tiny_skia::Pixmap::new(size.width(), size.height()).ok_or("cannot allocate image")?;
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut image.as_mut());
Ok((image.encode_png().map_err(|e| e.to_string())?, size.width(), size.height()))
}svg-renderer/src/wasm.rs
use serde::Deserialize;
#[link(wasm_import_module = "kcode")]
unsafe extern "C" {
fn register_tool(np: *const u8, nl: usize, dp: *const u8, dl: usize);
fn set_invoke_result(p: *const u8, n: usize, error: i32);
fn fs_read(p: *const u8, n: usize) -> u64;
fn fs_write(pp: *const u8, pn: usize, bp: *const u8, bn: usize);
}
#[unsafe(no_mangle)]
pub extern "C" fn __alloc(n: usize) -> *mut u8 {
Box::into_raw(vec![0u8; n].into_boxed_slice()) as *mut u8
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn __free(p: *mut u8, n: usize) {
unsafe { drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(p, n))); }
}
#[unsafe(no_mangle)]
pub extern "C" fn register() {
let name = "render_svg";
let desc = "Render a local vector-only SVG to PNG. Arguments: source (project-relative SVG path), output (project-relative .png path within out/). Rejects text/images/external resources, SVG over 1 MiB or four million pixels. Existing output is overwritten. Returns output, width, height, size_bytes.";
unsafe { register_tool(name.as_ptr(), name.len(), desc.as_ptr(), desc.len()); }
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Args { source: String, output: String }
fn run(bytes: &[u8]) -> Result<Vec<u8>, String> {
let args: Args = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
if args.source.is_empty() || !args.output.ends_with(".png") { return Err("source is required and output must end in .png".into()); }
let packed = unsafe { fs_read(args.source.as_ptr(), args.source.len()) };
let p = (packed >> 32) as u32 as *mut u8;
let n = packed as u32 as usize;
let source = unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(p, n)) };
let text = std::str::from_utf8(&source).map_err(|e| e.to_string())?;
let (png, width, height) = super::render(text)?;
unsafe { fs_write(args.output.as_ptr(), args.output.len(), png.as_ptr(), png.len()); }
serde_json::to_vec(&serde_json::json!({"output":args.output,"width":width,"height":height,"size_bytes":png.len()})).map_err(|e|e.to_string())
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn invoke(_np: *const u8, _nl: usize, ap: *const u8, al: usize) {
let result = run(unsafe { std::slice::from_raw_parts(ap, al) });
let (bytes, error) = match result { Ok(bytes) => (bytes, 0), Err(e) => (e.into_bytes(), 1) };
unsafe { set_invoke_result(bytes.as_ptr(), bytes.len(), error); }
}