#[path = "common/mod.rs"]
mod common;
use common::pdf_assembler::{assemble_pdf, stream_obj};
use common::synthetic_pdf::build_pdf_with_content_stream;
use oxidize_pdf::parser::{PdfDocument, PdfReader};
use oxidize_pdf::text::plaintext::{LineBreakMode, PlainTextConfig, PlainTextExtractor};
use oxidize_pdf::text::{ExtractionOptions, TextExtractor};
use std::io::Cursor;
const CAP: usize = 1024;
const OVERFLOW: usize = 976;
fn q_flood(n: usize) -> Vec<u8> {
b"q\n".repeat(n)
}
fn q_unwind(n: usize) -> Vec<u8> {
b"Q\n".repeat(n)
}
fn fragments_of_pdf(pdf: Vec<u8>) -> Vec<(String, f64, f64, f64)> {
let reader = PdfReader::new(Cursor::new(pdf)).expect("synthetic PDF must parse");
let document = PdfDocument::new(reader);
let options = ExtractionOptions {
preserve_layout: true,
..Default::default()
};
let mut extractor = TextExtractor::with_options(options);
extractor
.extract_from_page(&document, 0)
.expect("extract page 0")
.fragments
.into_iter()
.map(|f| (f.text, f.x, f.y, f.font_size))
.collect()
}
fn fragments_of(content: &[u8]) -> Vec<(String, f64, f64, f64)> {
fragments_of_pdf(build_pdf_with_content_stream(content))
}
fn fragment_named<'a>(
frags: &'a [(String, f64, f64, f64)],
text: &str,
) -> &'a (String, f64, f64, f64) {
frags
.iter()
.find(|f| f.0 == text)
.unwrap_or_else(|| panic!("no fragment {text:?} among {frags:?}"))
}
fn plain_text_of(pdf: Vec<u8>) -> String {
let reader = PdfReader::new(Cursor::new(pdf)).expect("synthetic PDF must parse");
let document = PdfDocument::new(reader);
let mut plain = PlainTextExtractor::with_config(PlainTextConfig {
line_break_mode: LineBreakMode::PreserveAll,
..Default::default()
});
plain.extract(&document, 0).expect("extract page 0").text
}
fn overflow_only_unwind_content() -> Vec<u8> {
let mut content = b"BT\n/F1 12 Tf\n100 700 Td\n10 TL\n(one)Tj\nET\n".to_vec();
content.extend_from_slice(&q_flood(CAP + OVERFLOW));
content.extend_from_slice(b"50 TL\n");
content.extend_from_slice(&q_unwind(OVERFLOW));
content.extend_from_slice(b"BT\n/F1 12 Tf\n100 400 Td\n(two)Tj\nT*\n(three)Tj\nET\n");
content
}
#[test]
fn q_operators_matching_dropped_pushes_restore_nothing() {
let frags = fragments_of(&overflow_only_unwind_content());
let three = fragment_named(&frags, "three");
assert!(
(three.2 - 350.0).abs() < 0.01,
"the {OVERFLOW} Q operators that pair with dropped pushes must restore nothing, so the \
leading in force is still the 50 set inside the flood and `three` sits at 350; got \
y = {} (390 means a Q consumed a snapshot that belongs to an outer level, restoring \
the leading of 10)",
three.2
);
}
#[test]
fn the_levels_within_the_cap_still_restore_exactly() {
let mut content = overflow_only_unwind_content();
content.extend_from_slice(&q_unwind(CAP));
content.extend_from_slice(b"BT\n/F1 12 Tf\n100 200 Td\n(four)Tj\nT*\n(five)Tj\nET\n");
let frags = fragments_of(&content);
let five = fragment_named(&frags, "five");
assert!(
(five.2 - 190.0).abs() < 0.01,
"after unwinding the whole flood the leading is the outer 10 again, so `five` sits at \
190; got y = {} (150 means the 50 set inside the flood survived the last Q)",
five.2
);
}
#[test]
fn unbalanced_q_after_a_flood_does_not_break_extraction() {
let mut content = overflow_only_unwind_content();
content.extend_from_slice(&q_unwind(CAP + 5));
content.extend_from_slice(b"20 TL\n");
content.extend_from_slice(&q_unwind(3));
content.extend_from_slice(b"BT\n/F1 12 Tf\n100 200 Td\n(four)Tj\nT*\n(five)Tj\nET\n");
let frags = fragments_of(&content);
let five = fragment_named(&frags, "five");
assert!(
(five.2 - 180.0).abs() < 0.01,
"with the stack empty, the extra Q operators must be ignored and the leading of 20 set \
after the unwind stays in force, so `five` sits at 180; got y = {}",
five.2
);
}
#[test]
fn the_plain_extractor_also_keeps_q_and_q_paired_under_a_flood() {
let mut content = b"BT\n/F1 12 Tf\n100 700 Td\n(one)Tj\nET\n".to_vec();
content.extend_from_slice(&q_flood(CAP + OVERFLOW));
content.extend_from_slice(b"50 TL\n");
content.extend_from_slice(&q_unwind(OVERFLOW));
content.extend_from_slice(b"BT\n/F1 12 Tf\n100 400 Td\n(two)Tj\nT*\n(three)Tj\nET\n");
let text = plain_text_of(build_pdf_with_content_stream(&content));
assert!(
text.contains("two\nthree"),
"the {OVERFLOW} Q operators pair with dropped pushes, so the leading of 50 is still in \
force and the final T* breaks the line; got {text:?} (\"twothree\" means a Q restored \
the outer leading of 0 and the T* stopped moving the pen)"
);
}
fn pdf_with_form_xobject(page_content: &[u8], xobject_content: &[u8]) -> Vec<u8> {
assemble_pdf(&[
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> \
/XObject << /X1 6 0 R >> >> /Contents 4 0 R >>"
.to_vec(),
stream_obj("", page_content),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
stream_obj(
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> >>",
xobject_content,
),
])
}
#[test]
fn the_overflow_count_does_not_cross_a_form_xobject_boundary() {
let mut page = b"BT /F1 12 Tf 100 700 Td 10 TL (one)Tj ET\n".to_vec();
page.extend_from_slice(&q_flood(CAP + OVERFLOW));
page.extend_from_slice(b"50 TL\n/X1 Do\n");
page.extend_from_slice(&q_unwind(OVERFLOW));
page.extend_from_slice(b"BT /F1 12 Tf 100 400 Td (two)Tj T* (three)Tj ET\n");
let xobject = b"q\n70 TL\nBT /F1 12 Tf 100 600 Td (inner)Tj T* (inner2)Tj ET\nQ\n";
let frags = fragments_of_pdf(pdf_with_form_xobject(&page, xobject));
let inner2 = fragment_named(&frags, "inner2");
assert!(
(inner2.2 - 530.0).abs() < 0.01,
"sanity: inside the form its own leading of 70 is in force, so `inner2` sits at 530; \
got y = {} (a form that inherited the page's overflow count would have its own q \
dropped or its Q eaten)",
inner2.2
);
let three = fragment_named(&frags, "three");
assert!(
(three.2 - 350.0).abs() < 0.01,
"back on the page, the {OVERFLOW} Q operators still pair with the page's dropped \
pushes, so the leading is the 50 set before the Do and `three` sits at 350; got \
y = {} (390 means the form ate part of the page's overflow count and a Q restored a \
real snapshot)",
three.2
);
}