initial commit

This commit is contained in:
2025-11-22 09:25:56 +01:00
commit 802c44d79b
10 changed files with 1735 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
*.pif

1302
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

11
Cargo.toml Normal file
View File

@ -0,0 +1,11 @@
[package]
name = "papulastic-image-system"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
image = "0.25"
byteorder = "1.5"
chrono = { version = "0.4", features = ["clock"] }
thiserror = "1.0"

42
src/cli.rs Normal file
View File

@ -0,0 +1,42 @@
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(author, version, about = "Custom GFX encoder/decoder")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Encode an image into .pif format
Encode {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(short='a', long, default_value = "UN")]
initials: String,
#[arg(short='a', long, default_value = "TRUEVISION-XFILE")]
signature: String,
},
/// Decode .pif file into PNG
Decode {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
},
/// Print all metadata from a .pif file
Metadata {
#[arg(short, long)]
input: PathBuf,
}
}

139
src/decoder.rs Normal file
View File

@ -0,0 +1,139 @@
use crate::error::{GfxError, Result};
use crate::format::*;
use byteorder::{LittleEndian, ReadBytesExt};
use image::{ImageBuffer, RgbImage};
use std::fs::File;
use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::Path;
pub fn decode(input: &Path, output: &Path) -> Result<()> {
let mut file = File::open(input)?;
let metadata = file.metadata()?;
let file_size = metadata.len();
if file_size < (HEADER_LEN + FOOTER_LEN) as u64 {
return Err(GfxError::Format(format!(
"file too small to be valid ({} bytes)",
file_size
)));
}
// Header
let mut r = BufReader::new(&file);
let color_map = r.read_u8()?;
let img_type = r.read_u8()?;
if color_map != 0 {
return Err(GfxError::InvalidHeader(format!(
"color map not supported: {}",
color_map
)));
}
if img_type != 2 {
return Err(GfxError::Unsupported(img_type));
}
let _cm_first = r.read_u16::<LittleEndian>()?;
let _cm_len = r.read_u16::<LittleEndian>()?;
let _cm_extra = r.read_u8()?;
let width = r.read_u16::<LittleEndian>()? as u32;
let height = r.read_u16::<LittleEndian>()? as u32;
let bpp = r.read_u8()?;
let desc = r.read_u8()?;
if bpp != 24 {
return Err(GfxError::Format(format!(
"only 24bpp supported, got {}",
bpp
)));
}
// Determine origin
let origin_bits = (desc & 0b0011_0000) >> 4;
let origin_top = matches!(origin_bits, 0b10 | 0b11);
let origin_left = matches!(origin_bits, 0b00 | 0b10);
// FOOTER
file.seek(SeekFrom::End(-(FOOTER_LEN as i64)))?;
let mut footer = [0u8; FOOTER_LEN];
file.read_exact(&mut footer)?;
// Footer layout:
// 0-15: signature
// 16: '.'
// 17: 0x00
let signature = &footer[..16];
if signature.starts_with(b"\0\0\0\0") {
return Err(GfxError::Format("footer signature is empty/invalid".into()));
}
if footer[16] != b'.' {
return Err(GfxError::Format("footer missing '.' separator".into()));
}
if footer[17] != 0x00 {
return Err(GfxError::Format("footer missing null terminator".into()));
}
// DATA
let payload_start = HEADER_LEN as u64; // right after header
let payload_end = file_size - FOOTER_LEN as u64;
let payload_len = (payload_end - payload_start) as usize;
let pixel_bytes = (width as usize * height as usize) * 3;
if payload_len < pixel_bytes {
return Err(GfxError::Format("file too small for pixel data".into()));
}
// Read pixels
file.seek(SeekFrom::Start(payload_start))?;
let mut pixel_buf = vec![0u8; pixel_bytes];
file.read_exact(&mut pixel_buf)?;
// Extended data: whatever remains between pixel data and footer
let extended_len = payload_len - pixel_bytes;
let mut extended = vec![0u8; extended_len];
file.read_exact(&mut extended)?;
// Reconstruct
let mut img: RgbImage = ImageBuffer::new(width, height);
let mut idx = 0;
for file_row in 0..height {
let y = if origin_top { file_row } else { (height - 1) - file_row };
if origin_left {
for x in 0..width {
let (r8, g8, b8) = (
pixel_buf[idx + 2],
pixel_buf[idx + 1],
pixel_buf[idx],
);
img.put_pixel(x, y, image::Rgb([r8, g8, b8]));
idx += 3;
}
} else {
for xr in 0..width {
let x = (width - 1) - xr;
let (r8, g8, b8) = (
pixel_buf[idx + 2],
pixel_buf[idx + 1],
pixel_buf[idx],
);
img.put_pixel(x, y, image::Rgb([r8, g8, b8]));
idx += 3;
}
}
}
img.save(output)?;
Ok(())
}

