fix(content): skip a long unreadable number whole

When an operand failed to parse, the content parser went back to the
byte after its start and tried again, so a run of n bytes that never
reads as a number (a long run of '+', or digits ending in a letter) was
read again from every one of its bytes: n^2/2 bytes from a tiny
compressed stream. A failed number token longer than 64 bytes is now
skipped whole; shorter ones still recover byte by byte.
This commit is contained in:
aiongg
2026-10-22 17:43:03 +00:00
parent 58dc10c227
commit 3c64f9314e
2 changed files with 38 additions and 3 deletions
+16 -3
View File
@@ -74,6 +74,9 @@ fn is_delim(b: u8) -> bool {
matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
}
/// Longest unreadable number token retried from its next byte. Real numbers are far shorter.
const MAX_RETRIED_TOKEN: usize = 64;
/// Parse a (decoded) content stream.
pub fn parse(data: &[u8]) -> Parsed {
parse_pieces(data, &[])
@@ -110,9 +113,19 @@ fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed {
lx.pos = data.len();
}
Err(_) => {
// Skip the offending byte and carry on.
lx.pos = at + 1;
out.skipped += 1;
let read = lx.pos.saturating_sub(at);
#[cfg(test)]
tests::RESCANNED.with(|c| c.set(c.get() + read));
let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit();
if number && read > MAX_RETRIED_TOKEN {
// A long unreadable number is skipped whole: retrying from each of its
// bytes would read the rest of it again every time.
out.skipped += read;
} else {
// Skip the offending byte and carry on.
lx.pos = at + 1;
out.skipped += 1;
}
}
}
continue;
+22
View File
@@ -1,5 +1,27 @@
use super::*;
thread_local! {
/// Bytes the lexer read before an operand failed, on this thread.
pub(crate) static RESCANNED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[test]
fn a_long_unreadable_number_is_read_once() {
for junk in ["+", "5"] {
let n = 20_000;
let src = format!("{}{} 1 0 0 1 0 0 cm", junk.repeat(n), if junk == "5" { "x" } else { "" });
RESCANNED.with(|c| c.set(0));
let p = parse(src.as_bytes());
let rescanned = RESCANNED.with(|c| c.get());
assert!(rescanned <= 2 * n, "{rescanned} bytes read for a {n}-byte token");
assert_eq!(p.ops.last().and_then(|o| o.nums::<6>()), Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
}
// A short damaged number still recovers byte by byte.
let p = parse(b"-+5 0 Td");
assert_eq!((p.ops.len(), p.skipped), (1, 1));
assert_eq!(p.ops[0].nums::<2>(), Some([5.0, 0.0]));
}
#[test]
fn operators_keep_operands_and_spans() {
let src = b"q 1 0 0 1 72 720 cm BT /F1 12 Tf (Hello \\(world\\)) Tj [(A) -120 (B)] TJ ET Q";