Compare commits

...

12 Commits

Author SHA1 Message Date
5bd7b10904 readme update 2026-03-29 21:49:03 +02:00
e64a0c4bf9 docs: update README with usage instructions and examples for text generation 2026-03-23 03:01:06 +01:00
797714d1e1 feat(exporters): implement PNG image exporter with grid and legend rendering
- Added PngExporter struct to generate PNG images from voxel grids.
- Implemented image dimensions calculation, drawing primitives, and legend rendering.
- Integrated PNG export functionality into the CLI with a new --cell-size flag.
- Removed outdated design spec and implementation plan documents.
2026-03-23 02:59:11 +01:00
4842000c31 feat(cli): add --cell-size flag, route png through build_png 2026-03-23 02:47:58 +01:00
87d49f8959 fix(exporters): scale legend text with cell_size, extract layer_block_height helper 2026-03-23 02:46:03 +01:00
29cfe8e801 feat(exporters): implement full PNG grid + legend rendering 2026-03-23 02:43:30 +01:00
037dcf82a4 fix(exporters): saturating cast in draw_text 2026-03-23 02:41:49 +01:00
01f64caea1 feat(exporters): bitmap font, colors, drawing primitives 2026-03-23 02:37:31 +01:00
56c53d7f53 feat(exporters): implement image_dimensions with TDD 2026-03-23 02:33:22 +01:00
0d0627b223 feat(exporters): scaffold PngExporter 2026-03-23 02:30:11 +01:00
4e742d9ebc docs: add png image exporter implementation plan 2026-03-23 02:25:52 +01:00
d40441da55 docs: address spec review issues in png exporter design 2026-03-23 02:13:10 +01:00
10 changed files with 620 additions and 136 deletions

86
Cargo.lock generated
View File

@@ -89,12 +89,36 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "cesu8"
version = "1.1.0"
@@ -169,8 +193,10 @@ dependencies = [
"anyhow",
"fastnbt",
"flate2",
"image",
"lib",
"serde",
"serde_json",
"thiserror",
"tracing",
]
@@ -187,6 +213,15 @@ dependencies = [
"serde_bytes",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "flate2"
version = "1.1.9"
@@ -203,6 +238,19 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -278,6 +326,16 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -287,6 +345,15 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -314,6 +381,19 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -323,6 +403,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "quote"
version = "1.0.45"

View File

@@ -2,3 +2,61 @@
This little project is a tool that takes a string of text and generates minecraft structure that represents given text in a font of your choice. User can choose blocks to use (or define their own palette) or just use preset.
Exported structure can be in various formats, such as .schematic, .litematic or .nbt, or just .mcfunction file with setblock commands.
## Usage
### Build
```bash
cargo build --release
```
The binary is at `target/release/minecraft-text-generator`.
### Generate text
```bash
minecraft-text-generator -t "Hello World" -f path/to/font.ttf
```
Outputs `output.mcfunction` by default.
### Common flags
| Flag | Default | Description |
|------|---------|-------------|
| `-t, --text` | *(required)* | Text to generate |
| `-f, --font` | *(required)* | Path to `.ttf` font file |
| `--format` | `mcfunction` | Output format(s): `mcfunction`, `litematica`, `png`, `all` |
| `-o, --out` | `output` | Output file path (without extension) |
| `--size` | `16.0` | Text height in blocks |
| `-d, --depth` | `1` | How many blocks deep |
| `-p, --palette` | `default` | Built-in palette preset name |
| `--palette-file` | — | Path to a custom JSON palette file |
| `--shadow` | off | Add a drop shadow |
| `--shadow-x` / `--shadow-y` | `1` / `-1` | Shadow offset in blocks |
| `--outline-mode` | `8` | Outline connectivity: `4` (cardinal) or `8` (with corners) |
| `--letter-spacing` | font default | Extra blocks between characters |
| `--word-spacing` | `4` | Blocks wide for a space character |
| `--cell-size` | `16` | Pixel size per block for PNG export (864) |
### List available palettes
```bash
minecraft-text-generator --list-palettes
```
### Examples
```bash
# Litematica schematic with a shadow
minecraft-text-generator -t "HELLO" -f fonts/myfont.ttf --format litematica --shadow
# PNG preview at 32px per block
minecraft-text-generator -t "HELLO" -f fonts/myfont.ttf --format png --cell-size 32
# All formats at once, custom palette
minecraft-text-generator -t "HELLO" -f fonts/myfont.ttf --format all -p stone
```
![showcase](minecraft_world.png)

View File

@@ -65,6 +65,10 @@ struct Cli {
/// Width in blocks inserted for a space character
#[arg(long, default_value_t = 4)]
word_spacing: u32,
/// 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,
}
fn palettes_dir() -> PathBuf {
@@ -160,12 +164,20 @@ pub fn run() -> anyhow::Result<()> {
.filter(|&name| seen.insert(name))
.collect();
// Build and validate in one pass
// 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<_> {
.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(", ")))
.ok_or_else(|| anyhow::anyhow!(
"unknown format '{}'. Available: {}",
name,
all_names.join(", ")
))
}
})
.collect::<anyhow::Result<Vec<_>>>()?;

