1use crate::error::PdfError;
41use crate::objects::{Dict, Object};
42use crate::reader::parse::Parser;
43
44#[derive(Debug, Clone, PartialEq)]
51pub struct LinearizationParams {
52 pub linearized: f64,
56 pub file_length: u64,
61 pub hint_offset: u64,
67 pub hint_length: u64,
68 pub hint_overflow: Option<(u64, u64)>,
71 pub first_page_object_number: u32,
75 pub end_of_first_page: u64,
80 pub page_count: u32,
82 pub main_xref_offset: u64,
87}
88
89impl LinearizationParams {
90 pub fn parse(input: &[u8]) -> Result<Option<Self>, PdfError> {
103 parse_linearization_dict(input)
104 }
105
106 pub fn verify(&self, input: &[u8]) -> Result<(), PdfError> {
112 if self.file_length != input.len() as u64 {
113 return Err(PdfError::other(format!(
114 "PDF linearization: /L = {} but file is {} bytes (truncated or extended?)",
115 self.file_length,
116 input.len()
117 )));
118 }
119 if self.main_xref_offset >= input.len() as u64 {
120 return Err(PdfError::other(format!(
121 "PDF linearization: /T = {} points past end of file ({} bytes)",
122 self.main_xref_offset,
123 input.len()
124 )));
125 }
126 if self.end_of_first_page > input.len() as u64 {
127 return Err(PdfError::other(format!(
128 "PDF linearization: /E = {} points past end of file ({} bytes)",
129 self.end_of_first_page,
130 input.len()
131 )));
132 }
133 if self.hint_offset >= input.len() as u64 {
134 return Err(PdfError::other(format!(
135 "PDF linearization: /H[0] = {} points past end of file ({} bytes)",
136 self.hint_offset,
137 input.len()
138 )));
139 }
140 if self.hint_offset + self.hint_length > input.len() as u64 {
141 return Err(PdfError::other(format!(
142 "PDF linearization: /H stream extends past end of file ({} + {} > {})",
143 self.hint_offset,
144 self.hint_length,
145 input.len()
146 )));
147 }
148 if self.page_count == 0 {
149 return Err(PdfError::other(
150 "PDF linearization: /N = 0 — linearized file must declare ≥1 page",
151 ));
152 }
153 Ok(())
154 }
155}
156
157pub fn parse_linearization_dict(input: &[u8]) -> Result<Option<LinearizationParams>, PdfError> {
163 if input.len() < 16 {
168 return Ok(None);
169 }
170 let scan_end = 2048.min(input.len());
171 let head = &input[..scan_end];
172
173 let obj_pos = match find_first_obj_header(head) {
178 Some(p) => p,
179 None => return Ok(None),
180 };
181
182 let mut p = Parser::new(input);
183 p.lexer_mut().seek(obj_pos);
184 let (_id, body) = match p.parse_indirect() {
189 Ok(v) => v,
190 Err(_) => return Ok(None),
191 };
192 let Object::Dict(d) = body else {
193 return Ok(None);
194 };
195
196 let Some(_) = lookup(&d, "Linearized") else {
200 return Ok(None);
201 };
202
203 let linearized = require_number(&d, "Linearized")?;
205 let file_length = require_uint(&d, "L")?;
206 let (hint_offset, hint_length, hint_overflow) = require_hint_array(&d)?;
207 let first_page_object_number = require_uint(&d, "O")? as u32;
208 let end_of_first_page = require_uint(&d, "E")?;
209 let page_count = require_uint(&d, "N")? as u32;
210 let main_xref_offset = require_uint(&d, "T")?;
211
212 Ok(Some(LinearizationParams {
213 linearized,
214 file_length,
215 hint_offset,
216 hint_length,
217 hint_overflow,
218 first_page_object_number,
219 end_of_first_page,
220 page_count,
221 main_xref_offset,
222 }))
223}
224
225fn find_first_obj_header(head: &[u8]) -> Option<usize> {
231 let needle = b" obj";
232 let mut search = 0usize;
233 while let Some(rel) = window_find(&head[search..], needle) {
234 let pos = search + rel;
235 let header_start = match scan_back_two_ints(head, pos) {
240 Some(p) => p,
241 None => {
242 search = pos + needle.len();
243 continue;
244 }
245 };
246 return Some(header_start);
247 }
248 None
249}
250
251fn window_find(hay: &[u8], needle: &[u8]) -> Option<usize> {
253 if needle.is_empty() || hay.len() < needle.len() {
254 return None;
255 }
256 hay.windows(needle.len()).position(|w| w == needle)
257}
258
259fn scan_back_two_ints(input: &[u8], space_before_obj_pos: usize) -> Option<usize> {
264 let mut p = space_before_obj_pos;
266 if p == 0 {
267 return None;
268 }
269 p -= 1;
270 while p > 0 && input[p].is_ascii_digit() {
271 p -= 1;
272 }
273 if !input[p].is_ascii_whitespace() {
276 return None;
277 }
278 while p > 0 && input[p].is_ascii_whitespace() {
280 p -= 1;
281 }
282 if !input[p].is_ascii_digit() {
284 return None;
285 }
286 while p > 0 && input[p].is_ascii_digit() {
287 p -= 1;
288 }
289 if input[p].is_ascii_digit() {
292 Some(p)
293 } else {
294 Some(p + 1)
295 }
296}
297
298fn lookup<'d>(d: &'d Dict, k: &str) -> Option<&'d Object> {
299 d.entries().iter().find(|(kk, _)| kk == k).map(|(_, v)| v)
300}
301
302fn require_number(d: &Dict, k: &str) -> Result<f64, PdfError> {
303 match lookup(d, k) {
304 Some(Object::Integer(n)) => Ok(*n as f64),
305 Some(Object::Real(f)) => Ok(*f),
306 Some(other) => Err(PdfError::other(format!(
307 "PDF linearization: /{k} must be a number (got {other:?})"
308 ))),
309 None => Err(PdfError::other(format!(
310 "PDF linearization: missing required /{k}"
311 ))),
312 }
313}
314
315fn require_uint(d: &Dict, k: &str) -> Result<u64, PdfError> {
316 match lookup(d, k) {
317 Some(Object::Integer(n)) if *n >= 0 => Ok(*n as u64),
318 Some(Object::Integer(n)) => Err(PdfError::other(format!(
319 "PDF linearization: /{k} must be non-negative (got {n})"
320 ))),
321 Some(other) => Err(PdfError::other(format!(
322 "PDF linearization: /{k} must be an integer (got {other:?})"
323 ))),
324 None => Err(PdfError::other(format!(
325 "PDF linearization: missing required /{k}"
326 ))),
327 }
328}
329
330#[allow(clippy::type_complexity)]
333fn require_hint_array(d: &Dict) -> Result<(u64, u64, Option<(u64, u64)>), PdfError> {
334 let Some(obj) = lookup(d, "H") else {
335 return Err(PdfError::other("PDF linearization: missing required /H"));
336 };
337 let Object::Array(items) = obj else {
338 return Err(PdfError::other(format!(
339 "PDF linearization: /H must be an array (got {obj:?})"
340 )));
341 };
342 if items.len() < 2 || items.len() % 2 != 0 {
343 return Err(PdfError::other(format!(
344 "PDF linearization: /H must have 2 or 4 elements (got {})",
345 items.len()
346 )));
347 }
348 let as_uint = |o: &Object, ix: usize| -> Result<u64, PdfError> {
349 match o {
350 Object::Integer(n) if *n >= 0 => Ok(*n as u64),
351 _ => Err(PdfError::other(format!(
352 "PDF linearization: /H[{ix}] must be a non-negative integer (got {o:?})"
353 ))),
354 }
355 };
356 let off = as_uint(&items[0], 0)?;
357 let len = as_uint(&items[1], 1)?;
358 let overflow = if items.len() >= 4 {
359 Some((as_uint(&items[2], 2)?, as_uint(&items[3], 3)?))
360 } else {
361 None
362 };
363 Ok((off, len, overflow))
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::linearize::write_pdf_linearized;
370 use crate::writer::write_pdf_from_scene;
371 use oxideav_core::time::TimeBase;
372 use oxideav_core::vector::{
373 FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Point, Rgba, VectorFrame,
374 };
375 use oxideav_scene::{Page, Scene};
376
377 fn rect_frame(w: f32, h: f32, color: Rgba) -> VectorFrame {
378 let mut p = Path::new();
379 p.commands.push(PathCommand::MoveTo(Point::new(10.0, 10.0)));
380 p.commands
381 .push(PathCommand::LineTo(Point::new(w - 10.0, 10.0)));
382 p.commands
383 .push(PathCommand::LineTo(Point::new(w - 10.0, h - 10.0)));
384 p.commands
385 .push(PathCommand::LineTo(Point::new(10.0, h - 10.0)));
386 p.commands.push(PathCommand::Close);
387 VectorFrame {
388 width: w,
389 height: h,
390 view_box: None,
391 root: Group {
392 children: vec![Node::Path(PathNode {
393 path: p,
394 fill: Some(Paint::Solid(color)),
395 stroke: None,
396 fill_rule: FillRule::NonZero,
397 })],
398 ..Group::default()
399 },
400 pts: None,
401 time_base: TimeBase::new(1, 1),
402 }
403 }
404
405 fn page_with(w: f32, h: f32, color: Rgba) -> Page {
406 let mut page = Page::new(w, h);
407 page.content = rect_frame(w, h, color);
408 page
409 }
410
411 fn linearized_scene_3_pages() -> Vec<u8> {
412 let scene = Scene {
413 pages: Some(vec![
414 page_with(100.0, 100.0, Rgba::opaque(255, 0, 0)),
415 page_with(200.0, 150.0, Rgba::opaque(0, 255, 0)),
416 page_with(300.0, 200.0, Rgba::opaque(0, 0, 255)),
417 ]),
418 ..Scene::default()
419 };
420 write_pdf_linearized(&scene).expect("linearize")
421 }
422
423 #[test]
424 fn parses_linearization_dict_from_writer_output() {
425 let pdf = linearized_scene_3_pages();
426 let lin = LinearizationParams::parse(&pdf)
427 .expect("parse")
428 .expect("Some");
429 assert_eq!(lin.linearized, 1.0);
430 assert_eq!(lin.file_length, pdf.len() as u64);
431 assert_eq!(lin.page_count, 3);
432 }
433
434 #[test]
435 fn parsed_linearization_main_xref_actually_holds_xref() {
436 let pdf = linearized_scene_3_pages();
437 let lin = LinearizationParams::parse(&pdf)
438 .expect("parse")
439 .expect("Some");
440 let off = lin.main_xref_offset as usize;
442 assert_eq!(
443 &pdf[off..off + 5],
444 b"xref\n",
445 "/T must point at the main xref section"
446 );
447 }
448
449 #[test]
450 fn parsed_linearization_first_page_object_at_byte_offset_matches() {
451 let pdf = linearized_scene_3_pages();
452 let lin = LinearizationParams::parse(&pdf)
453 .expect("parse")
454 .expect("Some");
455 let needle = format!("{} 0 obj", lin.first_page_object_number);
459 let pos = pdf
460 .windows(needle.len())
461 .position(|w| w == needle.as_bytes())
462 .expect("first-page obj header present");
463 assert!(pos > 0);
464 }
465
466 #[test]
467 fn verify_succeeds_for_writer_output() {
468 let pdf = linearized_scene_3_pages();
469 let lin = LinearizationParams::parse(&pdf)
470 .expect("parse")
471 .expect("Some");
472 lin.verify(&pdf).expect("verify clean");
473 }
474
475 #[test]
476 fn verify_fails_when_l_mismatches_actual_length() {
477 let pdf = linearized_scene_3_pages();
478 let mut lin = LinearizationParams::parse(&pdf)
479 .expect("parse")
480 .expect("Some");
481 lin.file_length += 1;
482 let err = lin.verify(&pdf).expect_err("must reject");
483 let msg = format!("{err}");
484 assert!(msg.contains("/L = "), "msg = {msg:?}");
485 }
486
487 #[test]
488 fn non_linearized_pdf_returns_none() {
489 let scene = Scene {
490 pages: Some(vec![page_with(100.0, 100.0, Rgba::opaque(0, 0, 0))]),
491 ..Scene::default()
492 };
493 let pdf = write_pdf_from_scene(&scene).expect("write");
494 let lin = LinearizationParams::parse(&pdf).expect("parse");
496 assert!(
497 lin.is_none(),
498 "non-linearized PDF must parse to None, got {lin:?}"
499 );
500 }
501
502 #[test]
503 fn empty_input_returns_none() {
504 assert!(LinearizationParams::parse(b"").expect("parse").is_none());
505 assert!(LinearizationParams::parse(b"%PDF-1.7\n%%EOF\n")
506 .expect("parse")
507 .is_none());
508 }
509
510 #[test]
511 fn malformed_input_returns_none() {
512 let stub = b"%PDF-1.5\n\xE2\xE3\xCF\xD3 no obj here at all\n%%EOF\n";
514 assert!(LinearizationParams::parse(stub).expect("parse").is_none());
515 }
516
517 #[test]
518 fn rejects_lin_dict_missing_required_key() {
519 let mut bytes = Vec::new();
522 bytes.extend_from_slice(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n");
523 bytes.extend_from_slice(b"1 0 obj\n<< /Linearized 1 >>\nendobj\n");
524 bytes.extend_from_slice(
525 b"xref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
526 );
527 let err = LinearizationParams::parse(&bytes).expect_err("must reject");
528 let msg = format!("{err}");
529 assert!(msg.contains("/L"), "msg = {msg:?}");
530 }
531
532 #[test]
533 fn rejects_hint_array_with_odd_length() {
534 let mut bytes = Vec::new();
535 bytes.extend_from_slice(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n");
536 bytes.extend_from_slice(
537 b"1 0 obj\n<< /Linearized 1 /L 100 /H [ 50 ] /O 2 /E 80 /N 1 /T 90 >>\nendobj\n",
538 );
539 bytes.extend_from_slice(
540 b"xref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
541 );
542 let err = LinearizationParams::parse(&bytes).expect_err("must reject /H length");
543 let msg = format!("{err}");
544 assert!(msg.contains("/H"), "msg = {msg:?}");
545 }
546
547 #[test]
548 fn accepts_hint_array_with_four_elements() {
549 let mut bytes = Vec::new();
550 bytes.extend_from_slice(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n");
551 bytes.extend_from_slice(
552 b"1 0 obj\n<< /Linearized 1 /L 200 /H [ 50 30 100 20 ] /O 2 /E 80 /N 1 /T 180 >>\nendobj\n",
553 );
554 bytes.extend_from_slice(
555 b"xref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
556 );
557 let lin = LinearizationParams::parse(&bytes)
558 .expect("parse")
559 .expect("Some");
560 assert_eq!(lin.hint_offset, 50);
561 assert_eq!(lin.hint_length, 30);
562 assert_eq!(lin.hint_overflow, Some((100, 20)));
563 }
564
565 #[test]
566 fn rejects_lin_dict_with_negative_int() {
567 let mut bytes = Vec::new();
568 bytes.extend_from_slice(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n");
569 bytes.extend_from_slice(
570 b"1 0 obj\n<< /Linearized 1 /L -1 /H [ 50 30 ] /O 2 /E 80 /N 1 /T 90 >>\nendobj\n",
571 );
572 bytes.extend_from_slice(
573 b"xref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
574 );
575 let err = LinearizationParams::parse(&bytes).expect_err("must reject /L=-1");
576 let msg = format!("{err}");
577 assert!(msg.contains("/L"), "msg = {msg:?}");
578 }
579
580 #[test]
581 fn scan_back_two_ints_finds_header_start() {
582 let buf = b" 1 0 obj\n<<";
586 let space_pos = buf
587 .windows(b" obj".len())
588 .position(|w| w == b" obj")
589 .unwrap();
590 let start = scan_back_two_ints(buf, space_pos).expect("found");
591 assert_eq!(&buf[start..start + 1], b"1");
592 }
593}