Skip to content

Commit c6a236e

Browse files
committed
testing remove_headers
- remove_headers_removes_simple_page_number - passes - remove_headers_removes_word_plus_page_number - fails The second test hist the mechanism WITH the first-occurrence however, that seems like a bug... why should "page #" ever be kept?
1 parent 46dbfe7 commit c6a236e

1 file changed

Lines changed: 188 additions & 0 deletions

File tree

tests/test_remove_headers.rs

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
//! Tests for `remove_headers` page-number
2+
//! detector, which uses two purely structural signals — position (top or
3+
//! bottom margin band) and shape (a short number, or a number that
4+
//! varies page to page) — with no concept of "page number" beyond that.
5+
6+
use pdf_oxide::PdfDocument;
7+
8+
// ---------------- test helper: build_pdf_with_page_extras -------------------
9+
//
10+
// two fn used only by build_pdf_with_page_extras write one object each,
11+
// recording its offset as they go:
12+
// - `buf` the buffer we're writing into
13+
// - `off[id]` start of object definition
14+
15+
// -- write a plain dictionary object --
16+
fn obj(buf: &mut Vec<u8>, off: &mut [usize], id: usize, body: &str) {
17+
off[id] = buf.len();
18+
buf.extend_from_slice(format!("{id} 0 obj\n{body}\nendobj\n").as_bytes());
19+
}
20+
21+
// -- write a `stream` object - used here for page content --
22+
fn stream(buf: &mut Vec<u8>, off: &mut [usize], id: usize, data: &[u8]) {
23+
off[id] = buf.len();
24+
buf.extend_from_slice(format!("{id} 0 obj\n<< /Length {} >>\nstream\n", data.len()).as_bytes());
25+
buf.extend_from_slice(data);
26+
buf.extend_from_slice(b"\nendstream\nendobj\n");
27+
}
28+
29+
/// Minimal single-page-content PDF builder: N pages, each with a body
30+
/// paragraph plus arbitrary extra content-stream text supplied per page.
31+
fn build_pdf_with_page_extras(
32+
page_count: usize,
33+
extra_per_page: impl Fn(usize) -> String,
34+
) -> Vec<u8> {
35+
// buffer for the PDF we're building
36+
let mut buf: Vec<u8> = Vec::new();
37+
38+
// `off[N]` = byte offset where object N's bytes start, filled in as
39+
// each object is written below. `xref_off` (further down) separately
40+
// records where the xref table itself starts.
41+
let mut off = vec![0usize; 4 + page_count * 2];
42+
43+
// PDF File header
44+
buf.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
45+
46+
// Catalog
47+
obj(&mut buf, &mut off, 1, "<< /Type /Catalog /Pages 2 0 R >>");
48+
49+
// Pages tree root
50+
// Build the /Kids array value ahead of time: "5 0 R 7 0 R 9 0 R ..."
51+
// — one indirect reference per page object we're about to create.
52+
// (Page objects are 5, 7, 9, ... because each page also needs a
53+
// content-stream object right before it: 4, 6, 8, ... — see the loop
54+
// below.)
55+
let kids: String = (0..page_count)
56+
.map(|i| format!("{} 0 R", 5 + i * 2))
57+
.collect::<Vec<_>>()
58+
.join(" ");
59+
obj(
60+
&mut buf,
61+
&mut off,
62+
2,
63+
&format!("<< /Type /Pages /Kids [{kids}] /Count {page_count} >>"),
64+
);
65+
66+
// Font resource
67+
obj(
68+
&mut buf,
69+
&mut off,
70+
3,
71+
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
72+
);
73+
74+
// --- One content stream + one page object, per page ---
75+
for i in 0..page_count {
76+
let content_id = 4 + i * 2; // 4, 6, 8, ...
77+
let page_id = 5 + i * 2; // 5, 7, 9, ...
78+
79+
// text object per page + whatever the test wants to insert
80+
let content = format!(
81+
"BT /F1 12 Tf 1 0 0 1 72 400 Tm (Body text placeholder) Tj ET\n{}",
82+
extra_per_page(i)
83+
);
84+
stream(&mut buf, &mut off, content_id, content.as_bytes());
85+
86+
// Page object:
87+
// physical size - `/MediaBox`, in points `[0 0 612 792]` is US Letter
88+
// resources it can reference by name - `/Resources`
89+
// - just our one font as `/F1`
90+
// object w/ drawing instructions `/Contents` with content-stream object
91+
obj(
92+
&mut buf,
93+
&mut off,
94+
page_id,
95+
&format!(
96+
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
97+
/Resources << /Font << /F1 3 0 R >> >> /Contents {content_id} 0 R >>"
98+
),
99+
);
100+
}
101+
102+
// Cross-reference table - `xref_off`
103+
// flat table mapping object number -> byte offset
104+
// records where THIS table itself starts (needed for the trailer).
105+
let xref_off = buf.len();
106+
let total_objs = off.len();
107+
buf.extend_from_slice(format!("xref\n0 {}\n", total_objs).as_bytes());
108+
109+
// fixed, required first entry marking object 0 as "free"
110+
buf.extend_from_slice(b"0000000000 65535 f \n");
111+
112+
// one `NNNNNNNNNN 00000 n` line per real object, `n` meaning "in use",
113+
// giving its 10-digit zero-padded byte offset
114+
for offset in &off[1..] {
115+
buf.extend_from_slice(format!("{:010} 00000 n \n", offset).as_bytes());
116+
}
117+
118+
// Trailer
119+
buf.extend_from_slice(
120+
format!(
121+
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n",
122+
total_objs, xref_off
123+
)
124+
.as_bytes(),
125+
);
126+
buf
127+
}
128+
129+
/// The simplest, most basic case: a bare page number ("1", "2", "3", ...)
130+
/// alone on its own line in the header band, changing every page — the
131+
/// textbook definition of a page number. `is_bare_page_number_text` marks
132+
/// this unconditionally per page (isolated + short + digits-only), with
133+
/// no cross-page signature lookup and no first-occurrence exemption
134+
/// involved, so this is core, expected behavior that should already work
135+
/// correctly on `main`.
136+
#[test]
137+
fn remove_headers_removes_simple_page_number() {
138+
let bytes = build_pdf_with_page_extras(5, |i| {
139+
format!("BT /F1 10 Tf 1 0 0 1 300 760 Tm ({}) Tj ET\n", i + 1)
140+
});
141+
let doc = PdfDocument::from_bytes(bytes).unwrap();
142+
doc.remove_headers(0.5).unwrap();
143+
144+
for page in 0..5 {
145+
let text = doc.extract_text(page).unwrap();
146+
assert!(
147+
text.contains("Body text placeholder"),
148+
"page {page}: body wrongly removed: {text:?}"
149+
);
150+
let page_number = format!("{}", page + 1);
151+
assert!(
152+
!text.contains(&page_number),
153+
"page {page}: page number {page_number:?} should have been removed: {text:?}"
154+
);
155+
}
156+
}
157+
158+
/// One step up from a bare digit: "page 1", "page 2", ... — not digits-only
159+
/// (it has letters too), so `is_bare_page_number_text` doesn't apply at
160+
/// all. This has to go through the cross-page signature detector instead:
161+
/// `normalize_artifact_signature` turns "page 1" into "page #", sees that
162+
/// shape recur across pages with the digit changing, and flags it as a
163+
/// page number that way.
164+
#[test]
165+
fn remove_headers_removes_word_plus_page_number() {
166+
let bytes = build_pdf_with_page_extras(5, |i| {
167+
format!("BT /F1 10 Tf 1 0 0 1 280 760 Tm (page {}) Tj ET\n", i + 1)
168+
});
169+
let doc = PdfDocument::from_bytes(bytes).unwrap();
170+
doc.remove_headers(0.5).unwrap();
171+
172+
for page in 0..5 {
173+
let text = doc.extract_text(page).unwrap();
174+
assert!(
175+
text.contains("Body text placeholder"),
176+
"page {page}: body wrongly removed: {text:?}"
177+
);
178+
}
179+
180+
for page in 0..5 {
181+
let text = doc.extract_text(page).unwrap();
182+
let marker = format!("page {}", page + 1);
183+
assert!(
184+
!text.contains(&marker),
185+
"page {page}: marker {marker:?} should have been removed: {text:?}"
186+
);
187+
}
188+
}

0 commit comments

Comments
 (0)