Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Multi-slide HTTP API** — `POST /api/render` now accepts
`response_format: "json"` to render every slide of a carousel and return them
as base64 PNG entries with template dimensions, and `slide_index` to pick a
specific slide with the default binary PNG response. Empty slide arrays and
out-of-range indices are rejected with a 400.
- **Inline text markup** — `*bold*`, `_italic_`, and `*color:#hex*...*color*` in
markup-enabled text fields (opt-in via schema `options: ["markup"]`), rendered as
styled `<tspan>` runs with markup-aware line wrapping. Bundled Inter Italic,
Expand Down
37 changes: 36 additions & 1 deletion docs/api/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ Renders an image from a template.
| `template` | string | Yes | Template name (e.g. `"stat-card"`) |
| `data` | object | Yes | Template input data (brand + slides) |
| `scale` | float | No | Scale factor (default: `1.0`) |
| `slide_index` | int | No | Zero-based slide to render with the default `png` format (default: `0`) |
| `response_format` | string | No | `"png"` (default, binary image) or `"json"` (all slides as base64 entries) |

**Example:**

Expand Down Expand Up @@ -71,11 +73,44 @@ Renders an image from a template.
}
```

**Multi-slide example** — render every slide of a carousel as base64 PNGs:

```json
{
"template": "carousel-default",
"response_format": "json",
"scale": 0.5,
"data": {
"brand": {"brand_name": "CodeCora"},
"slides": [
{"eyebrow": "s1", "headline": "Slide One", "body": "first"},
{"eyebrow": "s2", "headline": "Slide Two", "body": "second"}
]
}
}
```

The JSON response contains per-slide base64 PNGs:

```json
{
"template": "carousel-default",
"slides": 2,
"width": 540,
"height": 675,
"data": [
{"index": 0, "png_base64": "iVBORw0KGgo..."},
{"index": 1, "png_base64": "iVBORw0KGgo..."}
]
}
```

### Responses

#### 200 OK

Returns the rendered PNG image.
`response_format: "png"` (default) returns the rendered PNG image. With a
`slide_index`, that specific slide is rendered; without one, the first slide.

| Header | Value |
|--------|-------|
Expand Down
148 changes: 127 additions & 21 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,52 @@ pub struct RenderRequest {
/// Scale factor (default 2.0).
#[serde(default = "default_scale")]
pub scale: f32,
/// Zero-based slide to render when responding with `image/png`.
/// Defaults to the first slide. Ignored by the `json` response format,
/// which renders every slide.
#[serde(default)]
pub slide_index: Option<usize>,
/// Response format: `png` (default, binary image) or `json` (rendered
/// slides as base64 PNG entries with metadata).
#[serde(default)]
pub response_format: Option<ResponseFormat>,
}

/// Response format for POST /api/render.
#[derive(Debug, Clone, Copy, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ResponseFormat {
/// Raw PNG bytes (`image/png`).
Png,
/// JSON envelope with per-slide base64 PNGs (`application/json`).
Json,
}

fn default_scale() -> f32 {
2.0
}

/// One rendered slide in a JSON response.
#[derive(Debug, Serialize)]
pub struct RenderedSlide {
/// Zero-based slide index.
pub index: usize,
/// Base64-encoded PNG bytes.
pub png_base64: String,
}

/// JSON response envelope for `response_format: "json"`.
#[derive(Debug, Serialize)]
pub struct RenderResponse {
pub template: String,
/// Rendered slide count.
pub slides: usize,
/// Dimensions of the template canvas (scale applied).
pub width: u32,
pub height: u32,
pub data: Vec<RenderedSlide>,
}

/// Response for GET /api/health.
#[derive(Debug, Serialize)]
pub struct HealthResponse {
Expand Down Expand Up @@ -185,13 +225,22 @@ async fn render_handler(
State(state): State<Arc<AppState>>,
Json(req): Json<RenderRequest>,
) -> Response {
let format = req.response_format.unwrap_or(ResponseFormat::Png);
log::info!(
"Render request: template={}, slides={}, scale={}",
"Render request: template={}, slides={}, scale={}, format={:?}",
req.template,
req.data.slides.len(),
req.scale
req.scale,
format
);

if req.data.slides.is_empty() {
return error_response(
StatusCode::BAD_REQUEST,
"Input data must contain at least one slide".into(),
);
}

// Load template definition
let tmpl = match template::load_template(&req.template) {
Ok(t) => t,
Expand All @@ -208,32 +257,89 @@ async fn render_handler(
}
};

// Render first slide (API returns single PNG for simplicity).
// Which slides to render: all for json format, one for png format.
let slide_indices: Vec<usize> = match format {
ResponseFormat::Json => (0..req.data.slides.len()).collect(),
ResponseFormat::Png => {
let idx = req.slide_index.unwrap_or(0);
if idx >= req.data.slides.len() {
return error_response(
StatusCode::BAD_REQUEST,
format!(
"slide_index {} out of range (input has {} slide(s))",
idx,
req.data.slides.len()
),
);
}
vec![idx]
}
};

// Blocking work (template IO, resvg, possibly remote image fetches) runs
// on the blocking thread pool so the async runtime is never blocked.
let font_db = state.font_db.clone();
let image_policy = state.image_policy;
let scale = req.scale;
let data = req.data;
let template_id = tmpl.id.clone();
let dims = tmpl.dimensions.clone();
let render_result = tokio::task::spawn_blocking(move || {
render::render_slide_to_png(
&tmpl,
&template_dir,
&req.data,
0,
req.scale,
&state.font_db,
state.image_policy,
)
slide_indices
.into_iter()
.map(|i| {
let png = render::render_slide_to_png(
&tmpl,
&template_dir,
&data,
i,
scale,
&font_db,
image_policy,
)?;
Ok((i, png))
})
.collect::<anyhow::Result<Vec<(usize, Vec<u8>)>>>()
})
.await;

match render_result {
Ok(Ok(png_bytes)) => {
log::info!("Rendered {} bytes of PNG", png_bytes.len());
(
StatusCode::OK,
[(header::CONTENT_TYPE, "image/png")],
png_bytes,
)
.into_response()
}
Ok(Ok(rendered)) => match format {
ResponseFormat::Png => {
let (_, png_bytes) = rendered.into_iter().next().expect("one slide rendered");
log::info!("Rendered {} bytes of PNG", png_bytes.len());
(
StatusCode::OK,
[(header::CONTENT_TYPE, "image/png")],
png_bytes,
)
.into_response()
}
ResponseFormat::Json => {
let slides_json: Vec<RenderedSlide> = rendered
.into_iter()
.map(|(i, png)| RenderedSlide {
index: i,
png_base64: base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&png,
),
})
.collect();
log::info!("Rendered {} slide(s) as JSON", slides_json.len());
(
StatusCode::OK,
Json(RenderResponse {
template: template_id,
slides: slides_json.len(),
width: (dims.width as f32 * scale) as u32,
height: (dims.height as f32 * scale) as u32,
data: slides_json,
}),
)
.into_response()
}
},
Ok(Err(e)) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {e:#}"),
Expand Down
108 changes: 108 additions & 0 deletions tests/api_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,114 @@ fn test_render_default_scale() {
assert!(bytes.len() > 5000, "2x render should be substantial");
}

#[test]
fn test_render_json_multi_slide() {
let url = start_server();
// response_format=json renders ALL slides and returns a JSON envelope
let body = serde_json::json!({
"template": "carousel-default",
"response_format": "json",
"scale": 0.5,
"data": {
"brand": {"brand_name": "Multi Test"},
"slides": [
{"eyebrow": "s1", "headline": "Slide One", "body": "first"},
{"eyebrow": "s2", "headline": "Slide Two", "body": "second"},
{"eyebrow": "s3", "headline": "Slide Three", "body": "third"}
]
}
});
let resp = http_client()
.post(format!("{}/api/render", url))
.json(&body)
.send()
.unwrap();
assert_eq!(resp.status(), 200);
let json: serde_json::Value = resp.json().unwrap();
assert_eq!(json["template"], "carousel-default");
assert_eq!(json["slides"], 3);
assert_eq!(json["data"].as_array().unwrap().len(), 3);
for (i, slide) in json["data"].as_array().unwrap().iter().enumerate() {
assert_eq!(slide["index"], i);
let b64 = slide["png_base64"].as_str().unwrap();
assert!(b64.len() > 1000, "slide {} png should be substantial", i);
}
// Slide dimensions: 1080x1350 at 0.5 scale = 540x675
assert_eq!(json["width"], 540);
assert_eq!(json["height"], 675);
}

#[test]
fn test_render_slide_index_png() {
let url = start_server();
// slide_index picks a specific slide with png format (default)
let body = serde_json::json!({
"template": "carousel-default",
"slide_index": 1,
"scale": 0.5,
"data": {
"brand": {"brand_name": "Index Test"},
"slides": [
{"eyebrow": "s1", "headline": "Slide One", "body": "first"},
{"eyebrow": "s2", "headline": "Slide Two", "body": "second"}
]
}
});
let resp = http_client()
.post(format!("{}/api/render", url))
.json(&body)
.send()
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers()["content-type"], "image/png");
let bytes = resp.bytes().unwrap();
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
}

#[test]
fn test_render_slide_index_out_of_range() {
let url = start_server();
let body = serde_json::json!({
"template": "carousel-default",
"slide_index": 5,
"scale": 0.5,
"data": {
"brand": {"brand_name": "Range Test"},
"slides": [
{"eyebrow": "s1", "headline": "Only", "body": "one"}
]
}
});
let resp = http_client()
.post(format!("{}/api/render", url))
.json(&body)
.send()
.unwrap();
assert_eq!(resp.status(), 400);
let json: serde_json::Value = resp.json().unwrap();
assert!(json["error"].as_str().unwrap().contains("out of range"));
}

#[test]
fn test_render_empty_slides_rejected() {
let url = start_server();
let body = serde_json::json!({
"template": "carousel-default",
"data": {"brand": {"brand_name": "Empty"}, "slides": []}
});
let resp = http_client()
.post(format!("{}/api/render", url))
.json(&body)
.send()
.unwrap();
assert_eq!(resp.status(), 400);
let json: serde_json::Value = resp.json().unwrap();
assert!(json["error"]
.as_str()
.unwrap()
.contains("at least one slide"));
}

#[test]
fn test_render_nonexistent_template() {
let url = start_server();
Expand Down
Loading