79
src/encoder.rs Normal file
View File

@ -0,0 +1,79 @@
use crate::error::{GfxError, Result};
use crate::format::*;
use chrono::{Local, Datelike, Timelike};
use byteorder::{LittleEndian, WriteBytesExt};
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
pub fn encode(input: &Path, output: &Path, initials: &str, signature: &str, origin: u8) -> Result<()> {
if signature.len() > 15 {
return Err(GfxError::Format("Signature longer than 15 bytes!".into()));
}
let img = image::open(input)?;
let rgb = img.to_rgb8();
let (width, height) = rgb.dimensions();
let mut out = BufWriter::new(File::create(output)?);
// HEADER
out.write_u8(0)?; // no color map
out.write_u8(2)?; // type 2 = truecolor
out.write_u16::<LittleEndian>(0)?;
out.write_u16::<LittleEndian>(0)?;
out.write_u8(0)?;
out.write_u16::<LittleEndian>(width as u16)?;
out.write_u16::<LittleEndian>(height as u16)?;
out.write_u8(24)?; // bits per pixel
out.write_u8(origin)?; // <-- NOW DYNAMIC
// PIXELS — HANDLE ALL ORIGINS
let xs: Vec<u32> = match origin {
ORIGIN_BOTTOM_LEFT | ORIGIN_TOP_LEFT => (0..width).collect(),
ORIGIN_BOTTOM_RIGHT | ORIGIN_TOP_RIGHT => (0..width).rev().collect(),
_ => return Err(GfxError::InvalidOrigin("Invalid origin".into())),
};
let ys: Vec<u32> = match origin {
ORIGIN_BOTTOM_LEFT | ORIGIN_BOTTOM_RIGHT => (0..height).collect(),
ORIGIN_TOP_LEFT | ORIGIN_TOP_RIGHT => (0..height).rev().collect(),
_ => return Err(GfxError::InvalidOrigin("Invalid origin".into())),
};
for y in ys {
for x in xs.clone() {
let p = rgb.get_pixel(x, y);
out.write_all(&[p[2], p[1], p[0]])?; // BGR
}
}
// EXTENDED DATA
let mut inis = [0u8; 2];
for (i, b) in initials.as_bytes().iter().take(2).enumerate() {
inis[i] = *b;
}
out.write_all(&inis)?;
out.write_all(&[0x0])?;
let now = Local::now();
out.write_u16::<LittleEndian>(now.month() as u16)?;
out.write_u16::<LittleEndian>(now.day() as u16)?;
out.write_u16::<LittleEndian>(now.year() as u16)?;
out.write_u16::<LittleEndian>(now.hour() as u16)?;
out.write_u16::<LittleEndian>(now.second() as u16)?;
// FOOTER
let mut sig = [0u8; 16];
let s: &[u8] = signature.as_bytes();
sig[..s.len()].copy_from_slice(s);
out.write_all(&sig)?;
out.write_u8(b'.')?;
out.write_u8(0)?;
out.flush()?;
Ok(())
}

24
src/error.rs Normal file
View File

