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