Files
minecraft-text-generator/docs/superpowers/plans/2026-03-23-png-image-exporter.md

791 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PNG Image Exporter Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `--format png` that generates a color-coded block grid image with B/O/S letter codes in each cell, all z-layers stacked vertically, and a legend mapping colors to block names.
**Architecture:** New `PngExporter` in `crates/exporters` implements `StructureExporter`. Renders via `image` crate (`RgbaImage` pixel ops) using an embedded 5×7 bitmap font — no external font files. CLI routes `"png"` through a `build_png(palette, cell_size)` factory to honor `--cell-size`.
**Tech Stack:** Rust, `image = "0.25"` (PNG via `RgbaImage`), `clap` (CLI flag)
---
### Task 1: Scaffold + dependency
**Files:**
- Modify: `crates/exporters/Cargo.toml`
- Create: `crates/exporters/src/image_export.rs`
- Modify: `crates/exporters/src/lib.rs`
- Modify: `crates/lib/src/lib.rs`
- [ ] **Step 1: Export `PaletteMappings` from lib**
`image_export.rs` needs to inspect `palette.blocks.outline: Option<String>`. Currently `PaletteMappings` is not re-exported. Add to `crates/lib/src/lib.rs`:
```rust
pub use palette::PaletteMappings;
```
- [ ] **Step 2: Add `image` crate**
In `crates/exporters/Cargo.toml`, under `[dependencies]`:
```toml
image = { version = "0.25", default-features = false, features = ["png"] }
```
- [ ] **Step 3: Create stub module**
Create `crates/exporters/src/image_export.rs`:
```rust
use lib::{BlockPalette, StructureExporter, VoxelGrid, VoxelType};
pub struct PngExporter {
palette: BlockPalette,
cell_size: u32,
}
impl PngExporter {
pub fn new(palette: &BlockPalette, cell_size: u32) -> Self {
Self { palette: palette.clone(), cell_size }
}
}
impl StructureExporter for PngExporter {
fn export(&self, _grid: &VoxelGrid) -> anyhow::Result<Vec<u8>> {
todo!("png export not yet implemented")
}
fn file_extension(&self) -> &'static str {
"png"
}
}
```
- [ ] **Step 4: Register in `crates/exporters/src/lib.rs`**
Add at the top:
```rust
mod image_export;
pub use image_export::PngExporter;
```
Add to `REGISTRY`:
```rust
("png", |p| Box::new(PngExporter::new(p, 16))),
```
Add after the `build` function:
```rust
pub fn build_png(palette: &BlockPalette, cell_size: u32) -> Box<dyn StructureExporter> {
Box::new(PngExporter::new(palette, cell_size))
}
```
- [ ] **Step 5: Verify it compiles**
```bash
cargo check
```
Expected: clean (the `todo!()` is fine)
- [ ] **Step 6: Commit scaffold**
```bash
git add crates/lib/src/lib.rs crates/exporters/Cargo.toml crates/exporters/src/image_export.rs crates/exporters/src/lib.rs
git commit -m "feat(exporters): scaffold PngExporter"
```
---
### Task 2: Dimension calculation (TDD)
**Files:**
- Modify: `crates/exporters/src/image_export.rs`
- [ ] **Step 1: Write failing dimension tests**
Add to `image_export.rs`:
```rust
const GAP: u32 = 1;
// Returns VoxelTypes that (1) are configured in the palette AND (2) appear in the grid.
// This matches the spec's definition of "active" for legend entries.
fn active_voxel_types(palette: &BlockPalette, grid: &VoxelGrid) -> Vec<VoxelType> {
let mut candidates = vec![VoxelType::Body];
if palette.blocks.outline.is_some() {
candidates.push(VoxelType::Outline);
}
if palette.blocks.shadow.is_some() {
candidates.push(VoxelType::Shadow);
}
candidates.retain(|&vt| {
(0..grid.depth).any(|z|
(0..grid.height).any(|y|
(0..grid.width).any(|x| grid.get(x, y, z) == Some(vt))
)
)
});
candidates
}
pub fn image_dimensions(grid: &VoxelGrid, palette: &BlockPalette, cell_size: u32) -> (u32, u32) {
todo!()
}
#[cfg(test)]
mod tests {
use super::*;
fn palette_full() -> BlockPalette {
serde_json::from_str(r#"{
"name": "test",
"blocks": {
"body": "minecraft:stone",
"outline": "minecraft:cobblestone",
"shadow": "minecraft:gravel"
}
}"#).unwrap()
}
fn palette_body_only() -> BlockPalette {
serde_json::from_str(r#"{
"name": "test",
"blocks": { "body": "minecraft:stone" }
}"#).unwrap()
}
#[test]
fn dimensions_single_layer() {
let grid = VoxelGrid::new(4, 3, 1);
let palette = palette_full();
let (w, h) = image_dimensions(&grid, &palette, 16);
// width: 1 + 4*(16+1) = 69
assert_eq!(w, 69);
// label_height = 20, per_layer = 3*17-1 = 50, layer_block = 71
// total_grid = 71
// legend = 3*17 + 1 = 52
// total_h = 71 + 1 + 52 = 124
assert_eq!(h, 124);
}
#[test]
fn dimensions_multi_layer() {
let g1 = VoxelGrid::new(4, 3, 1);
let g3 = VoxelGrid::new(4, 3, 3);
let palette = palette_full();
let (_, h1) = image_dimensions(&g1, &palette, 16);
let (_, h3) = image_dimensions(&g3, &palette, 16);
assert!(h3 > h1);
}
}
```
- [ ] **Step 2: Run to confirm they fail**
```bash
cargo test -p exporters 2>&1 | head -10
```
Expected: `panicked at 'not yet implemented'`
- [ ] **Step 3: Implement `image_dimensions`**
Replace the `todo!()`:
```rust
pub fn image_dimensions(grid: &VoxelGrid, palette: &BlockPalette, cell_size: u32) -> (u32, u32) {
let label_height = cell_size + 4;
let per_layer_height = if grid.height == 0 {
0
} else {
grid.height * (cell_size + GAP) - GAP
};
let layer_block_height = label_height + GAP + per_layer_height;
let total_grid_height = grid.depth * layer_block_height
+ grid.depth.saturating_sub(1) * GAP;
let active = active_voxel_types(palette, grid);
let legend_height = if active.is_empty() {
0
} else {
active.len() as u32 * (cell_size + GAP) + GAP
};
let img_width = if grid.width == 0 {
1
} else {
GAP + grid.width * (cell_size + GAP)
};
let img_height = total_grid_height
+ if legend_height > 0 { GAP + legend_height } else { 0 };
(img_width, img_height)
}
```
- [ ] **Step 4: Run tests**
```bash
cargo test -p exporters dimensions
```
Expected: both pass
- [ ] **Step 5: Commit**
```bash
git add crates/exporters/src/image_export.rs
git commit -m "feat(exporters): implement image_dimensions with TDD"
```
---
### Task 3: Color constants, font, drawing primitives
**Files:**
- Modify: `crates/exporters/src/image_export.rs`
- [ ] **Step 1: Add color constants and helpers**
Add near the top of `image_export.rs` (after `use` statements):
```rust
use image::{ImageFormat, Rgba, RgbaImage};
const COLOR_BODY: [u8; 4] = [0x4A, 0x90, 0xD9, 0xFF];
const COLOR_OUTLINE: [u8; 4] = [0x88, 0x88, 0x88, 0xFF];
const COLOR_SHADOW: [u8; 4] = [0xC0, 0xA0, 0x60, 0xFF];
const COLOR_BG: [u8; 4] = [0x22, 0x22, 0x22, 0xFF];
const COLOR_TEXT: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF];
fn voxel_color(vt: VoxelType) -> [u8; 4] {
match vt {
VoxelType::Body => COLOR_BODY,
VoxelType::Outline => COLOR_OUTLINE,
VoxelType::Shadow => COLOR_SHADOW,
}
}
fn voxel_letter(vt: VoxelType) -> char {
match vt {
VoxelType::Body => 'B',
VoxelType::Outline => 'O',
VoxelType::Shadow => 'S',
}
}
```
- [ ] **Step 2: Add `letter_for_each_voxel_type` test and run it**
In the test module:
```rust
#[test]
fn letter_for_each_voxel_type() {
assert_eq!(voxel_letter(VoxelType::Body), 'B');
assert_eq!(voxel_letter(VoxelType::Outline), 'O');
assert_eq!(voxel_letter(VoxelType::Shadow), 'S');
}
```
```bash
cargo test -p exporters letter_for
```
Expected: passes
- [ ] **Step 3: Add embedded 5×7 bitmap font**
Each `[u8; 7]` entry encodes 7 rows; each `u8` encodes 5 pixels (bit 4 = leftmost col, bit 0 = rightmost). Add to `image_export.rs`:
```rust
/// 5×7 bitmap font indexed by ASCII code. Bit4=left, Bit0=right per row.
static FONT_5X7: [[u8; 7]; 128] = {
const __: [u8; 7] = [0; 7];
[
// 0-31: control chars (blank)
__, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __,
__, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __,
// 32 ' '
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
// 33 '!'
[0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04],
// 34-47: unused (blank)
__, __, __, __, __, __, __, __, __, __, __, __, __, __,
// 48 '0'
[0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
// 49 '1'
[0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
// 50 '2'
[0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
// 51 '3'
[0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E],
// 52 '4'
[0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
// 53 '5'
[0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
// 54 '6'
[0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E],
// 55 '7'
[0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
// 56 '8'
[0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
// 57 '9'
[0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C],
// 58 ':'
[0x00, 0x04, 0x04, 0x00, 0x04, 0x04, 0x00],
// 59-64: blank
__, __, __, __, __, __,
// 65 'A'
[0x04, 0x0A, 0x11, 0x11, 0x1F, 0x11, 0x11],
// 66 'B'
[0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
// 67 'C'
[0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
// 68 'D'
[0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C],
// 69 'E'
[0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
// 70 'F'
[0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
// 71 'G'
[0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F],
// 72 'H'
[0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
// 73 'I'
[0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
// 74 'J'
[0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C],
// 75 'K'
[0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
// 76 'L'
[0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
// 77 'M'
[0x11, 0x1B, 0x15, 0x11, 0x11, 0x11, 0x11],
// 78 'N'
[0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11],
// 79 'O'
[0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
// 80 'P'
[0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
// 81 'Q'
[0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D],
// 82 'R'
[0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
// 83 'S'
[0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E],
// 84 'T'
[0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
// 85 'U'
[0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
// 86 'V'
[0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],
// 87 'W'
[0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11],
// 88 'X'
[0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],
// 89 'Y'
[0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04],
// 90 'Z'
[0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
// 91-94: blank
__, __, __, __,
// 95 '_'
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F],
// 96: blank
__,
// 97 'a'
[0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F],
// 98 'b'
[0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x1E],
// 99 'c'
[0x00, 0x00, 0x0E, 0x10, 0x10, 0x10, 0x0E],
// 100 'd'
[0x01, 0x01, 0x0F, 0x11, 0x11, 0x11, 0x0F],
// 101 'e'
[0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E],
// 102 'f'
[0x06, 0x09, 0x08, 0x1E, 0x08, 0x08, 0x08],
// 103 'g'
[0x00, 0x0F, 0x11, 0x11, 0x0F, 0x01, 0x0E],
// 104 'h'
[0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x11],
// 105 'i'
[0x04, 0x00, 0x0C, 0x04, 0x04, 0x04, 0x0E],
// 106 'j'
[0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0C],
// 107 'k'
[0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12],
// 108 'l'
[0x0C, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
// 109 'm'
[0x00, 0x00, 0x1A, 0x15, 0x15, 0x11, 0x11],
// 110 'n'
[0x00, 0x00, 0x16, 0x19, 0x11, 0x11, 0x11],
// 111 'o'
[0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E],
// 112 'p'
[0x00, 0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10],
// 113 'q'
[0x00, 0x0F, 0x11, 0x11, 0x0F, 0x01, 0x01],
// 114 'r'
[0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x10],
// 115 's'
[0x00, 0x00, 0x0E, 0x10, 0x0E, 0x01, 0x0E],
// 116 't'
[0x08, 0x08, 0x1E, 0x08, 0x08, 0x09, 0x06],
// 117 'u'
[0x00, 0x00, 0x11, 0x11, 0x11, 0x13, 0x0D],
// 118 'v'
[0x00, 0x00, 0x11, 0x11, 0x11, 0x0A, 0x04],
// 119 'w'
[0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A],
// 120 'x'
[0x00, 0x00, 0x11, 0x0A, 0x04, 0x0A, 0x11],
// 121 'y'
[0x00, 0x11, 0x11, 0x0F, 0x01, 0x11, 0x0E],
// 122 'z'
[0x00, 0x00, 0x1F, 0x02, 0x04, 0x08, 0x1F],
// 123-127: blank
__, __, __, __, __,
]
};
```
- [ ] **Step 4: Add drawing helpers**
```rust
fn draw_rect(img: &mut RgbaImage, x: u32, y: u32, w: u32, h: u32, color: [u8; 4]) {
let c = Rgba(color);
for dy in 0..h {
for dx in 0..w {
let px = x + dx;
let py = y + dy;
if px < img.width() && py < img.height() {
img.put_pixel(px, py, c);
}
}
}
}
/// Render one char at (x, y). Each font pixel = scale×scale actual pixels.
fn draw_char(img: &mut RgbaImage, x: u32, y: u32, ch: char, scale: u32, color: [u8; 4]) {
let code = ch as usize;
if code >= 128 { return; }
let glyph = FONT_5X7[code];
for (row, &bits) in glyph.iter().enumerate() {
for col in 0..5u32 {
if bits & (1 << (4 - col)) != 0 {
draw_rect(img, x + col * scale, y + row as u32 * scale, scale, scale, color);
}
}
}
}
/// Render a string left-to-right. Each char is 6*scale px wide (5px + 1px gap).
fn draw_text(img: &mut RgbaImage, x: u32, y: u32, text: &str, scale: u32, color: [u8; 4]) {
let char_w = 6 * scale;
for (i, ch) in text.chars().enumerate() {
draw_char(img, x + i as u32 * char_w, y, ch, scale, color);
}
}
```
- [ ] **Step 5: Run all tests**
```bash
cargo test -p exporters
```
Expected: 3 passing (dimensions ×2, letter_for)
- [ ] **Step 6: Commit**
```bash
git add crates/exporters/src/image_export.rs
git commit -m "feat(exporters): bitmap font, colors, drawing primitives"
```
---
### Task 4: Full export implementation (TDD)
**Files:**
- Modify: `crates/exporters/src/image_export.rs`
- [ ] **Step 1: Write the 4 export tests**
Add to the test module:
```rust
fn make_grid_with_voxels(w: u32, h: u32, d: u32) -> VoxelGrid {
let mut grid = VoxelGrid::new(w, h, d);
let _ = grid.set(0, 0, 0, VoxelType::Body);
let _ = grid.set(1, 0, 0, VoxelType::Outline);
if d > 1 { let _ = grid.set(0, 0, 1, VoxelType::Body); }
grid
}
#[test]
fn export_produces_valid_png() {
let grid = make_grid_with_voxels(3, 3, 1);
let bytes = PngExporter::new(&palette_full(), 16).export(&grid).unwrap();
assert_eq!(&bytes[..8], &[137u8, 80, 78, 71, 13, 10, 26, 10]);
}
#[test]
fn export_empty_grid_no_panic() {
let grid = VoxelGrid::new(5, 5, 1); // all None
let bytes = PngExporter::new(&palette_full(), 16).export(&grid).unwrap();
assert!(!bytes.is_empty());
}
#[test]
fn export_multi_layer_larger_than_single() {
let g1 = make_grid_with_voxels(4, 4, 1);
let g3 = make_grid_with_voxels(4, 4, 3);
let b1 = PngExporter::new(&palette_full(), 16).export(&g1).unwrap();
let b3 = PngExporter::new(&palette_full(), 16).export(&g3).unwrap();
assert!(b3.len() > b1.len());
}
#[test]
fn export_palette_with_no_outline() {
let mut grid = VoxelGrid::new(3, 3, 1);
let _ = grid.set(0, 0, 0, VoxelType::Outline);
let bytes = PngExporter::new(&palette_body_only(), 16).export(&grid).unwrap();
assert!(!bytes.is_empty());
}
```
- [ ] **Step 2: Run to confirm they fail**
```bash
cargo test -p exporters export 2>&1 | grep -E "FAILED|panicked"
```
Expected: all 4 fail with `not yet implemented`
- [ ] **Step 3: Implement layer label and legend helpers**
Add to `image_export.rs`:
```rust
fn draw_layer_label(img: &mut RgbaImage, y: u32, z: u32, cell_size: u32) {
let label = format!("Layer {}", z);
let scale = (cell_size / 8).max(1);
let label_height = cell_size + 4;
let text_h = 7 * scale;
let text_y = y + (label_height.saturating_sub(text_h)) / 2;
draw_text(img, GAP, text_y, &label, scale, COLOR_TEXT);
}
fn draw_legend(img: &mut RgbaImage, y_start: u32, palette: &BlockPalette, grid: &VoxelGrid, cell_size: u32) {
let lscale = (cell_size / 8).max(1); // letter scale inside swatch
for (i, vt) in active_voxel_types(palette, grid).iter().enumerate() {
let y = y_start + i as u32 * (cell_size + GAP);
let color = voxel_color(*vt);
// Color swatch
draw_rect(img, GAP, y, cell_size, cell_size, color);
// Letter centered in swatch
let lw = 5 * lscale;
let lh = 7 * lscale;
let lx = GAP + (cell_size.saturating_sub(lw)) / 2;
let ly = y + (cell_size.saturating_sub(lh)) / 2;
draw_char(img, lx, ly, voxel_letter(*vt), lscale, COLOR_TEXT);
// `BlockPalette::resolve(&VoxelType) -> String` is defined in crates/lib/src/palette.rs.
// It returns the block name string (e.g. "minecraft:stone") or "minecraft:air" for None fields.
let block_name = palette.resolve(vt);
let text_x = GAP + cell_size + GAP * 3;
let text_y = y + (cell_size.saturating_sub(7)) / 2;
draw_text(img, text_x, text_y, &block_name, 1, COLOR_TEXT);
}
}
```
- [ ] **Step 4: Implement `export`**
Replace `todo!()` in the `StructureExporter` impl:
```rust
fn export(&self, grid: &VoxelGrid) -> anyhow::Result<Vec<u8>> {
use std::io::Cursor;
let cs = self.cell_size;
let label_height = cs + 4;
let per_layer_height = if grid.height == 0 {
0
} else {
grid.height * (cs + GAP) - GAP
};
let layer_block_height = label_height + GAP + per_layer_height;
let (img_w, img_h) = image_dimensions(grid, &self.palette, cs);
let mut img = RgbaImage::from_pixel(img_w, img_h, Rgba(COLOR_BG));
for z in 0..grid.depth {
let layer_top_y = z * (layer_block_height + GAP);
draw_layer_label(&mut img, layer_top_y, z, cs);
let grid_top_y = layer_top_y + label_height + GAP;
for y in 0..grid.height {
// y=0 in VoxelGrid is the bottom row; flip for image (top-down)
let vy = grid.height - 1 - y;
for x in 0..grid.width {
let px = GAP + x * (cs + GAP);
let py = grid_top_y + y * (cs + GAP);
match grid.get(x, vy, z) {
None => draw_rect(&mut img, px, py, cs, cs, COLOR_BG),
Some(vt) => {
draw_rect(&mut img, px, py, cs, cs, voxel_color(vt));
let lscale = (cs / 8).max(1);
let lw = 5 * lscale;
let lh = 7 * lscale;
let lx = px + (cs.saturating_sub(lw)) / 2;
let ly = py + (cs.saturating_sub(lh)) / 2;
draw_char(&mut img, lx, ly, voxel_letter(vt), lscale, COLOR_TEXT);
}
}
}
}
}
// Legend below all layers
let total_grid_height = grid.depth * layer_block_height
+ grid.depth.saturating_sub(1) * GAP;
draw_legend(&mut img, total_grid_height + GAP, &self.palette, grid, cs);
let mut buf = Cursor::new(Vec::new());
img.write_to(&mut buf, ImageFormat::Png)
.map_err(|e| anyhow::anyhow!("PNG encode failed: {}", e))?;
Ok(buf.into_inner())
}
```
- [ ] **Step 5: Run all tests**
```bash
cargo test -p exporters
```
Expected: all 7 tests pass
- [ ] **Step 6: Commit**
```bash
git add crates/exporters/src/image_export.rs
git commit -m "feat(exporters): implement full PNG grid + legend rendering"
```
---
### Task 5: CLI integration
**Files:**
- Modify: `crates/bin/src/cli.rs`
- [ ] **Step 1: Add `--cell-size` flag to `Cli` struct**
In `crates/bin/src/cli.rs`, add to the `Cli` struct (after `word_spacing`):
```rust
/// Cell size in pixels for PNG export (8-64)
#[arg(long, default_value_t = 16, value_parser = clap::value_parser!(u32).range(8..=64))]
cell_size: u32,
```
- [ ] **Step 2: Route `"png"` through `build_png`**
In `run()`, find the `// Build and validate in one pass` comment and replace the existing `exporters_to_run` collection with:
```rust
// Build and validate in one pass; route "png" through build_png to honour --cell-size
let exporters_to_run = format_names
.iter()
.map(|&name| -> anyhow::Result<Box<dyn lib::StructureExporter>> {
if name == "png" {
Ok(exporters::build_png(&palette, cli.cell_size))
} else {
exporters::build(name, &palette)
.ok_or_else(|| anyhow::anyhow!(
"unknown format '{}'. Available: {}",
name,
all_names.join(", ")
))
}
})
.collect::<anyhow::Result<Vec<_>>>()?;
```
The `for` loop that writes files stays unchanged.
Note: you may need to add `use lib::StructureExporter;` at the top of `cli.rs` if the explicit trait bound `Box<dyn lib::StructureExporter>` requires it. Check the import list — if `lib::StructureExporter` is not imported, add it alongside the existing `lib::` imports.
- [ ] **Step 3: Build**
```bash
cargo build
```
Expected: clean build
- [ ] **Step 4: Smoke test — basic PNG generation**
```bash
# Find a TTF font available on your system:
# fc-list | grep -i ttf | head -5
# Then substitute the path below:
cargo run -- --text "Hi" --font /usr/share/fonts/TTF/DejaVuSans.ttf \
--format png --cell-size 32 --out /tmp/test_hi
```
Expected: `/tmp/test_hi.png` created. Open it — should show "Layer 0" header, a grid of colored cells with B/O letters, and a legend below.
- [ ] **Step 5: Test `--format all`**
```bash
# Use the same font path from Step 4
cargo run -- --text "Hi" --font /usr/share/fonts/TTF/DejaVuSans.ttf \
--format all --cell-size 32 --out /tmp/test_all
```
Expected: `/tmp/test_all.mcfunction`, `/tmp/test_all.litematica`, and `/tmp/test_all.png` all created
- [ ] **Step 6: Test depth > 1**
```bash
cargo run -- --text "Hi" --font /usr/share/fonts/TTF/DejaVuSans.ttf \
--format png --depth 3 --cell-size 16 --out /tmp/test_depth
```
Expected: PNG with 3 stacked "Layer 0", "Layer 1", "Layer 2" sections
- [ ] **Step 7: Commit**
```bash
git add crates/bin/src/cli.rs
git commit -m "feat(cli): add --cell-size flag, route png through build_png"
```
---
### Task 6: Final check
- [ ] **Full test suite**
```bash
cargo test
```
Expected: all tests pass, no regressions
- [ ] **Test shadow (3 legend entries)**
```bash
cargo run -- --text "Hi" --font /usr/share/fonts/TTF/DejaVuSans.ttf \
--format png --shadow --cell-size 32 --out /tmp/test_shadow
```
Expected: PNG with S (tan/gold) cells and all 3 legend rows (B blue, O gray, S tan)
- [ ] **Test invalid cell-size is rejected**
```bash
cargo run -- --text "Hi" --font /usr/share/fonts/TTF/DejaVuSans.ttf \
--format png --cell-size 4 --out /tmp/x 2>&1 | head -3
```
Expected: clap error about value out of range
- [ ] **Final commit if needed**
```bash
git log --oneline -6
```
Confirm all 6 feature commits are present and clean (Tasks 16 each produce one commit).