Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
120f7f5197 |
@@ -43,6 +43,10 @@ unicode-normalization = "0.1"
|
||||
# TrueType font parsing (for Identity-H CID font cmap extraction)
|
||||
ttf-parser = "0.25"
|
||||
|
||||
# Incremental Flate inflate so detector scans can stop before a highly
|
||||
# compressible stream materializes a multi-gigabyte buffer.
|
||||
flate2 = "1.1"
|
||||
|
||||
# Native builds keep lopdf's parallel parser and CLI logging. Browser WASM is
|
||||
# deliberately single-threaded so it works without cross-origin isolation.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
||||
+118
-7
@@ -4,6 +4,7 @@
|
||||
//! by sampling content streams for text operators (Tj/TJ) without loading
|
||||
//! all objects.
|
||||
|
||||
use crate::stream_decode::stream_content_for_scan;
|
||||
use crate::PdfError;
|
||||
use lopdf::{Document, Object, ObjectId};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -765,10 +766,7 @@ fn analyze_page_content(doc: &Document, page_id: ObjectId) -> PageAnalysis {
|
||||
|
||||
for content_id in content_streams {
|
||||
if let Ok(Object::Stream(stream)) = doc.get_object(content_id) {
|
||||
let content = match stream.decompressed_content() {
|
||||
Ok(data) => data,
|
||||
Err(_) => stream.content.clone(),
|
||||
};
|
||||
let content = stream_content_for_scan(stream);
|
||||
|
||||
// Scan for text operators, collecting raw font names
|
||||
let mut page_font_names: HashSet<Vec<u8>> = HashSet::new();
|
||||
@@ -1303,9 +1301,7 @@ fn scan_xobjects_in_resources(
|
||||
.and_then(|o| o.as_name().ok());
|
||||
match subtype {
|
||||
Some(b"Form") => {
|
||||
let content = stream
|
||||
.decompressed_content()
|
||||
.unwrap_or_else(|_| stream.content.clone());
|
||||
let content = stream_content_for_scan(stream);
|
||||
// Collect raw font names from this XObject's content stream
|
||||
let mut xobj_font_names: HashSet<Vec<u8>> = HashSet::new();
|
||||
let (ops, imgs, paths, fonts) = scan_content_for_text_operators(
|
||||
@@ -3918,4 +3914,119 @@ mod tests {
|
||||
"P3: inherited decodable font should be detected as used"
|
||||
);
|
||||
}
|
||||
|
||||
fn flate_content(plain: &[u8]) -> lopdf::Stream {
|
||||
use flate2::write::ZlibEncoder;
|
||||
use flate2::Compression;
|
||||
use lopdf::dictionary;
|
||||
use std::io::Write;
|
||||
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
|
||||
encoder.write_all(plain).unwrap();
|
||||
lopdf::Stream::new(
|
||||
dictionary! { "Filter" => "FlateDecode" },
|
||||
encoder.finish().unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flate_page_content_still_finds_text_operators() {
|
||||
use lopdf::dictionary;
|
||||
let mut doc = Document::with_version("1.4");
|
||||
let pages_id = doc.new_object_id();
|
||||
let page_id = doc.new_object_id();
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => Object::Name(b"Type1".to_vec()),
|
||||
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
|
||||
});
|
||||
let content_id = doc.add_object(Object::Stream(flate_content(
|
||||
b"BT /F1 12 Tf (Hello world) Tj ET",
|
||||
)));
|
||||
doc.objects.insert(
|
||||
page_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => Object::Reference(pages_id),
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! {
|
||||
"F1" => Object::Reference(font_id),
|
||||
},
|
||||
},
|
||||
"Contents" => Object::Reference(content_id),
|
||||
}),
|
||||
);
|
||||
doc.objects.insert(
|
||||
pages_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => vec![Object::Reference(page_id)],
|
||||
"Count" => Object::Integer(1),
|
||||
}),
|
||||
);
|
||||
|
||||
let analysis = analyze_page_content(&doc, page_id);
|
||||
assert!(
|
||||
analysis.text_operator_count > 0,
|
||||
"bounded Flate decode must still see ordinary page text operators"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flate_form_xobject_still_finds_text_operators() {
|
||||
use lopdf::dictionary;
|
||||
let mut doc = Document::with_version("1.4");
|
||||
let pages_id = doc.new_object_id();
|
||||
let page_id = doc.new_object_id();
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => Object::Name(b"Type1".to_vec()),
|
||||
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
|
||||
});
|
||||
let form_id = doc.add_object(Object::Stream(flate_content(
|
||||
b"BT /F1 12 Tf (Form text) Tj ET",
|
||||
)));
|
||||
if let Object::Stream(form) = doc.objects.get_mut(&form_id).unwrap() {
|
||||
form.dict.set("Type", Object::Name(b"XObject".to_vec()));
|
||||
form.dict.set("Subtype", Object::Name(b"Form".to_vec()));
|
||||
form.dict.set(
|
||||
"Resources",
|
||||
dictionary! {
|
||||
"Font" => dictionary! {
|
||||
"F1" => Object::Reference(font_id),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
let page_content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
|
||||
dictionary! {},
|
||||
b"/Fm0 Do".to_vec(),
|
||||
)));
|
||||
doc.objects.insert(
|
||||
page_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => Object::Reference(pages_id),
|
||||
"Resources" => dictionary! {
|
||||
"XObject" => dictionary! {
|
||||
"Fm0" => Object::Reference(form_id),
|
||||
},
|
||||
},
|
||||
"Contents" => Object::Reference(page_content_id),
|
||||
}),
|
||||
);
|
||||
doc.objects.insert(
|
||||
pages_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => vec![Object::Reference(page_id)],
|
||||
"Count" => Object::Integer(1),
|
||||
}),
|
||||
);
|
||||
|
||||
let analysis = analyze_page_content(&doc, page_id);
|
||||
assert!(
|
||||
analysis.text_operator_count > 0,
|
||||
"bounded Flate decode must still see Form XObject text operators"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+47
-170
@@ -462,95 +462,19 @@ fn try_xy_cut_split(
|
||||
])
|
||||
}
|
||||
|
||||
/// A prose line must span at least this fraction of its column's width to
|
||||
/// count as "full" — shared by the gate's ratio and run measurements.
|
||||
const LINE_FILL_THRESHOLD: f32 = 0.45;
|
||||
|
||||
/// Per-column line measurements backing the prose-evidence predicates in
|
||||
/// [`columns_have_prose`]: how many grouped lines exist, how many are
|
||||
/// "full" (span most of the column), the longest *vertically contiguous*
|
||||
/// run of full lines, and the item count per line.
|
||||
#[derive(Default)]
|
||||
struct ProseLineStats {
|
||||
full_lines: usize,
|
||||
total_lines: usize,
|
||||
total_items: usize,
|
||||
current_run: usize,
|
||||
best_run: usize,
|
||||
previous_line_y: Option<f32>,
|
||||
previous_line_height: f32,
|
||||
}
|
||||
|
||||
impl ProseLineStats {
|
||||
/// A run only counts as a paragraph block while lines follow at normal
|
||||
/// leading; a full line resuming after a figure-sized vertical gap
|
||||
/// starts a new run rather than extending the previous one.
|
||||
const MAX_LEADING_FACTOR: f32 = 2.5;
|
||||
|
||||
fn flush_line(&mut self, line_items: &[&TextItem], col: &ColumnRegion, col_width: f32) {
|
||||
if line_items.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.total_lines += 1;
|
||||
self.total_items += line_items.len();
|
||||
|
||||
let line_y = line_items[0].y;
|
||||
let line_height = line_items
|
||||
.iter()
|
||||
.map(|i| i.height)
|
||||
.fold(0.0_f32, f32::max)
|
||||
.max(1.0);
|
||||
if let Some(previous_y) = self.previous_line_y {
|
||||
let leading = (previous_y - line_y).abs();
|
||||
if leading > self.previous_line_height.max(line_height) * Self::MAX_LEADING_FACTOR {
|
||||
self.current_run = 0;
|
||||
}
|
||||
}
|
||||
self.previous_line_y = Some(line_y);
|
||||
self.previous_line_height = line_height;
|
||||
|
||||
// Compute the span of text on this line within the column
|
||||
let left = line_items
|
||||
.iter()
|
||||
.map(|i| i.x.max(col.x_min))
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let right = line_items
|
||||
.iter()
|
||||
.map(|i| (i.x + effective_width(i)).min(col.x_max))
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let span = (right - left).max(0.0);
|
||||
if span >= col_width * LINE_FILL_THRESHOLD {
|
||||
self.full_lines += 1;
|
||||
self.current_run += 1;
|
||||
self.best_run = self.best_run.max(self.current_run);
|
||||
} else {
|
||||
self.current_run = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether each proposed column contains paragraph-like content.
|
||||
///
|
||||
/// Groups items per column into rough lines by Y-proximity, then measures
|
||||
/// what fraction of those lines span a significant portion of the column
|
||||
/// width. Two-column prose (justified or ragged-right) produces lines that
|
||||
/// fill most of the column width. Tables, forms, and checklists produce
|
||||
/// short scattered items that don't. A column whose global ratio is diluted
|
||||
/// by a figure still qualifies through a sustained run of consecutive
|
||||
/// full-width lines at normal leading — a paragraph block scattered
|
||||
/// layouts cannot produce.
|
||||
/// short scattered items that don't.
|
||||
///
|
||||
/// Returns true only when *every* column passes the prose evidence.
|
||||
/// Returns true only when *every* column passes a minimum prose density.
|
||||
fn columns_have_prose(columns: &[ColumnRegion], items: &[&TextItem]) -> bool {
|
||||
const Y_TOL: f32 = 3.0; // y-proximity to group items into the same line
|
||||
const MIN_PROSE_RATIO: f32 = 0.40; // ≥40% of lines must be "full"...
|
||||
// ...or the column contains a sustained paragraph block: this many
|
||||
// CONSECUTIVE full lines. A prose column hosting a figure + caption can
|
||||
// fall under the global ratio (the fragments dilute it), but the
|
||||
// scattered layouts this gate exists to reject — tables, TOCs,
|
||||
// checklists, forms — cannot produce an unbroken block of full-width
|
||||
// lines.
|
||||
const MIN_PROSE_RUN: usize = 6;
|
||||
const LINE_FILL_THRESHOLD: f32 = 0.45; // line must span ≥45% of column width
|
||||
const MIN_PROSE_RATIO: f32 = 0.40; // ≥40% of lines must be "full"
|
||||
const MIN_LINES: usize = 8; // need enough lines to judge
|
||||
const MIN_COL_WIDTH: f32 = 120.0; // columns must be ≥120pt (not narrow sidebars/fragments)
|
||||
const MAX_AVG_ITEMS_PER_LINE: f32 = 3.5; // prose has 1-3 items/line; tables/forms have 4+
|
||||
@@ -580,10 +504,36 @@ fn columns_have_prose(columns: &[ColumnRegion], items: &[&TextItem]) -> bool {
|
||||
sorted.sort_by(|a, b| b.y.total_cmp(&a.y));
|
||||
|
||||
// Group into lines by Y-proximity and measure fill + item count
|
||||
let mut stats = ProseLineStats::default();
|
||||
let mut full_lines = 0usize;
|
||||
let mut total_lines = 0usize;
|
||||
let mut total_items_in_lines = 0usize;
|
||||
let mut line_items: Vec<&TextItem> = Vec::new();
|
||||
let mut line_y = f32::NAN;
|
||||
|
||||
let flush_line = |line_items: &[&TextItem],
|
||||
full: &mut usize,
|
||||
total: &mut usize,
|
||||
total_items: &mut usize| {
|
||||
if line_items.is_empty() {
|
||||
return;
|
||||
}
|
||||
*total += 1;
|
||||
*total_items += line_items.len();
|
||||
// Compute the span of text on this line within the column
|
||||
let left = line_items
|
||||
.iter()
|
||||
.map(|i| i.x.max(col.x_min))
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let right = line_items
|
||||
.iter()
|
||||
.map(|i| (i.x + effective_width(i)).min(col.x_max))
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let span = (right - left).max(0.0);
|
||||
if span >= col_width * LINE_FILL_THRESHOLD {
|
||||
*full += 1;
|
||||
}
|
||||
};
|
||||
|
||||
for item in &sorted {
|
||||
if line_items.is_empty() || (line_y - item.y).abs() < Y_TOL {
|
||||
if line_items.is_empty() {
|
||||
@@ -591,29 +541,35 @@ fn columns_have_prose(columns: &[ColumnRegion], items: &[&TextItem]) -> bool {
|
||||
}
|
||||
line_items.push(item);
|
||||
} else {
|
||||
stats.flush_line(&line_items, col, col_width);
|
||||
flush_line(
|
||||
&line_items,
|
||||
&mut full_lines,
|
||||
&mut total_lines,
|
||||
&mut total_items_in_lines,
|
||||
);
|
||||
line_items.clear();
|
||||
line_y = item.y;
|
||||
line_items.push(item);
|
||||
}
|
||||
}
|
||||
stats.flush_line(&line_items, col, col_width);
|
||||
flush_line(
|
||||
&line_items,
|
||||
&mut full_lines,
|
||||
&mut total_lines,
|
||||
&mut total_items_in_lines,
|
||||
);
|
||||
|
||||
if stats.total_lines < MIN_LINES {
|
||||
if total_lines < MIN_LINES {
|
||||
return false;
|
||||
}
|
||||
|
||||
let full_lines = stats.full_lines;
|
||||
let total_lines = stats.total_lines;
|
||||
let best_run = stats.best_run;
|
||||
let total_items_in_lines = stats.total_items;
|
||||
let ratio = full_lines as f32 / total_lines as f32;
|
||||
let avg_items = total_items_in_lines as f32 / total_lines as f32;
|
||||
debug!(
|
||||
"columns_have_prose: col [{:.0}..{:.0}] lines={} full={} ratio={:.2} run={} avg_items={:.1}",
|
||||
col.x_min, col.x_max, total_lines, full_lines, ratio, best_run, avg_items
|
||||
"columns_have_prose: col [{:.0}..{:.0}] lines={} full={} ratio={:.2} avg_items={:.1}",
|
||||
col.x_min, col.x_max, total_lines, full_lines, ratio, avg_items
|
||||
);
|
||||
if ratio < MIN_PROSE_RATIO && best_run < MIN_PROSE_RUN {
|
||||
if ratio < MIN_PROSE_RATIO {
|
||||
return false;
|
||||
}
|
||||
// Tables and forms tend to have many small items per line (one per cell),
|
||||
@@ -2611,85 +2567,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prose_gate_accepts_figure_diluted_column_via_run() {
|
||||
// A prose column hosting a figure: 9 consecutive full-width lines
|
||||
// (a paragraph block) followed by many short caption/figure
|
||||
// fragments. The global full-line ratio falls under 40%, but the
|
||||
// sustained run proves flowing prose.
|
||||
let mut items: Vec<TextItem> = Vec::new();
|
||||
for line in 0..9 {
|
||||
// full-width prose line, ~230pt wide in a 250pt column
|
||||
items.push(make_item(
|
||||
1,
|
||||
10.0,
|
||||
700.0 - line as f32 * 14.0,
|
||||
&"m".repeat(38),
|
||||
));
|
||||
}
|
||||
for line in 0..16 {
|
||||
// short figure/caption fragments
|
||||
items.push(make_item(1, 60.0, 560.0 - line as f32 * 14.0, "cap"));
|
||||
}
|
||||
let column = ColumnRegion {
|
||||
x_min: 0.0,
|
||||
x_max: 250.0,
|
||||
};
|
||||
let refs: Vec<&TextItem> = items.iter().collect();
|
||||
assert!(columns_have_prose(&[column], &refs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prose_gate_run_requires_vertical_continuity() {
|
||||
// Full-width lines separated by figure-sized vertical gaps are not
|
||||
// a paragraph block: the run must reset across large leading, so a
|
||||
// column of scattered wide labels stays rejected.
|
||||
let mut items: Vec<TextItem> = Vec::new();
|
||||
for line in 0..6 {
|
||||
// Wide labels, sequence-consecutive but 90pt apart — far beyond
|
||||
// normal leading. Without the continuity rule they would count
|
||||
// as a 6-line paragraph block.
|
||||
items.push(make_item(
|
||||
1,
|
||||
10.0,
|
||||
720.0 - line as f32 * 90.0,
|
||||
&"m".repeat(38),
|
||||
));
|
||||
}
|
||||
for line in 0..12 {
|
||||
// Short fragments below, keeping total lines high and the
|
||||
// global full-line ratio (6/18) under the 40% bar.
|
||||
items.push(make_item(1, 60.0, 150.0 - line as f32 * 12.0, "box"));
|
||||
}
|
||||
let column = ColumnRegion {
|
||||
x_min: 0.0,
|
||||
x_max: 250.0,
|
||||
};
|
||||
let refs: Vec<&TextItem> = items.iter().collect();
|
||||
assert!(!columns_have_prose(&[column], &refs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prose_gate_rejects_scattered_short_lines() {
|
||||
// Checklist/form-like column: no sustained block of full lines and
|
||||
// a low global ratio must still be rejected.
|
||||
let mut items: Vec<TextItem> = Vec::new();
|
||||
for line in 0..24 {
|
||||
let text = if line % 4 == 0 {
|
||||
"m".repeat(38)
|
||||
} else {
|
||||
"box".to_string()
|
||||
};
|
||||
items.push(make_item(1, 10.0, 700.0 - line as f32 * 14.0, &text));
|
||||
}
|
||||
let column = ColumnRegion {
|
||||
x_min: 0.0,
|
||||
x_max: 250.0,
|
||||
};
|
||||
let refs: Vec<&TextItem> = items.iter().collect();
|
||||
assert!(!columns_have_prose(&[column], &refs));
|
||||
}
|
||||
|
||||
/// Generate dense items in a horizontal zone across many Y positions.
|
||||
/// Items are placed with overlapping coverage so no intra-zone valleys appear.
|
||||
fn fill_zone(page: u32, x_start: f32, x_end: f32, y_start: f32, y_end: f32) -> Vec<TextItem> {
|
||||
|
||||
@@ -37,6 +37,7 @@ pub mod extractor;
|
||||
pub mod glyph_names;
|
||||
pub mod markdown;
|
||||
pub mod process_mode;
|
||||
mod stream_decode;
|
||||
pub mod structure_tree;
|
||||
pub mod tables;
|
||||
mod text_quality;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Bounded stream decompression for detector scans.
|
||||
//!
|
||||
//! `lopdf::Stream::decompressed_content` materializes the full decoded buffer
|
||||
//! before any caller can apply a limit. A few megabytes of Flate-compressed
|
||||
//! zeros can therefore expand to gigabytes. These helpers stop inflate once
|
||||
//! the decoded budget is reached.
|
||||
|
||||
use flate2::read::{DeflateDecoder, ZlibDecoder};
|
||||
use lopdf::Stream;
|
||||
use std::io::Read;
|
||||
|
||||
/// Maximum decoded bytes held for a single content stream during detection.
|
||||
pub(crate) const MAX_DECOMPRESSED_STREAM_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// Decode `stream` for scanning, or return an empty buffer when the decoded
|
||||
/// size would exceed `max_bytes`.
|
||||
pub(crate) fn stream_content_for_scan(stream: &Stream) -> Vec<u8> {
|
||||
match decompressed_content_bounded(stream, MAX_DECOMPRESSED_STREAM_BYTES) {
|
||||
Some(data) => data,
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental decode with a hard output cap. `None` means the stream is
|
||||
/// larger than `max_bytes` (or not safely decodable within that budget).
|
||||
pub(crate) fn decompressed_content_bounded(stream: &Stream, max_bytes: usize) -> Option<Vec<u8>> {
|
||||
let filters = match stream.filters() {
|
||||
Ok(filters) => filters,
|
||||
Err(_) => {
|
||||
return take_if_within_budget(&stream.content, max_bytes);
|
||||
}
|
||||
};
|
||||
|
||||
if filters.is_empty() {
|
||||
return take_if_within_budget(&stream.content, max_bytes);
|
||||
}
|
||||
|
||||
// Plain Flate is the highly compressible case. Detector scans only need
|
||||
// the inflated operator bytes; skip PNG predictors here so inflate can
|
||||
// stop at the budget instead of materializing the full buffer first.
|
||||
if filters.len() == 1 && filters[0] == b"FlateDecode" {
|
||||
return inflate_flate_bounded(&stream.content, max_bytes);
|
||||
}
|
||||
|
||||
if stream.content.len() > max_bytes {
|
||||
return None;
|
||||
}
|
||||
match stream.decompressed_content() {
|
||||
Ok(data) if data.len() <= max_bytes => Some(data),
|
||||
Ok(_) => None,
|
||||
Err(_) => take_if_within_budget(&stream.content, max_bytes),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_if_within_budget(bytes: &[u8], max_bytes: usize) -> Option<Vec<u8>> {
|
||||
if bytes.len() > max_bytes {
|
||||
None
|
||||
} else {
|
||||
Some(bytes.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
fn inflate_flate_bounded(input: &[u8], max_bytes: usize) -> Option<Vec<u8>> {
|
||||
if input.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
match read_bounded(ZlibDecoder::new(input), max_bytes) {
|
||||
Some(data) => Some(data),
|
||||
None if input.len() > 2 => read_bounded(DeflateDecoder::new(&input[2..]), max_bytes),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bounded<R: Read>(mut decoder: R, max_bytes: usize) -> Option<Vec<u8>> {
|
||||
let mut output = Vec::new();
|
||||
let mut buf = [0u8; 16 * 1024];
|
||||
loop {
|
||||
match decoder.read(&mut buf) {
|
||||
Ok(0) => return Some(output),
|
||||
Ok(n) => {
|
||||
if output.len().saturating_add(n) > max_bytes {
|
||||
return None;
|
||||
}
|
||||
output.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use flate2::write::ZlibEncoder;
|
||||
use flate2::Compression;
|
||||
use lopdf::dictionary;
|
||||
use std::io::Write;
|
||||
|
||||
fn flate_stream(plain: &[u8]) -> Stream {
|
||||
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
|
||||
encoder.write_all(plain).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
Stream::new(dictionary! { "Filter" => "FlateDecode" }, compressed)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_flate_stream_round_trips() {
|
||||
let plain = b"BT /F1 12 Tf (Hello world) Tj ET";
|
||||
let stream = flate_stream(plain);
|
||||
assert_eq!(
|
||||
decompressed_content_bounded(&stream, MAX_DECOMPRESSED_STREAM_BYTES).as_deref(),
|
||||
Some(plain.as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highly_compressible_flate_stops_at_budget() {
|
||||
let plain = vec![0u8; 256 * 1024];
|
||||
let stream = flate_stream(&plain);
|
||||
assert!(
|
||||
stream.content.len() < 8 * 1024,
|
||||
"fixture must stay compact on disk, got {} compressed bytes",
|
||||
stream.content.len()
|
||||
);
|
||||
assert!(decompressed_content_bounded(&stream, 16 * 1024).is_none());
|
||||
assert_eq!(
|
||||
decompressed_content_bounded(&stream, 256 * 1024).as_deref(),
|
||||
Some(plain.as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_over_budget_is_skipped() {
|
||||
let stream = Stream::new(dictionary! {}, vec![b'x'; 64]);
|
||||
assert!(decompressed_content_bounded(&stream, 32).is_none());
|
||||
assert_eq!(decompressed_content_bounded(&stream, 64).unwrap().len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_helper_returns_empty_when_capped() {
|
||||
let stream = flate_stream(&vec![0u8; 64 * 1024]);
|
||||
// Production cap is far above 64 KiB, so this still decodes.
|
||||
assert_eq!(stream_content_for_scan(&stream).len(), 64 * 1024);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user