#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Sauce {
pub title: String,
pub author: String,
pub group: String,
pub date: String,
pub width: usize,
pub height: usize,
pub ice_colors: bool,
pub letter_spacing: Option<LetterSpacing>,
pub font_name: String,
content_len: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LetterSpacing {
EightPixels,
NinePixels,
}
impl LetterSpacing {
pub(crate) fn glyph_width(self) -> usize {
match self {
Self::EightPixels => 8,
Self::NinePixels => 9,
}
}
}
impl Sauce {
pub(crate) fn from_text_metadata(
title: String,
author: String,
group: String,
date: String,
width: usize,
height: usize,
font_name: String,
) -> Option<Self> {
if title.is_empty()
&& author.is_empty()
&& group.is_empty()
&& date.is_empty()
&& font_name.is_empty()
{
return None;
}
Some(Self {
title,
author,
group,
date,
width,
height,
ice_colors: false,
letter_spacing: None,
font_name,
content_len: 0,
})
}
pub fn parse(data: &[u8]) -> Option<Self> {
let start = data.len().checked_sub(128)?;
let record = &data[start..];
if &record[..7] != b"SAUCE00" {
return None;
}
let reported_len = u32::from_le_bytes(record[90..94].try_into().ok()?) as usize;
let content_len = if reported_len <= start {
reported_len
} else {
start
};
Some(Self {
title: field(&record[7..42]),
author: field(&record[42..62]),
group: field(&record[62..82]),
date: field(&record[82..90]),
width: u16::from_le_bytes(record[96..98].try_into().ok()?) as usize,
height: u16::from_le_bytes(record[98..100].try_into().ok()?) as usize,
ice_colors: record[105] & 1 != 0,
letter_spacing: match (record[105] >> 1) & 0b11 {
1 => Some(LetterSpacing::EightPixels),
2 => Some(LetterSpacing::NinePixels),
_ => None,
},
font_name: field(&record[106..128]),
content_len,
})
}
pub fn content<'a>(&self, data: &'a [u8]) -> &'a [u8] {
&data[..self.content_len.min(data.len())]
}
}
fn field(bytes: &[u8]) -> String {
let end = bytes
.iter()
.rposition(|&byte| byte != 0 && byte != b' ')
.map_or(0, |position| position + 1);
bytes[..end]
.iter()
.map(|&byte| crate::text::CP437[usize::from(byte)])
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_record_and_content_size() {
let mut data = vec![b'x'; 3];
let mut record = [0_u8; 128];
record[..7].copy_from_slice(b"SAUCE00");
record[7..11].copy_from_slice(b"Demo");
record[90..94].copy_from_slice(&3_u32.to_le_bytes());
record[96..98].copy_from_slice(&80_u16.to_le_bytes());
record[98..100].copy_from_slice(&25_u16.to_le_bytes());
record[105] = 0b101; data.extend(record);
let sauce = Sauce::parse(&data).unwrap();
assert_eq!(sauce.title, "Demo");
assert_eq!((sauce.width, sauce.height), (80, 25));
assert!(sauce.ice_colors);
assert_eq!(sauce.letter_spacing, Some(LetterSpacing::NinePixels));
assert_eq!(sauce.content(&data), b"xxx");
}
#[test]
fn preserves_a_binary_sub_byte_inside_the_reported_size() {
let mut data = vec![b'X', 0x1a];
let mut record = [0_u8; 128];
record[..7].copy_from_slice(b"SAUCE00");
record[90..94].copy_from_slice(&2_u32.to_le_bytes());
record[94] = 6; data.extend(record);
let sauce = Sauce::parse(&data).unwrap();
assert_eq!(sauce.content(&data), &[b'X', 0x1a]);
}
#[test]
fn decodes_text_fields_as_cp437() {
let mut data = Vec::new();
let mut record = [0_u8; 128];
record[..7].copy_from_slice(b"SAUCE00");
record[7..11].copy_from_slice(&[b'C', b'a', b'f', 0x82]);
data.extend(record);
let sauce = Sauce::parse(&data).unwrap();
assert_eq!(sauce.title, "Café");
}
}