use crate::error::Error;
use pdfrum_common::{DiagKind, Diagnostics, Severity, hex_digit};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Container {
Pfb,
Pfa,
Bare,
}
#[derive(Debug, Clone)]
pub struct Split {
pub container: Container,
pub clear: Vec<u8>,
pub cipher: Vec<u8>,
}
const PFB_MARKER: u8 = 0x80;
const PFB_TEXT: u8 = 1;
const PFB_BINARY: u8 = 2;
const PFB_EOF: u8 = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FontFile {
pub program: Vec<u8>,
pub length1: u32,
pub length2: u32,
pub length3: u32,
}
#[must_use]
pub fn font_file(bytes: &[u8]) -> FontFile {
if bytes.first() == Some(&PFB_MARKER) {
return pfb_font_file(bytes);
}
ascii_font_file(bytes)
}
fn pfb_font_file(bytes: &[u8]) -> FontFile {
let mut out = FontFile {
program: Vec::with_capacity(bytes.len()),
length1: 0,
length2: 0,
length3: 0,
};
let mut seen_binary = false;
let mut at = 0usize;
while at < bytes.len() {
let Some(header) = bytes.get(at..at.saturating_add(6)) else {
break;
};
if header.first().copied() != Some(PFB_MARKER) {
break;
}
let Some(k @ (PFB_TEXT | PFB_BINARY)) = header.get(1).copied() else {
break;
};
let declared = le_u32(header.get(2..6).unwrap_or_default()) as usize;
let body_at = at.saturating_add(6);
let body = bytes
.get(body_at..body_at.saturating_add(declared))
.unwrap_or_else(|| bytes.get(body_at..).unwrap_or_default());
let n = len_u32(body.len());
out.program.extend_from_slice(body);
if k == PFB_TEXT {
if seen_binary {
out.length3 = out.length3.saturating_add(n);
} else {
out.length1 = out.length1.saturating_add(n);
}
} else {
seen_binary = true;
out.length2 = out.length2.saturating_add(n);
}
at = body_at.saturating_add(body.len());
if body.len() < declared {
break;
}
}
if out.program.is_empty() {
return FontFile {
program: bytes.to_vec(),
length1: len_u32(bytes.len()),
length2: 0,
length3: 0,
};
}
out
}
fn ascii_font_file(bytes: &[u8]) -> FontFile {
let Some(key) = find_eexec(bytes) else {
return FontFile {
program: bytes.to_vec(),
length1: len_u32(bytes.len()),
length2: 0,
length3: 0,
};
};
let trailer = trailer_start(bytes, key);
FontFile {
program: bytes.to_vec(),
length1: len_u32(key),
length2: len_u32(trailer.saturating_sub(key)),
length3: len_u32(bytes.len().saturating_sub(trailer)),
}
}
fn trailer_start(bytes: &[u8], after: usize) -> usize {
const TRAILER_ZEROS: usize = 512;
let tail = bytes.get(after..).unwrap_or_default();
let mut end = tail.len();
while end > 0 && tail.get(end.saturating_sub(1)) != Some(&b'0') {
end = end.saturating_sub(1);
}
let mut zeros = 0usize;
let mut at = end;
while at > 0 && zeros < TRAILER_ZEROS {
match tail.get(at.saturating_sub(1)) {
Some(b'0') => zeros = zeros.saturating_add(1),
Some(b) if b.is_ascii_whitespace() => {}
_ => break,
}
at = at.saturating_sub(1);
}
if zeros == TRAILER_ZEROS {
after.saturating_add(at)
} else {
bytes.len()
}
}
fn len_u32(n: usize) -> u32 {
u32::try_from(n).unwrap_or(u32::MAX)
}
pub fn split(bytes: &[u8], diags: &mut Diagnostics) -> Result<Split, Error> {
if bytes.is_empty() {
return Err(Error::Empty);
}
if bytes.first() == Some(&PFB_MARKER) {
return split_pfb(bytes, diags);
}
let container = if banner(bytes) {
Container::Pfa
} else {
Container::Bare
};
split_ascii(bytes, container, diags)
}
fn banner(bytes: &[u8]) -> bool {
let start = bytes
.iter()
.position(|b| !b.is_ascii_whitespace())
.unwrap_or(bytes.len());
let rest = bytes.get(start..).unwrap_or_default();
rest.starts_with(b"%!PS-AdobeFont") || rest.starts_with(b"%!FontType1")
}
fn split_pfb(bytes: &[u8], diags: &mut Diagnostics) -> Result<Split, Error> {
let mut clear = Vec::new();
let mut cipher = Vec::new();
let mut at = 0usize;
let mut first = true;
while at < bytes.len() {
let Some(header) = bytes.get(at..at.saturating_add(6)) else {
diags.record(
Severity::Suspicious,
DiagKind::Type1PfbTruncated,
Some(at as u64),
);
break;
};
let (marker, kind) = (header.first().copied(), header.get(1).copied());
if marker != Some(PFB_MARKER) {
if first {
return Err(Error::PfbSegment { at });
}
diags.record(
Severity::Suspicious,
DiagKind::Type1PfbTruncated,
Some(at as u64),
);
break;
}
match kind {
Some(PFB_EOF) => break,
Some(k @ (PFB_TEXT | PFB_BINARY)) => {
let declared = le_u32(header.get(2..6).unwrap_or_default()) as usize;
let body_at = at.saturating_add(6);
let body = if let Some(b) = bytes.get(body_at..body_at.saturating_add(declared)) {
b
} else {
diags.record(
Severity::Recovered,
DiagKind::Type1PfbTruncated,
Some(at as u64),
);
bytes.get(body_at..).unwrap_or_default()
};
if k == PFB_TEXT {
if cipher.is_empty() {
clear.extend_from_slice(body);
}
} else {
cipher.extend_from_slice(body);
}
at = body_at.saturating_add(body.len());
if body.len() < declared {
break;
}
}
_ => {
if first {
return Err(Error::PfbSegment { at });
}
diags.record(
Severity::Suspicious,
DiagKind::Type1PfbTruncated,
Some(at as u64),
);
break;
}
}
first = false;
}
if cipher.is_empty() {
if let Ok(mut ascii) = split_ascii(&clear, Container::Pfb, diags) {
ascii.container = Container::Pfb;
return Ok(ascii);
}
return Err(Error::NoEexec);
}
Ok(Split {
container: Container::Pfb,
clear,
cipher,
})
}
fn split_ascii(
bytes: &[u8],
container: Container,
diags: &mut Diagnostics,
) -> Result<Split, Error> {
let key = find_eexec(bytes).ok_or(Error::NoEexec)?;
let clear = bytes.get(..key).unwrap_or_default().to_vec();
let tail = bytes.get(key..).unwrap_or_default();
let significant: Vec<u8> = tail
.iter()
.copied()
.filter(|b| !b.is_ascii_whitespace())
.take(4)
.collect();
let is_hex = significant.len() == 4 && significant.iter().all(u8::is_ascii_hexdigit);
let cipher = if is_hex {
hex_decode(tail, diags)
} else {
tail.to_vec()
};
Ok(Split {
container,
clear,
cipher,
})
}
fn find_eexec(bytes: &[u8]) -> Option<usize> {
let mut i = 0usize;
while i < bytes.len() {
match bytes.get(i).copied() {
Some(b'%') => {
while i < bytes.len() && !matches!(bytes.get(i), Some(b'\r' | b'\n')) {
i = i.saturating_add(1);
}
}
Some(b'(') => {
let mut depth = 1usize;
i = i.saturating_add(1);
while i < bytes.len() && depth > 0 {
match bytes.get(i).copied() {
Some(b'\\') => i = i.saturating_add(1),
Some(b'(') => depth = depth.saturating_add(1),
Some(b')') => depth = depth.saturating_sub(1),
_ => {}
}
i = i.saturating_add(1);
}
}
_ => {
if bytes.get(i..i.saturating_add(5)) == Some(b"eexec".as_slice())
&& before_is_boundary(bytes, i)
&& after_is_boundary(bytes, i.saturating_add(5))
{
return Some(skip_one_eol(bytes, i.saturating_add(5)));
}
i = i.saturating_add(1);
}
}
}
None
}
fn before_is_boundary(bytes: &[u8], at: usize) -> bool {
at == 0
|| at
.checked_sub(1)
.and_then(|p| bytes.get(p))
.is_some_and(|b| b.is_ascii_whitespace() || *b == b'/')
}
fn after_is_boundary(bytes: &[u8], at: usize) -> bool {
bytes.get(at).is_none_or(u8::is_ascii_whitespace)
}
fn skip_one_eol(bytes: &[u8], mut at: usize) -> usize {
while matches!(bytes.get(at), Some(b' ' | b'\t')) {
at = at.saturating_add(1);
}
match bytes.get(at) {
Some(b'\r') => {
at = at.saturating_add(1);
if bytes.get(at) == Some(&b'\n') {
at = at.saturating_add(1);
}
}
Some(b'\n') => at = at.saturating_add(1),
_ => {}
}
at
}
fn hex_decode(bytes: &[u8], diags: &mut Diagnostics) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len() / 2);
let mut high: Option<u8> = None;
for (i, b) in bytes.iter().enumerate() {
if b.is_ascii_whitespace() {
continue;
}
let Some(nibble) = hex_digit(*b) else {
if i.saturating_add(1) < bytes.len() {
diags.record(
Severity::Recovered,
DiagKind::Type1HexTruncated,
Some(i as u64),
);
}
break;
};
match high.take() {
None => high = Some(nibble),
Some(h) => out.push((h << 4) | nibble),
}
}
out
}
fn le_u32(b: &[u8]) -> u32 {
let g = |i: usize| u32::from(b.get(i).copied().unwrap_or(0));
g(0) | (g(1) << 8) | (g(2) << 16) | (g(3) << 24)
}
#[cfg(test)]
#[allow(
clippy::indexing_slicing,
clippy::float_cmp,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::similar_names
)]
mod tests {
use super::{Container, split};
use pdfrum_common::{DiagKind, Diagnostics};
fn pfb(text: &[u8], binary: &[u8]) -> Vec<u8> {
let mut v = vec![0x80, 1];
v.extend_from_slice(&(text.len() as u32).to_le_bytes());
v.extend_from_slice(text);
v.extend_from_slice(&[0x80, 2]);
v.extend_from_slice(&(binary.len() as u32).to_le_bytes());
v.extend_from_slice(binary);
v.extend_from_slice(&[0x80, 3]);
v
}
#[test]
fn pfb_and_pfa_agree() {
let mut d = Diagnostics::default();
let clear = b"%!PS-AdobeFont-1.0: T 1\n/FontName /T def\ncurrentfile eexec\n";
let binary = b"\x01\x02\x03\x04rest-of-the-private-dict";
let from_pfb = split(&pfb(clear, binary), &mut d).unwrap();
assert_eq!(from_pfb.container, Container::Pfb);
assert_eq!(from_pfb.clear, clear);
assert_eq!(from_pfb.cipher, binary);
let mut pfa = clear.to_vec();
for b in binary {
pfa.extend_from_slice(format!("{b:02X}").as_bytes());
}
let from_pfa = split(&pfa, &mut d).unwrap();
assert_eq!(from_pfa.container, Container::Pfa);
assert_eq!(from_pfa.clear, clear);
assert_eq!(from_pfa.cipher, binary);
}
#[test]
fn truncated_pfb_segment_keeps_what_it_has() {
let mut good = pfb(b"%!PS-AdobeFont\n", b"abcdefgh");
good.truncate(good.len() - 6);
let mut d = Diagnostics::default();
let s = split(&good, &mut d).unwrap();
assert_eq!(s.cipher, b"abcd");
assert!(d.contains(&DiagKind::Type1PfbTruncated));
}
#[test]
fn bare_program_reads_as_pfa_shaped() {
let mut d = Diagnostics::default();
let s = split(
b"/FontName /T def\ncurrentfile eexec\n\x01\x02\x03\x04tail",
&mut d,
)
.unwrap();
assert_eq!(s.container, Container::Bare);
assert_eq!(s.cipher, b"\x01\x02\x03\x04tail");
}
#[test]
fn eexec_in_a_comment_or_string_is_not_the_keyword() {
let mut d = Diagnostics::default();
let s = split(
b"%!PS-AdobeFont\n% eexec here\n(eexec there) def\ncurrentfile eexec\r\n\x01\x02\x03\x04real",
&mut d,
)
.unwrap();
assert_eq!(s.cipher, b"\x01\x02\x03\x04real");
assert!(s.clear.ends_with(b"eexec\r\n"));
}
#[test]
fn pfb_font_file_drops_the_framing_and_partitions_what_is_left() {
let clear = b"%!PS-AdobeFont-1.0: T 1\ncurrentfile eexec\n";
let binary = b"\x01\x02\x03\x04private";
let mut wrapped = pfb(clear, binary);
let trailer = {
let mut t = vec![b'0'; 512];
t.extend_from_slice(b"\ncleartomark\n");
t
};
wrapped.truncate(wrapped.len() - 2); wrapped.extend_from_slice(&[0x80, 1]);
wrapped.extend_from_slice(&(trailer.len() as u32).to_le_bytes());
wrapped.extend_from_slice(&trailer);
wrapped.extend_from_slice(&[0x80, 3]);
let file = super::font_file(&wrapped);
assert_eq!(
file.program.len() as u32,
file.length1 + file.length2 + file.length3,
"the three lengths must partition the stored program"
);
assert!(file.program.starts_with(b"%!"));
assert_eq!(file.length1 as usize, clear.len());
assert_eq!(file.length2 as usize, binary.len());
assert_eq!(file.length3 as usize, trailer.len());
assert_eq!(&file.program[..clear.len()], clear);
assert_eq!(
&file.program[clear.len()..clear.len() + binary.len()],
binary
);
assert_eq!(file.program.len() + 20, wrapped.len());
}
#[test]
fn pfa_font_file_is_stored_as_is_with_hex_kept_hex() {
let mut pfa = b"%!PS-AdobeFont-1.0: T 1\ncurrentfile eexec\n".to_vec();
let head = pfa.len();
pfa.extend_from_slice(b"41424344454647484950\n");
let cipher = pfa.len() - head;
let mut trailer = vec![b'0'; 512];
trailer.extend_from_slice(b"\ncleartomark\n");
pfa.extend_from_slice(&trailer);
let file = super::font_file(&pfa);
assert_eq!(file.program, pfa, "a raw program is stored unchanged");
assert_eq!(
file.program.len() as u32,
file.length1 + file.length2 + file.length3
);
assert_eq!(file.length1 as usize, head);
assert_eq!(file.length2 as usize, cipher);
assert_eq!(file.length3 as usize, trailer.len());
}
#[test]
fn a_program_without_a_trailer_has_length3_zero() {
let raw = b"%!FontType1\ncurrentfile eexec\n\x01\x02\x03\x04tail";
let file = super::font_file(raw);
assert_eq!(file.length3, 0);
assert_eq!(
file.program.len() as u32,
file.length1 + file.length2 + file.length3
);
assert_eq!(file.program, raw);
}
#[test]
fn a_program_without_eexec_is_all_length1() {
let file = super::font_file(b"not a font");
assert_eq!(file.length1, 10);
assert_eq!((file.length2, file.length3), (0, 0));
assert_eq!(file.program, b"not a font");
}
#[test]
fn truncated_pfb_font_file_still_partitions() {
let mut good = pfb(b"%!PS-AdobeFont\ncurrentfile eexec\n", b"abcdefgh");
good.truncate(good.len() - 6);
let file = super::font_file(&good);
assert_eq!(
file.program.len() as u32,
file.length1 + file.length2 + file.length3
);
assert_eq!(file.length2, 4);
}
#[test]
fn hex_stops_at_the_first_non_hex_byte() {
let mut d = Diagnostics::default();
let s = split(b"%!FontType1\neexec\n4142 4344 zz9999", &mut d).unwrap();
assert_eq!(s.cipher, b"ABCD");
assert!(d.contains(&DiagKind::Type1HexTruncated));
}
}