initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/target
|
||||
*.pif
|
||||
1302
Cargo.lock
generated
Normal file
1302
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
Cargo.toml
Normal file
11
Cargo.toml
Normal 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
42
src/cli.rs
Normal 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
139
src/decoder.rs
Normal 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
79
src/encoder.rs
Normal 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
24
src/error.rs
Normal 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
10
src/format.rs
Normal 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
30
src/main.rs
Normal 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
96
src/metadata.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user