@ -0,0 +1,24 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum GfxError {
#[error("I/O Error: {0}")]
Io(#[from] std::io::Error),
#[error("Image Error: {0}")]
Image(#[from] image::ImageError),
#[error("Invalid Header: {0}")]
InvalidHeader(String),
#[error("Unsupported image type: {0}")]
Unsupported(u8),
#[error("Format error: {0}")]
Format(String),
#[error("Invalid origin: {0}")]
InvalidOrigin(String),
}
pub type Result<T> = std::result::Result<T, GfxError>;

10
src/format.rs Normal file
View File

@ -0,0 +1,10 @@
pub const HEADER_LEN: usize = 13;
pub const FOOTER_LEN: usize = 18;
// Descriptor bits:
// bit 5-4 = origin
// bit 3-0 = alpha depth
pub const ORIGIN_BOTTOM_LEFT: u8 = 0b0000_0000;
pub const ORIGIN_BOTTOM_RIGHT: u8 = 0b0001_0000;
pub const ORIGIN_TOP_LEFT: u8 = 0b0010_0000;
pub const ORIGIN_TOP_RIGHT: u8 = 0b0011_0000;

30
src/main.rs Normal file
View File

@ -0,0 +1,30 @@
mod cli;
mod encoder;
mod decoder;
mod format;
mod error;
mod metadata;
use clap::Parser;
use cli::{Cli, Commands};
use error::Result;
use crate::format::ORIGIN_TOP_LEFT;
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Encode { input, output, initials, signature } => {
encoder::encode(&input, &output, &initials, &signature, ORIGIN_TOP_LEFT)?;
}
Commands::Decode { input, output } => {
decoder::decode(&input, &output)?;
}
Commands::Metadata { input } => {
metadata::print_metadata(&input)?;
}
}
Ok(())
}

96
src/metadata.rs Normal file
View File

@ -0,0 +1,96 @@
use crate::error::{GfxError, Result};
use crate::format::*;
use byteorder::{LittleEndian, ReadBytesExt};
use std::fs::File;
use std::io::{Read, Seek, SeekFrom, BufReader};
use std::path::Path;
pub fn print_metadata(path: &Path) -> Result<()> {
let mut file = File::open(path)?;
let meta = file.metadata()?;
let file_size = meta.len();
if file_size < (HEADER_LEN + FOOTER_LEN) as u64 {
return Err(GfxError::Format("file too small".into()));
}
let mut r = BufReader::new(&file);
// ─────────────────────────────────────────────
// Header
// ─────────────────────────────────────────────
let color_map = r.read_u8()?;
let img_type = r.read_u8()?;
let cm_first = r.read_u16::<LittleEndian>()?;
let cm_len = r.read_u16::<LittleEndian>()?;
let cm_extra = r.read_u8()?;
let width = r.read_u16::<LittleEndian>()?;
let height = r.read_u16::<LittleEndian>()?;
let bpp = r.read_u8()?;
let desc = r.read_u8()?;
let origin_bits = (desc & 0b0011_0000) >> 4;
let origin = match origin_bits {
0b00 => "bottom-left",
0b01 => "bottom-right",
0b10 => "top-left",
0b11 => "top-right",
_ => "invalid",
};
let pixel_bytes = (width as usize * height as usize) * 3;
let ext_offset = HEADER_LEN as u64 + pixel_bytes as u64;
file.seek(SeekFrom::Start(ext_offset))?;
let mut initials_raw = [0u8; 3];
file.read_exact(&mut initials_raw)?;
let initials = String::from_utf8_lossy(&initials_raw[..2]);
let month = file.read_u16::<LittleEndian>()?;
let day = file.read_u16::<LittleEndian>()?;
let year = file.read_u16::<LittleEndian>()?;
let hour = file.read_u16::<LittleEndian>()?;
let minute = file.read_u16::<LittleEndian>()?;
let second = file.read_u16::<LittleEndian>()?;
file.seek(SeekFrom::End(-(FOOTER_LEN as i64)))?;
let mut footer = [0u8; FOOTER_LEN];
file.read_exact(&mut footer)?;
let signature = &footer[..16];
// Output
println!("File Metadata");
println!("===============================");
println!("File size: {} bytes", file_size);
println!();
println!("Header:");
println!(" Color map type: {}", color_map);
println!(" Image type: {}", img_type);
println!(" Color map first: {}", cm_first);
println!(" Color map len: {}", cm_len);
println!(" Color map extra: {}", cm_extra);
println!(" Width: {}px", width);
println!(" Height: {}px", height);
println!(" Bits per pixel: {}", bpp);
println!(" Origin: {}", origin);
println!();
println!("Author:");
println!(" Initials: {}", initials);
println!();
println!("Timestamp:");
println!(" {:04}-{:02}-{:02} {:02}:{:02}:{:02}",
year, month, day, hour, minute, second
);
println!();
println!("Signature:");
println!(" {:?}", String::from_utf8_lossy(signature));
Ok(())
}