View File

@@ -5,6 +5,7 @@ edition = "2024"
[dependencies]
lib = { path = "../lib" }
image = { version = "0.25", default-features = false, features = ["png"] }
fastnbt = "2.6.1"
flate2 = "1.1.9"
anyhow = { workspace = true }

View File

@@ -0,0 +1,448 @@
use image::{ImageFormat, Rgba, RgbaImage};
use lib::{BlockPalette, StructureExporter, VoxelGrid, VoxelType};
const GAP: u32 = 1;
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',
}
}
/// 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
__, __, __, __, __,
]
};
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).saturating_mul(char_w), y, ch, scale, color);
}
}
// Returns VoxelTypes that (1) are configured in the palette AND (2) appear in the grid.
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
}
fn layer_block_height(grid: &VoxelGrid, cell_size: u32) -> u32 {
let label_height = cell_size + 4;
let per_layer_height = if grid.height == 0 {
0
} else {
grid.height * (cell_size + GAP) - GAP
};
label_height + GAP + per_layer_height
}
pub fn image_dimensions(grid: &VoxelGrid, palette: &BlockPalette, cell_size: u32) -> (u32, u32) {
let layer_block_height = layer_block_height(grid, cell_size);
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)
}
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 }
}
}
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);
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);
draw_rect(img, GAP, y, cell_size, cell_size, color);
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);
let block_name = palette.resolve(vt);
let text_x = GAP + cell_size + GAP * 3;
let text_y = y + (cell_size.saturating_sub(7 * lscale)) / 2;
draw_text(img, text_x, text_y, &block_name, lscale, COLOR_TEXT);
}
}
impl StructureExporter for PngExporter {
fn export(&self, grid: &VoxelGrid) -> anyhow::Result<Vec<u8>> {
use std::io::Cursor;
let cs = self.cell_size;
let lbh = layer_block_height(grid, cs);
let label_height = cs + 4;
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 * (lbh + 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 {
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);
}
}
}
}
}
let total_grid_height = grid.depth * lbh
+ 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())
}
fn file_extension(&self) -> &'static str {
"png"
}
}
#[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 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');
}
#[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 (BUT: all-None grid → 0 active types → legend_height=0)
// total_h = 71 + 0 = 71
assert_eq!(h, 71);
}
#[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);
}
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());
}
}

View File

