use crate::error::PdfError;
use crate::objects::{Dict, Object};
use crate::reader::parse::Parser;
#[derive(Debug, Clone, PartialEq)]
pub struct LinearizationParams {
pub linearized: f64,
pub file_length: u64,
pub hint_offset: u64,
pub hint_length: u64,
pub hint_overflow: Option<(u64, u64)>,
pub first_page_object_number: u32,
pub end_of_first_page: u64,
pub page_count: u32,
pub main_xref_offset: u64,
}
impl LinearizationParams {
pub fn parse(input: &[u8]) -> Result<Option<Self>, PdfError> {
parse_linearization_dict(input)
}
pub fn verify(&self, input: &[u8]) -> Result<(), PdfError> {
if self.file_length != input.len() as u64 {
return Err(PdfError::other(format!(
"PDF linearization: /L = {} but file is {} bytes (truncated or extended?)",
self.file_length,
input.len()
)));
}
if self.main_xref_offset >= input.len() as u64 {
return Err(PdfError::other(format!(
"PDF linearization: /T = {} points past end of file ({} bytes)",
self.main_xref_offset,
input.len()
)));
}
if self.end_of_first_page > input.len() as u64 {
return Err(PdfError::other(format!(
"PDF linearization: /E = {} points past end of file ({} bytes)",
self.end_of_first_page,
input.len()
)));
}
if self.hint_offset >= input.len() as u64 {
return Err(PdfError::other(format!(
"PDF linearization: /H[0] = {} points past end of file ({} bytes)",
self.hint_offset,
input.len()
)));
}
if self.hint_offset + self.hint_length > input.len() as u64 {
return Err(PdfError::other(format!(
"PDF linearization: /H stream extends past end of file ({} + {} > {})",
self.hint_offset,
self.hint_length,
input.len()
)));
}
if self.page_count == 0 {
return Err(PdfError::other(
"PDF linearization: /N = 0 — linearized file must declare ≥1 page",
));
}
Ok(())
}
}
pub fn parse_linearization_dict(input: &[u8]) -> Result<Option<LinearizationParams>, PdfError> {
if input.len() < 16 {
return Ok(None);
}
let scan_end = 2048.min(input.len());
let head = &input[..scan_end];
let obj_pos = match find_first_obj_header(head) {
Some(p) => p,
None => return Ok(None),
};
let mut p = Parser::new(input);
p.lexer_mut().seek(obj_pos);
let (_id, body) = match p.parse_indirect() {
Ok(v) => v,
Err(_) => return Ok(None),
};
let Object::Dict(d) = body else {
return Ok(None);
};
let Some(_) = lookup(&d, "Linearized") else {
return Ok(None);
};
let linearized = require_number(&d, "Linearized")?;
let file_length = require_uint(&d, "L")?;
let (hint_offset, hint_length, hint_overflow) = require_hint_array(&d)?;
let first_page_object_number = require_uint(&d, "O")? as u32;
let end_of_first_page = require_uint(&d, "E")?;
let page_count = require_uint(&d, "N")? as u32;
let main_xref_offset = require_uint(&d, "T")?;
Ok(Some(LinearizationParams {
linearized,
file_length,
hint_offset,
hint_length,
hint_overflow,
first_page_object_number,
end_of_first_page,
page_count,
main_xref_offset,
}))
}
fn find_first_obj_header(head: &[u8]) -> Option<usize> {
let needle = b" obj";
let mut search = 0usize;
while let Some(rel) = window_find(&head[search..], needle) {
let pos = search + rel;
let header_start = match scan_back_two_ints(head, pos) {
Some(p) => p,
None => {
search = pos + needle.len();
continue;
}
};
return Some(header_start);
}
None
}
fn window_find(hay: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || hay.len() < needle.len() {
return None;
}
hay.windows(needle.len()).position(|w| w == needle)
}
fn scan_back_two_ints(input: &[u8], space_before_obj_pos: usize) -> Option<usize> {
let mut p = space_before_obj_pos;
if p == 0 {
return None;
}
p -= 1;
while p > 0 && input[p].is_ascii_digit() {
p -= 1;
}
if !input[p].is_ascii_whitespace() {
return None;
}
while p > 0 && input[p].is_ascii_whitespace() {
p -= 1;
}
if !input[p].is_ascii_digit() {
return None;
}
while p > 0 && input[p].is_ascii_digit() {
p -= 1;
}
if input[p].is_ascii_digit() {
Some(p)
} else {
Some(p + 1)
}
}
fn lookup<'d>(d: &'d Dict, k: &str) -> Option<&'d Object> {
d.entries().iter().find(|(kk, _)| kk == k).map(|(_, v)| v)
}
fn require_number(d: &Dict, k: &str) -> Result<f64, PdfError> {
match lookup(d, k) {
Some(Object::Integer(n)) => Ok(*n as f64),
Some(Object::Real(f)) => Ok(*f),
Some(other) => Err(PdfError::other(format!(
"PDF linearization: /{k} must be a number (got {other:?})"
))),
None => Err(PdfError::other(format!(
"PDF linearization: missing required /{k}"
))),
}
}
fn require_uint(d: &Dict, k: &str) -> Result<u64, PdfError> {
match lookup(d, k) {
Some(Object::Integer(n)) if *n >= 0 => Ok(*n as u64),
Some(Object::Integer(n)) => Err(PdfError::other(format!(
"PDF linearization: /{k} must be non-negative (got {n})"
))),
Some(other) => Err(PdfError::other(format!(
"PDF linearization: /{k} must be an integer (got {other:?})"
))),
None => Err(PdfError::other(format!(
"PDF linearization: missing required /{k}"
))),
}
}
#[allow(clippy::type_complexity)]
fn require_hint_array(d: &Dict) -> Result<(u64, u64, Option<(u64, u64)>), PdfError> {
let Some(obj) = lookup(d, "H") else {
return Err(PdfError::other("PDF linearization: missing required /H"));
};
let Object::Array(items) = obj else {
return Err(PdfError::other(format!(
"PDF linearization: /H must be an array (got {obj:?})"
)));
};
if items.len() < 2 || items.len() % 2 != 0 {
return Err(PdfError::other(format!(
"PDF linearization: /H must have 2 or 4 elements (got {})",
items.len()
)));
}
let as_uint = |o: &Object, ix: usize| -> Result<u64, PdfError> {
match o {
Object::Integer(n) if *n >= 0 => Ok(*n as u64),
_ => Err(PdfError::other(format!(
"PDF linearization: /H[{ix}] must be a non-negative integer (got {o:?})"
))),
}
};
let off = as_uint(&items[0], 0)?;
let len = as_uint(&items[1], 1)?;
let overflow = if items.len() >= 4 {
Some((as_uint(&items[2], 2)?, as_uint(&items[3], 3)?))
} else {
None
};
Ok((off, len, overflow))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linearize::write_pdf_linearized;
use crate::writer::write_pdf_from_scene;
use oxideav_core::time::TimeBase;
use oxideav_core::vector::{
FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Point, Rgba, VectorFrame,
};
use oxideav_scene::{Page, Scene};
fn rect_frame(w: f32, h: f32, color: Rgba) -> VectorFrame {
let mut p = Path::new();
p.commands.push(PathCommand::MoveTo(Point::new(10.0, 10.0)));
p.commands
.push(PathCommand::LineTo(Point::new(w - 10.0, 10.0)));
p.commands
.push(PathCommand::LineTo(Point::new(w - 10.0, h - 10.0)));
p.commands
.push(PathCommand::LineTo(Point::new(10.0, h - 10.0)));
p.commands.push(PathCommand::Close);
VectorFrame {
width: w,
height: h,
view_box: None,
root: Group {
children: vec![Node::Path(PathNode {
path: p,
fill: Some(Paint::Solid(color)),
stroke: None,
fill_rule: FillRule::NonZero,
})],
..Group::default()
},
pts: None,
time_base: TimeBase::new(1, 1),
}
}
fn page_with(w: f32, h: f32, color: Rgba) -> Page {
let mut page = Page::new(w, h);
page.content = rect_frame(w, h, color);
page
}
fn linearized_scene_3_pages() -> Vec<u8> {
let scene = Scene {
pages: Some(vec![
page_with(100.0, 100.0, Rgba::opaque(255, 0, 0)),
page_with(200.0, 150.0, Rgba::opaque(0, 255, 0)),
page_with(300.0, 200.0, Rgba::opaque(0, 0, 255)),
]),
..Scene::default()
};
write_pdf_linearized(&scene).expect("linearize")
}
#[test]
fn parses_linearization_dict_from_writer_output() {
let pdf = linearized_scene_3_pages();
let lin = LinearizationParams::parse(&pdf)
.expect("parse")
.expect("Some");
assert_eq!(lin.linearized, 1.0);
assert_eq!(lin.file_length, pdf.len() as u64);
assert_eq!(lin.page_count, 3);
}
#[test]
fn parsed_linearization_main_xref_actually_holds_xref() {
let pdf = linearized_scene_3_pages();
let lin = LinearizationParams::parse(&pdf)
.expect("parse")
.expect("Some");
let off = lin.main_xref_offset as usize;
assert_eq!(
&pdf[off..off + 5],
b"xref\n",
"/T must point at the main xref section"
);
}
#[test]
fn parsed_linearization_first_page_object_at_byte_offset_matches() {
let pdf = linearized_scene_3_pages();
let lin = LinearizationParams::parse(&pdf)
.expect("parse")
.expect("Some");
let needle = format!("{} 0 obj", lin.first_page_object_number);
let pos = pdf
.windows(needle.len())
.position(|w| w == needle.as_bytes())
.expect("first-page obj header present");
assert!(pos > 0);
}
#[test]
fn verify_succeeds_for_writer_output() {
let pdf = linearized_scene_3_pages();
let lin = LinearizationParams::parse(&pdf)
.expect("parse")
.expect("Some");
lin.verify(&pdf).expect("verify clean");
}
#[test]
fn verify_fails_when_l_mismatches_actual_length() {
let pdf = linearized_scene_3_pages();
let mut lin = LinearizationParams::parse(&pdf)
.expect("parse")
.expect("Some");
lin.file_length += 1;
let err = lin.verify(&pdf).expect_err("must reject");
let msg = format!("{err}");
assert!(msg.contains("/L = "), "msg = {msg:?}");
}
#[test]
fn non_linearized_pdf_returns_none() {
let scene = Scene {
pages: Some(vec![page_with(100.0, 100.0, Rgba::opaque(0, 0, 0))]),
..Scene::default()
};
let pdf = write_pdf_from_scene(&scene).expect("write");
let lin = LinearizationParams::parse(&pdf).expect("parse");
assert!(
lin.is_none(),
"non-linearized PDF must parse to None, got {lin:?}"
);
}
#[test]
fn empty_input_returns_none() {
assert!(LinearizationParams::parse(b"").expect("parse").is_none());
assert!(LinearizationParams::parse(b"%PDF-1.7\n%%EOF\n")
.expect("parse")
.is_none());
}
#[test]
fn malformed_input_returns_none() {
let stub = b"%PDF-1.5\n\xE2\xE3\xCF\xD3 no obj here at all\n%%EOF\n";
assert!(LinearizationParams::parse(stub).expect("parse").is_none());
}
#[test]
fn rejects_lin_dict_missing_required_key() {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n");
bytes.extend_from_slice(b"1 0 obj\n<< /Linearized 1 >>\nendobj\n");
bytes.extend_from_slice(
b"xref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
);
let err = LinearizationParams::parse(&bytes).expect_err("must reject");
let msg = format!("{err}");
assert!(msg.contains("/L"), "msg = {msg:?}");
}
#[test]
fn rejects_hint_array_with_odd_length() {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n");
bytes.extend_from_slice(
b"1 0 obj\n<< /Linearized 1 /L 100 /H [ 50 ] /O 2 /E 80 /N 1 /T 90 >>\nendobj\n",
);
bytes.extend_from_slice(
b"xref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
);
let err = LinearizationParams::parse(&bytes).expect_err("must reject /H length");
let msg = format!("{err}");
assert!(msg.contains("/H"), "msg = {msg:?}");
}
#[test]
fn accepts_hint_array_with_four_elements() {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n");
bytes.extend_from_slice(
b"1 0 obj\n<< /Linearized 1 /L 200 /H [ 50 30 100 20 ] /O 2 /E 80 /N 1 /T 180 >>\nendobj\n",
);
bytes.extend_from_slice(
b"xref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
);
let lin = LinearizationParams::parse(&bytes)
.expect("parse")
.expect("Some");
assert_eq!(lin.hint_offset, 50);
assert_eq!(lin.hint_length, 30);
assert_eq!(lin.hint_overflow, Some((100, 20)));
}
#[test]
fn rejects_lin_dict_with_negative_int() {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n");
bytes.extend_from_slice(
b"1 0 obj\n<< /Linearized 1 /L -1 /H [ 50 30 ] /O 2 /E 80 /N 1 /T 90 >>\nendobj\n",
);
bytes.extend_from_slice(
b"xref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
);
let err = LinearizationParams::parse(&bytes).expect_err("must reject /L=-1");
let msg = format!("{err}");
assert!(msg.contains("/L"), "msg = {msg:?}");
}
#[test]
fn scan_back_two_ints_finds_header_start() {
let buf = b" 1 0 obj\n<<";
let space_pos = buf
.windows(b" obj".len())
.position(|w| w == b" obj")
.unwrap();
let start = scan_back_two_ints(buf, space_pos).expect("found");
assert_eq!(&buf[start..start + 1], b"1");
}
}