Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d4138805f | ||
|
|
1618387a62 | ||
|
|
87ccab733a |
@@ -1,328 +0,0 @@
|
||||
//! Bounded content-stream decoding.
|
||||
//!
|
||||
//! `lopdf::content::Content::decode` materializes every operator before any
|
||||
//! caller can apply a limit. A compact page of `q Q` pairs can therefore
|
||||
//! allocate hundreds of megabytes and abort. Count operators first (without
|
||||
//! allocating `Operation` objects) and skip decode when the cap is exceeded.
|
||||
|
||||
use crate::PdfError;
|
||||
use lopdf::content::Content;
|
||||
|
||||
/// Maximum content-stream operators decoded for a page or a single Form
|
||||
/// XObject. Matches the previous post-decode skip threshold.
|
||||
pub(crate) const MAX_PAGE_OPERATIONS: usize = 1_000_000;
|
||||
|
||||
/// Decode `data` unless it contains more than `max_operations` operators.
|
||||
///
|
||||
/// Returns `Ok(None)` when the stream exceeds the cap, so callers can skip
|
||||
/// extraction without first allocating the operation vector.
|
||||
pub(crate) fn decode_content_bounded(
|
||||
data: &[u8],
|
||||
max_operations: usize,
|
||||
) -> Result<Option<Content>, PdfError> {
|
||||
if content_exceeds_operation_limit(data, max_operations) {
|
||||
return Ok(None);
|
||||
}
|
||||
Content::decode(data)
|
||||
.map(Some)
|
||||
.map_err(|e| PdfError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn content_exceeds_operation_limit(data: &[u8], max_operations: usize) -> bool {
|
||||
count_content_operators(data, max_operations.saturating_add(1)) > max_operations
|
||||
}
|
||||
|
||||
/// Count operators using the same token rules as lopdf's content parser,
|
||||
/// stopping at `limit`. Does not allocate `Operation` / `Object` values.
|
||||
fn count_content_operators(data: &[u8], limit: usize) -> usize {
|
||||
let mut i = 0;
|
||||
let mut count = 0;
|
||||
while i < data.len() && count < limit {
|
||||
skip_content_space(data, &mut i);
|
||||
if i >= data.len() {
|
||||
break;
|
||||
}
|
||||
if data[i] == b'%' {
|
||||
skip_comment(data, &mut i);
|
||||
continue;
|
||||
}
|
||||
match data[i] {
|
||||
b'(' => i = skip_literal_string(data, i),
|
||||
b'<' => {
|
||||
if data.get(i + 1) == Some(&b'<') {
|
||||
i += 2;
|
||||
} else {
|
||||
i = skip_hex_string(data, i);
|
||||
}
|
||||
}
|
||||
b'>' => {
|
||||
i += 1;
|
||||
if data.get(i) == Some(&b'>') {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'[' | b']' => i += 1,
|
||||
b'/' => skip_name(data, &mut i),
|
||||
b'+' | b'-' | b'.' => skip_number(data, &mut i),
|
||||
b if b.is_ascii_digit() => skip_number(data, &mut i),
|
||||
b if is_operator_byte(b) => {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < data.len() && is_operator_byte(data[i]) {
|
||||
i += 1;
|
||||
}
|
||||
let token = &data[start..i];
|
||||
if token == b"true" || token == b"false" || token == b"null" {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
if token == b"BI" && (i >= data.len() || is_content_space(data[i])) {
|
||||
i = skip_inline_image_after_bi(data, i);
|
||||
}
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn is_content_space(b: u8) -> bool {
|
||||
// PDF whitespace (ISO 32000): NUL, tab, LF, FF, CR, space. Names must
|
||||
// stop on these so a following operator is not absorbed into `/Name`.
|
||||
matches!(b, b'\0' | b'\t' | b'\n' | b'\x0c' | b'\r' | b' ')
|
||||
}
|
||||
|
||||
fn is_operator_byte(b: u8) -> bool {
|
||||
b.is_ascii_alphabetic() || matches!(b, b'*' | b'\'' | b'"')
|
||||
}
|
||||
|
||||
fn is_delimiter(b: u8) -> bool {
|
||||
matches!(
|
||||
b,
|
||||
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
|
||||
)
|
||||
}
|
||||
|
||||
fn skip_content_space(data: &[u8], i: &mut usize) {
|
||||
while *i < data.len() && is_content_space(data[*i]) {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_comment(data: &[u8], i: &mut usize) {
|
||||
while *i < data.len() && data[*i] != b'\n' && data[*i] != b'\r' {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_literal_string(data: &[u8], mut i: usize) -> usize {
|
||||
let mut depth = 1i32;
|
||||
i += 1;
|
||||
while i < data.len() && depth > 0 {
|
||||
match data[i] {
|
||||
b'\\' => {
|
||||
i += 1;
|
||||
if i < data.len() {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'(' => {
|
||||
depth += 1;
|
||||
i += 1;
|
||||
}
|
||||
b')' => {
|
||||
depth -= 1;
|
||||
i += 1;
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn skip_hex_string(data: &[u8], mut i: usize) -> usize {
|
||||
i += 1;
|
||||
while i < data.len() && data[i] != b'>' {
|
||||
i += 1;
|
||||
}
|
||||
if i < data.len() {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn skip_name(data: &[u8], i: &mut usize) {
|
||||
*i += 1;
|
||||
while *i < data.len() && !is_content_space(data[*i]) && !is_delimiter(data[*i]) {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_number(data: &[u8], i: &mut usize) {
|
||||
if *i < data.len() && matches!(data[*i], b'+' | b'-') {
|
||||
*i += 1;
|
||||
}
|
||||
while *i < data.len() && data[*i].is_ascii_digit() {
|
||||
*i += 1;
|
||||
}
|
||||
if *i < data.len() && data[*i] == b'.' {
|
||||
*i += 1;
|
||||
while *i < data.len() && data[*i].is_ascii_digit() {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After a `BI` operator, skip inline-image data through `EI`.
|
||||
/// Uses the same PDF whitespace set as `is_content_space`. If `EI` is not
|
||||
/// found, leave the cursor in place so later operators are still counted
|
||||
/// (undercounting would let decode allocate the full vector).
|
||||
fn skip_inline_image_after_bi(data: &[u8], mut i: usize) -> usize {
|
||||
skip_content_space(data, &mut i);
|
||||
let rest = &data[i..];
|
||||
if let Some(pos) = rest.windows(4).position(|w| {
|
||||
is_content_space(w[0]) && w[1] == b'E' && w[2] == b'I' && is_content_space(w[3])
|
||||
}) {
|
||||
return i + pos + 3;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lopdf_op_count(data: &[u8]) -> usize {
|
||||
Content::decode(data)
|
||||
.map(|c| c.operations.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// DoS safety: never report fewer operators than lopdf would allocate.
|
||||
/// Overcount is acceptable (skip a page); undercount would re-open decode.
|
||||
fn assert_count_does_not_undercount(data: &[u8]) {
|
||||
let ours = count_content_operators(data, usize::MAX);
|
||||
match Content::decode(data) {
|
||||
Ok(content) => assert!(
|
||||
ours >= content.operations.len(),
|
||||
"undercount: ours={ours} lopdf={} for {:?}",
|
||||
content.operations.len(),
|
||||
String::from_utf8_lossy(data)
|
||||
),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_count_matches_lopdf_for_typical_streams() {
|
||||
let samples: &[&[u8]] = &[
|
||||
b"q 1 0 0 1 0 0 cm BT /F1 12 Tf 72 720 Td (Hello) Tj ET Q",
|
||||
b"q Q q Q",
|
||||
b"BT /F1 12 Tf 12 TL 1 0 0 1 100 512 Tm (first) Tj (struck) ' ET",
|
||||
b"1 0 0 rg 0 0 10 10 re f",
|
||||
b"true false null q",
|
||||
b"% comment\nq Q\n",
|
||||
b"[ (a) 1 (b) ] TJ",
|
||||
b"1 0 0 1 0 0 cm /Im0 Do",
|
||||
];
|
||||
for data in samples {
|
||||
assert_eq!(
|
||||
count_content_operators(data, usize::MAX),
|
||||
lopdf_op_count(data),
|
||||
"count mismatch for {}",
|
||||
String::from_utf8_lossy(data)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_and_comments_are_not_operators() {
|
||||
let data = b"(q Q Tj) Tj % q Q\nET";
|
||||
assert_eq!(
|
||||
count_content_operators(data, usize::MAX),
|
||||
lopdf_op_count(data)
|
||||
);
|
||||
assert_eq!(count_content_operators(data, usize::MAX), 2); // Tj, ET
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_image_counts_as_one_operator() {
|
||||
let data = b"BI /W 2 /H 2 /CS /RGB /BPC 8 ID \x00\x01\x02\x03 EI q";
|
||||
assert_eq!(
|
||||
count_content_operators(data, usize::MAX),
|
||||
lopdf_op_count(data)
|
||||
);
|
||||
assert_eq!(count_content_operators(data, usize::MAX), 2); // BI, q
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_image_ei_accepts_pdf_whitespace() {
|
||||
let tab = b"BI /W 1 /H 1 ID \xff\tEI\t q Q";
|
||||
let nul = b"BI /W 1 /H 1 ID \xff\x00EI\x00 q Q";
|
||||
let ff = b"BI /W 1 /H 1 ID \xff\x0cEI\x0c q Q";
|
||||
for data in [tab.as_slice(), nul.as_slice(), ff.as_slice()] {
|
||||
assert_count_does_not_undercount(data);
|
||||
assert!(
|
||||
count_content_operators(data, usize::MAX) >= 3,
|
||||
"BI plus following q Q must remain visible after EI, got {} for {:?}",
|
||||
count_content_operators(data, usize::MAX),
|
||||
String::from_utf8_lossy(data)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_is_skipped_when_operator_cap_is_exceeded() {
|
||||
let mut data = Vec::new();
|
||||
for _ in 0..20 {
|
||||
data.extend_from_slice(b"q Q\n");
|
||||
}
|
||||
assert!(decode_content_bounded(&data, 10).unwrap().is_none());
|
||||
let decoded = decode_content_bounded(&data, 50).unwrap().unwrap();
|
||||
assert_eq!(decoded.operations.len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_whitespace_does_not_swallow_following_operator() {
|
||||
// NUL / form-feed end a name (PDF whitespace). Absorbing `q` into
|
||||
// `/x` would undercount and let decode allocate the operator vector.
|
||||
let mut nul_sep = Vec::new();
|
||||
let mut ff_sep = Vec::new();
|
||||
for _ in 0..8_000 {
|
||||
nul_sep.extend_from_slice(b"/x\x00q");
|
||||
ff_sep.extend_from_slice(b"/x\x0cq");
|
||||
}
|
||||
assert_count_does_not_undercount(&nul_sep);
|
||||
assert_count_does_not_undercount(&ff_sep);
|
||||
assert!(count_content_operators(&ff_sep, usize::MAX) >= 8_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_streams_do_not_undercount_vs_lopdf() {
|
||||
let samples: &[&[u8]] = &[
|
||||
b".5 0 0 .5 0 0 cm",
|
||||
b"+1 -2 3.0 rg",
|
||||
b"<0041> Tj",
|
||||
b"(unbalanced",
|
||||
b"BI /W 1 /H 1 ID \xff\xff no EI here q Q q Q",
|
||||
b"q\x00Q\x00q\x00Q",
|
||||
b"/F1\x0c12 Tf (Hi) Tj",
|
||||
b"{ 1 2 add } cvx",
|
||||
];
|
||||
for data in samples {
|
||||
assert_count_does_not_undercount(data);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn million_q_pairs_are_rejected_without_decode() {
|
||||
let mut data = Vec::with_capacity((MAX_PAGE_OPERATIONS + 1) * 2);
|
||||
for _ in 0..=MAX_PAGE_OPERATIONS {
|
||||
data.extend_from_slice(b"q\n");
|
||||
}
|
||||
assert!(content_exceeds_operation_limit(&data, MAX_PAGE_OPERATIONS));
|
||||
assert!(decode_content_bounded(&data, MAX_PAGE_OPERATIONS)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,8 @@ pub(crate) fn extract_page_text_items(
|
||||
style_cache: &mut FontStyleCache,
|
||||
form_budget: &mut FormWalkBudget,
|
||||
) -> Result<(PageExtraction, bool, bool, bool), PdfError> {
|
||||
use lopdf::content::Content;
|
||||
|
||||
let mut items = Vec::new();
|
||||
let mut rects: Vec<PdfRect> = Vec::new();
|
||||
let mut clip_rects: Vec<PdfRect> = Vec::new();
|
||||
@@ -254,20 +256,18 @@ pub(crate) fn extract_page_text_items(
|
||||
// Content::decode parser, causing it to skip operators like ET and Q.
|
||||
let content_data = strip_pdf_comments(&content_data);
|
||||
|
||||
let content = match super::content_decode::decode_content_bounded(
|
||||
&content_data,
|
||||
super::content_decode::MAX_PAGE_OPERATIONS,
|
||||
)? {
|
||||
Some(content) => content,
|
||||
None => {
|
||||
log::warn!(
|
||||
"page {}: skipping extraction — content stream exceeds {} operations",
|
||||
page_num,
|
||||
super::content_decode::MAX_PAGE_OPERATIONS
|
||||
);
|
||||
return Ok(((Vec::new(), Vec::new(), Vec::new()), false, false, false));
|
||||
}
|
||||
};
|
||||
let content = Content::decode(&content_data).map_err(|e| PdfError::Parse(e.to_string()))?;
|
||||
|
||||
const MAX_OPERATIONS: usize = 1_000_000;
|
||||
if content.operations.len() > MAX_OPERATIONS {
|
||||
log::warn!(
|
||||
"page {}: skipping extraction — {} operations exceeds limit ({})",
|
||||
page_num,
|
||||
content.operations.len(),
|
||||
MAX_OPERATIONS
|
||||
);
|
||||
return Ok(((Vec::new(), Vec::new(), Vec::new()), false, false, false));
|
||||
}
|
||||
|
||||
// Graphics state tracking
|
||||
let mut ctm = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0]; // Current Transformation Matrix
|
||||
@@ -1937,19 +1937,4 @@ BT 30 700 Tm <41> Tj ET";
|
||||
let output = strip_pdf_comments(input);
|
||||
assert_eq!(output, b"(x\\\\) Tj \nET\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_content_stream_skips_extraction() {
|
||||
let mut content =
|
||||
Vec::with_capacity((super::super::content_decode::MAX_PAGE_OPERATIONS + 1) * 2);
|
||||
for _ in 0..=super::super::content_decode::MAX_PAGE_OPERATIONS {
|
||||
content.extend_from_slice(b"q\n");
|
||||
}
|
||||
content.extend_from_slice(b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n");
|
||||
let items = extract_simple_items(&content);
|
||||
assert!(
|
||||
items.is_empty(),
|
||||
"pages over the operator cap must not be decoded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-107
@@ -482,11 +482,7 @@ pub(crate) fn parse_cid_w_array(
|
||||
widths: &mut HashMap<u16, u16>,
|
||||
) {
|
||||
let mut i = 0;
|
||||
let mut assigned = 0usize;
|
||||
while i < w_array.len() {
|
||||
if assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
|
||||
return;
|
||||
}
|
||||
let start_cid = match &w_array[i] {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
@@ -505,14 +501,12 @@ pub(crate) fn parse_cid_w_array(
|
||||
Object::Array(arr) => {
|
||||
// [c [w1 w2 ...]] — consecutive widths starting at c
|
||||
for (j, w_obj) in arr.iter().enumerate() {
|
||||
if !assign_cid_width(
|
||||
widths,
|
||||
start_cid.wrapping_add(j as u16),
|
||||
w_obj,
|
||||
&mut assigned,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let w = match w_obj {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
_ => continue,
|
||||
};
|
||||
widths.insert(start_cid + j as u16, w);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -520,14 +514,12 @@ pub(crate) fn parse_cid_w_array(
|
||||
// Could be a reference to an array
|
||||
if let Ok(Object::Array(arr)) = doc.get_object(*r) {
|
||||
for (j, w_obj) in arr.iter().enumerate() {
|
||||
if !assign_cid_width(
|
||||
widths,
|
||||
start_cid.wrapping_add(j as u16),
|
||||
w_obj,
|
||||
&mut assigned,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let w = match w_obj {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
_ => continue,
|
||||
};
|
||||
widths.insert(start_cid + j as u16, w);
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
@@ -550,8 +542,8 @@ pub(crate) fn parse_cid_w_array(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !assign_cid_width_range(widths, start_cid, end, w, &mut assigned) {
|
||||
return;
|
||||
for cid in start_cid..=end {
|
||||
widths.insert(cid, w);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -569,8 +561,8 @@ pub(crate) fn parse_cid_w_array(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !assign_cid_width_range(widths, start_cid, end, w, &mut assigned) {
|
||||
return;
|
||||
for cid in start_cid..=end {
|
||||
widths.insert(cid, w);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -581,45 +573,6 @@ pub(crate) fn parse_cid_w_array(
|
||||
}
|
||||
}
|
||||
|
||||
fn assign_cid_width(
|
||||
widths: &mut HashMap<u16, u16>,
|
||||
cid: u16,
|
||||
w_obj: &Object,
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
let w = match w_obj {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
_ => return true,
|
||||
};
|
||||
if *assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
|
||||
return false;
|
||||
}
|
||||
widths.insert(cid, w);
|
||||
*assigned += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn assign_cid_width_range(
|
||||
widths: &mut HashMap<u16, u16>,
|
||||
start: u16,
|
||||
end: u16,
|
||||
w: u16,
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
if start > end {
|
||||
return true;
|
||||
}
|
||||
for cid in start..=end {
|
||||
if *assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
|
||||
return false;
|
||||
}
|
||||
widths.insert(cid, w);
|
||||
*assigned += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Compute the width of a string in text space units,
|
||||
/// given raw bytes and font width info.
|
||||
/// Returns width in text space units (font_units * units_scale * font_size).
|
||||
@@ -2360,48 +2313,4 @@ end",
|
||||
// invalid CMap result — so it must not clear the gid flag.
|
||||
assert!(gid_flagged(Some("<01> <FFFD>\n<02> <FFFD>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cid_w_array_range_and_consecutive() {
|
||||
use super::parse_cid_w_array;
|
||||
use lopdf::{Document, Object};
|
||||
use std::collections::HashMap;
|
||||
|
||||
let doc = Document::new();
|
||||
let mut widths = HashMap::new();
|
||||
let w = vec![
|
||||
Object::Integer(10),
|
||||
Object::Integer(12),
|
||||
Object::Integer(500),
|
||||
Object::Integer(20),
|
||||
Object::Array(vec![Object::Integer(100), Object::Integer(200)]),
|
||||
];
|
||||
parse_cid_w_array(&doc, &w, &mut widths);
|
||||
assert_eq!(widths.get(&10), Some(&500));
|
||||
assert_eq!(widths.get(&11), Some(&500));
|
||||
assert_eq!(widths.get(&12), Some(&500));
|
||||
assert_eq!(widths.get(&20), Some(&100));
|
||||
assert_eq!(widths.get(&21), Some(&200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cid_w_array_repeated_full_ranges_stay_bounded() {
|
||||
use super::parse_cid_w_array;
|
||||
use crate::tounicode::MAX_CID_W_EXPANSION;
|
||||
use lopdf::{Document, Object};
|
||||
use std::collections::HashMap;
|
||||
|
||||
let doc = Document::new();
|
||||
let mut widths = HashMap::new();
|
||||
let mut w = Vec::new();
|
||||
for _ in 0..5_000 {
|
||||
w.push(Object::Integer(0));
|
||||
w.push(Object::Integer(65535));
|
||||
w.push(Object::Integer(500));
|
||||
}
|
||||
parse_cid_w_array(&doc, &w, &mut widths);
|
||||
assert!(widths.len() <= MAX_CID_W_EXPANSION);
|
||||
assert_eq!(widths.get(&0), Some(&500));
|
||||
assert_eq!(widths.get(&65535), Some(&500));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! This module extracts text with position information for structure detection.
|
||||
|
||||
mod base14;
|
||||
mod content_decode;
|
||||
pub(crate) mod content_stream;
|
||||
mod fonts;
|
||||
mod layout;
|
||||
|
||||
@@ -215,6 +215,8 @@ fn extract_form_xobject_text_inner(
|
||||
depth: u8,
|
||||
budget: &mut FormWalkBudget,
|
||||
) -> Vec<TextItem> {
|
||||
use lopdf::content::Content;
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
if !budget.charge_invocation() {
|
||||
@@ -232,12 +234,8 @@ fn extract_form_xobject_text_inner(
|
||||
Err(_) => stream.content.clone(),
|
||||
};
|
||||
|
||||
// Decode the content stream. Cap before lopdf materializes the operator
|
||||
// vector — the walk budget cannot help if decode itself allocates first.
|
||||
let Ok(Some(content)) = super::content_decode::decode_content_bounded(
|
||||
&content_data,
|
||||
super::content_decode::MAX_PAGE_OPERATIONS,
|
||||
) else {
|
||||
// Decode the content stream
|
||||
let Ok(content) = Content::decode(&content_data) else {
|
||||
return items;
|
||||
};
|
||||
|
||||
|
||||
+12
-73
@@ -1832,11 +1832,6 @@ fn merge_cmaps(mut base: ToUnicodeCMap, overlay: ToUnicodeCMap) -> ToUnicodeCMap
|
||||
base
|
||||
}
|
||||
|
||||
/// Upper bound on CID `/W` range expansion. The CID domain is 16-bit, so more
|
||||
/// than 65,536 unique keys cannot exist; repeating full-width ranges must not
|
||||
/// re-expand the same domain.
|
||||
pub(crate) const MAX_CID_W_EXPANSION: usize = 65_536;
|
||||
|
||||
/// Check if a CIDFont's /W (widths) array contains CID values that look like
|
||||
/// Unicode codepoints rather than low-value GIDs.
|
||||
///
|
||||
@@ -1848,23 +1843,20 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// The /W array format: [cid [w1 w2 ...]] or [cid_start cid_end w].
|
||||
// Collect unique CIDs only: repeating a full-width range must not grow a
|
||||
// temporary vector (or the sort) with the range length on every copy.
|
||||
let mut seen = HashSet::new();
|
||||
// The /W array format: [cid [w1 w2 ...]] or [cid_start cid_end w]
|
||||
// We extract all CID values (the first element of each group).
|
||||
let mut cids: Vec<u16> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < w_arr.len() && seen.len() < MAX_CID_W_EXPANSION {
|
||||
while i < w_arr.len() {
|
||||
if let Ok(cid) = w_arr[i].as_i64() {
|
||||
let start = cid as u16;
|
||||
cids.push(cid as u16);
|
||||
// Skip the width data
|
||||
if i + 1 < w_arr.len() {
|
||||
match &w_arr[i + 1] {
|
||||
Object::Array(widths) => {
|
||||
// [cid [w1 w2 ...]] — CIDs are cid, cid+1, ..., cid+len-1
|
||||
for j in 0..widths.len() {
|
||||
if seen.len() >= MAX_CID_W_EXPANSION {
|
||||
break;
|
||||
}
|
||||
seen.insert(start.wrapping_add(j as u16));
|
||||
for j in 1..widths.len() {
|
||||
cids.push((cid as u16).wrapping_add(j as u16));
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
@@ -1872,7 +1864,9 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
// [cid_start cid_end w] — range of CIDs
|
||||
if i + 2 < w_arr.len() {
|
||||
if let Ok(cid_end) = w_arr[i + 1].as_i64() {
|
||||
record_unique_cid_range(start, cid_end as u16, &mut seen);
|
||||
for c in (cid as u16)..=(cid_end as u16) {
|
||||
cids.push(c);
|
||||
}
|
||||
}
|
||||
i += 3;
|
||||
} else {
|
||||
@@ -1881,7 +1875,6 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
}
|
||||
}
|
||||
} else {
|
||||
seen.insert(start);
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
@@ -1889,11 +1882,10 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
}
|
||||
}
|
||||
|
||||
if seen.is_empty() {
|
||||
if cids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut cids: Vec<u16> = seen.into_iter().collect();
|
||||
cids.sort_unstable();
|
||||
let median = cids[cids.len() / 2];
|
||||
// Unicode text CIDs are typically >= 0x20 (space) with letters at 0x41+.
|
||||
@@ -1902,18 +1894,6 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
median >= 0x41
|
||||
}
|
||||
|
||||
fn record_unique_cid_range(start: u16, end: u16, seen: &mut HashSet<u16>) {
|
||||
if start > end {
|
||||
return;
|
||||
}
|
||||
for cid in start..=end {
|
||||
if seen.len() >= MAX_CID_W_EXPANSION {
|
||||
return;
|
||||
}
|
||||
seen.insert(cid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a ToUnicodeCMap from predefined CID→Unicode mapping based on CIDSystemInfo.
|
||||
///
|
||||
/// Supports Adobe-Korea1 (Korean) character collection. Can be extended for
|
||||
@@ -3318,45 +3298,4 @@ endbfrange
|
||||
"An indirect /Subtype naming CIDFontType2 must still reach the remap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cid_values_look_like_unicode_letter_range() {
|
||||
let mut dict = lopdf::Dictionary::new();
|
||||
dict.set(
|
||||
"W",
|
||||
Object::Array(vec![
|
||||
Object::Integer(0x41),
|
||||
Object::Integer(0x5A),
|
||||
Object::Integer(500),
|
||||
]),
|
||||
);
|
||||
assert!(cid_values_look_like_unicode(&dict));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cid_values_look_like_unicode_low_gids() {
|
||||
let mut dict = lopdf::Dictionary::new();
|
||||
dict.set(
|
||||
"W",
|
||||
Object::Array(vec![
|
||||
Object::Integer(0),
|
||||
Object::Array(vec![Object::Integer(500); 10]),
|
||||
]),
|
||||
);
|
||||
assert!(!cid_values_look_like_unicode(&dict));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cid_values_look_like_unicode_repeated_full_ranges_stay_bounded() {
|
||||
// Repeating `[0 65535 w]` must not materialize 65,536 CIDs per copy.
|
||||
let mut w = Vec::new();
|
||||
for _ in 0..5_000 {
|
||||
w.push(Object::Integer(0));
|
||||
w.push(Object::Integer(65535));
|
||||
w.push(Object::Integer(500));
|
||||
}
|
||||
let mut dict = lopdf::Dictionary::new();
|
||||
dict.set("W", Object::Array(w));
|
||||
assert!(cid_values_look_like_unicode(&dict));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user