@@ -1,8 +1,10 @@
mod image_export;
mod litematica;
mod mcfunction;
use lib::{BlockPalette, StructureExporter};
pub use image_export::PngExporter;
pub use litematica::LitematicaExporter;
pub use mcfunction::McFunctionExporter;
@@ -11,12 +13,18 @@ type ExporterFactory = fn(&BlockPalette) -> Box<dyn StructureExporter>;
const REGISTRY: &[(&str, ExporterFactory)] = &[
("mcfunction", |p| Box::new(McFunctionExporter::new(p))),
("litematica", |p| Box::new(LitematicaExporter::new(p))),
// cell_size=16 default; CLI uses build_png() to honour --cell-size
("png", |p| Box::new(PngExporter::new(p, 16))),
];
pub fn available_names() -> Vec<&'static str> {
REGISTRY.iter().map(|(name, _)| *name).collect()
}
pub fn build_png(palette: &BlockPalette, cell_size: u32) -> Box<dyn StructureExporter> {
Box::new(PngExporter::new(palette, cell_size))
}
pub fn build(name: &str, palette: &BlockPalette) -> Option<Box<dyn StructureExporter>> {
REGISTRY.iter()
.find(|(n, _)| *n == name)

View File

@@ -37,7 +37,7 @@ pub struct Metadata {
#[serde(rename_all = "PascalCase")]
pub struct Region {
pub block_state_palette: Vec<BlockState>,
pub block_states: Vec<i64>,
pub block_states: fastnbt::LongArray,
pub position: Vector3,
pub size: Vector3,
}
@@ -144,7 +144,7 @@ impl StructureExporter for LitematicaExporter {
"TextRegion".to_string(),
Region {
block_state_palette: palette_list,
block_states: packed_states,
block_states: fastnbt::LongArray::new(packed_states),
position: Vector3 { x: 0, y: 0, z: 0 },
size: dimensions,
},

View File

@@ -13,6 +13,7 @@ pub use fonts::ttf_font::TtfFont;
pub use grid::VoxelGrid;
pub use models::VoxelType;
pub use palette::BlockPalette;
pub use palette::PaletteMappings;
pub trait StructureExporter {
fn export(&self, grid: &VoxelGrid) -> anyhow::Result<Vec<u8>>;

View File

@@ -1,130 +0,0 @@
# PNG Image Exporter — Design Spec
**Date:** 2026-03-23
**Status:** Approved
## Problem
Users generating Minecraft text structures need a visual reference showing which blocks to place and where. Current exporters (mcfunction, litematica) produce machine-readable files; there is no human-readable image guide.
## Solution
Add a `"png"` format to the exporter registry. The output is a PNG image with a color-coded grid — one cell per block position — plus a legend mapping colors to Minecraft block names.
## Visual Design
### Cell style
- Each cell: `cell_size × cell_size` pixels with 1px gaps between cells (gaps act as grid lines)
- Filled cells show a letter code in the center (B = Body, O = Outline, S = Shadow)
- Empty/air positions: dark background color, no letter
### Colors
| VoxelType | Color | Hex | Letter |
|-----------|-----------|-----------|--------|
| Body | Blue | `#4a90d9` | B |
| Outline | Gray | `#888888` | O |
| Shadow | Tan/gold | `#c0a060` | S |
| Empty | Dark | `#222222` | — |
| Background| Dark | `#222222` | — |
### Depth handling
All z-layers are stacked vertically in one PNG. Each layer has a "Layer N" header row above its grid.
### Legend
Below all layers: one row per active VoxelType showing a color swatch, the letter code, and the full Minecraft block name (e.g. `B minecraft:stone`).
## CLI Interface
New flag added to `crates/bin`:
```
--cell-size <PIXELS> Cell size in pixels for PNG export (16 or 32) [default: 16]
```
The `--format png` flag selects the image exporter. `--format all` includes PNG at 16px (default).
## Architecture
### New file: `crates/exporters/src/image_export.rs`
```rust
pub struct PngExporter {
palette: BlockPalette,
cell_size: u32,
}
impl PngExporter {
pub fn new(palette: &BlockPalette, cell_size: u32) -> Self
}
impl StructureExporter for PngExporter {
fn export(&self, grid: &VoxelGrid) -> anyhow::Result<Vec<u8>>;
fn file_extension(&self) -> &'static str; // "png"
}
```
Private helpers:
- `image_dimensions(grid, cell_size) -> (u32, u32)` — pure dimension calculation
- `draw_cell(img, px, py, cell_size, color)` — fill a rectangle
- `draw_letter(img, px, py, cell_size, ch, color)` — render one char from embedded bitmap font
- `draw_layer_label(img, y, z_index, cell_size)` — "Layer N" header row
- `draw_text_row(img, x, y, text, cell_size)` — render a text string
- `draw_legend(img, y_start, palette, cell_size)` — legend section
### Text rendering
Letters are rendered using an embedded 5×7 bitmap font (`const FONT_5X7: [[u8; 7]; 128]`). Each font pixel is scaled by `cell_size / 8` (so 2× at 16px, 4× at 32px). No external font files or font-rendering crates required.
### Registry integration
`crates/exporters/src/lib.rs` changes:
- Register `("png", |p| Box::new(PngExporter::new(p, 16)))` — enables `--format all`
- Add `pub fn build_png(palette: &BlockPalette, cell_size: u32) -> Box<dyn StructureExporter>` — used by CLI for user-specified cell size
The generic `ExporterFactory = fn(&BlockPalette) -> Box<dyn StructureExporter>` signature is unchanged. No changes to `McFunctionExporter` or `LitematicaExporter`.
### CLI dispatch (`crates/bin/src/cli.rs`)
When `"png"` appears in `format_names`, the CLI calls `exporters::build_png(&palette, cli.cell_size)` directly (to honour `--cell-size`) rather than the generic registry path.
### Image layout (per-pixel coordinates)
```
gap = 1
layer_block_height = (cell_size + 4) + gap + height * (cell_size + gap) - gap
total_grid_height = depth * layer_block_height + (depth - 1) * gap
img_width = gap + width * (cell_size + gap)
img_height = total_grid_height + gap + legend_height
```
Row y=0 in `VoxelGrid` is the bottom row; the image renders row `height-1-y` from the top so text reads naturally top-to-bottom.
## Dependencies
Only `crates/exporters/Cargo.toml` gains a new dependency:
```toml
image = { version = "0.25", default-features = false, features = ["png"] }
```
No changes to `crates/lib` or `crates/bin` dependencies.
## Tests
All in `crates/exporters/src/image_export.rs`:
1. `dimensions_single_layer` — formula correct for 1 layer
2. `dimensions_multi_layer` — height increases per additional layer
3. `export_produces_valid_png` — output starts with PNG magic bytes `[137, 80, 78, 71, ...]`
4. `export_empty_grid_no_panic` — all-air grid produces valid output
5. `export_multi_layer_larger_than_single` — 3-layer > 1-layer byte count
6. `letter_for_each_voxel_type` — B/O/S mapping is correct
## Verification
```bash
cargo build
cargo test -p exporters
cargo run -- --text "Hi" --font /path/to/font.ttf --format png --cell-size 32 --out test_out
# Verify: test_out.png is a valid image showing the block grid + legend
```

BIN
minecraft_world.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB