mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:39:01 -06:00
Keep the Ops iterator from this branch and carry main's long-number skip (MAX_RETRIED_TOKEN) into its operand error path.
555 lines
27 KiB
Rust
555 lines
27 KiB
Rust
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) };
|
|
}
|
|
|
|
thread_local! {
|
|
/// Pieces [`Pieces::splice`] looked at while copying, on this thread.
|
|
pub(crate) static PIECE_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
|
}
|
|
|
|
#[test]
|
|
fn splicing_visits_each_piece_a_few_times() {
|
|
let n = 5_000;
|
|
let pieces: Vec<String> = (0..n).map(|i| format!("({i}) Tj")).collect();
|
|
let joined = Pieces::join(&pieces);
|
|
let parsed = joined.parse();
|
|
let edits: Vec<_> = parsed.ops.iter().map(|op| (op.span.clone(), b"(x) Tj".to_vec())).collect();
|
|
assert_eq!(edits.len(), n);
|
|
PIECE_VISITS.with(|c| c.set(0));
|
|
let out = joined.splice(edits);
|
|
let visits = PIECE_VISITS.with(|c| c.get());
|
|
assert!(visits <= 4 * n, "{visits} piece visits for {n} pieces");
|
|
assert!(out.iter().all(|p| p == b"(x) Tj"));
|
|
// A replacement spanning two pieces stays whole in the first and leaves the rest of the second.
|
|
let two = Pieces::join(&["q (a)", "Tj Q"]);
|
|
let tj = two.parse().ops.into_iter().find(|o| o.is("Tj")).map(|o| o.span).unwrap_or_default();
|
|
assert_eq!(two.splice([(tj, b"(b) Tj".to_vec())]), [b"q (b) Tj".to_vec(), b" Q".to_vec()]);
|
|
}
|
|
|
|
#[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";
|
|
let p = parse(src);
|
|
assert_eq!(p.skipped, 0);
|
|
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
|
|
assert_eq!(names, [&b"q"[..], b"cm", b"BT", b"Tf", b"Tj", b"TJ", b"ET", b"Q"]);
|
|
let cm = &p.ops[1];
|
|
assert_eq!(cm.nums::<6>(), Some([1.0, 0.0, 0.0, 1.0, 72.0, 720.0]));
|
|
assert_eq!(&src[cm.span.clone()], b"1 0 0 1 72 720 cm");
|
|
assert_eq!(p.ops[4].operands[0].as_string().unwrap().bytes, b"Hello (world)");
|
|
assert_eq!(p.ops[5].operands[0].as_array().unwrap().len(), 3);
|
|
assert_eq!(p.ops[3].name(0), Some(&b"F1"[..]));
|
|
}
|
|
|
|
#[test]
|
|
fn round_trips_through_the_serializer() {
|
|
let src = b"0.5 g 10 10 100 50 re f /GS1 gs true false null 3 d0 [3 2] 0 d (x) ' 1 2 (y) \"";
|
|
let a = parse(src);
|
|
let b = parse(&serialize_ops(&a.ops));
|
|
assert_eq!(a.ops.iter().map(|o| (&o.op, &o.operands)).collect::<Vec<_>>(), b.ops.iter().map(|o| (&o.op, &o.operands)).collect::<Vec<_>>());
|
|
}
|
|
|
|
#[test]
|
|
fn inline_images_stay_whole() {
|
|
let mut src = b"q 10 0 0 10 0 0 cm BI /W 2 /H 2 /CS /G /BPC 8 ID ".to_vec();
|
|
src.extend_from_slice(&[0, b'E', b'I', 255]);
|
|
src.extend_from_slice(b"\nEI Q 1 g");
|
|
let p = parse(&src);
|
|
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
|
|
assert_eq!(names, [&b"q"[..], b"cm", b"BI", b"Q", b"g"]);
|
|
let (d, data) = p.ops[2].inline.clone().unwrap();
|
|
assert_eq!(d.int(b"W"), Some(2));
|
|
assert_eq!(data, [0, b'E', b'I', 255], "an EI inside the data without whitespace before it is data");
|
|
let again = parse(&serialize_ops(&p.ops));
|
|
assert_eq!(again.ops[2].inline, p.ops[2].inline);
|
|
}
|
|
|
|
#[test]
|
|
fn unfiltered_inline_image_keeps_whitespace_bounded_ei_pixels() {
|
|
let src = b"q 4 0 0 1 0 0 cm BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q";
|
|
let parsed = parse(src);
|
|
assert_eq!(parsed.ops[2].inline.as_ref().unwrap().1, b" EI ");
|
|
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), [b"q".as_slice(), b"cm", b"BI", b"Q"]);
|
|
let rewritten = serialize_ops(&parsed.ops);
|
|
assert_eq!(parse(&rewritten).ops[2].inline, parsed.ops[2].inline);
|
|
}
|
|
|
|
fn inline_pixels(parameters: &[u8], pixels: &[u8]) -> Parsed {
|
|
let mut src = b"q BI ".to_vec();
|
|
src.extend_from_slice(parameters);
|
|
src.extend_from_slice(b" ID ");
|
|
src.extend_from_slice(pixels);
|
|
src.extend_from_slice(b"\nEI Q");
|
|
parse(&src)
|
|
}
|
|
|
|
#[test]
|
|
fn unfiltered_inline_image_lengths_use_components_and_parameter_aliases() {
|
|
for (parameters, pixels) in [
|
|
(b"/W 4 /H 1 /CS /G /BPC 8".as_slice(), b" EI ".as_slice()),
|
|
(b"/W 4 /H 1 /CS /G /BPC 8 /Filter null", b" EI "),
|
|
(b"/W 4 /H 1 /CS /G /BPC 8 /F []", b" EI "),
|
|
(b"/W 4 /Width 4 /H 1 /CS /G /BPC 8", b" EI "),
|
|
(b"/Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b" EI "),
|
|
(b"/W 2 /H 1 /CS /RGB /BPC 8", b"\0 EI \xff"),
|
|
(b"/Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", b"\0 EI \xff"),
|
|
(b"/W 1 /H 1 /CS /CMYK /BPC 8", b" EI "),
|
|
(b"/Width 1 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8", b" EI "),
|
|
] {
|
|
let parsed = inline_pixels(parameters, pixels);
|
|
assert_eq!(parsed.skipped, 0);
|
|
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), [b"q".as_slice(), b"BI", b"Q"]);
|
|
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, pixels);
|
|
assert_eq!(parse(&serialize_ops(&parsed.ops)).ops[1].inline, parsed.ops[1].inline);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unfiltered_inline_image_rows_are_byte_aligned_for_every_sample_depth() {
|
|
// Two rows of two bytes each. Padding belongs to each row, not only the final row.
|
|
for (bits, width) in [(1, 9), (2, 5), (4, 3), (8, 2), (16, 1)] {
|
|
let parameters = format!("/W {width} /H 2 /BPC {bits} /CS /G");
|
|
let parsed = inline_pixels(parameters.as_bytes(), b" EI ");
|
|
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ", "bits={bits}");
|
|
assert_eq!(parsed.ops.len(), 3);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unfiltered_inline_masks_default_to_one_bit_and_indexed_images_have_one_component() {
|
|
for parameters in [b"/W 9 /H 2 /IM true".as_slice(), b"/Width 9 /Height 2 /ImageMask true /BitsPerComponent 1"] {
|
|
let parsed = inline_pixels(parameters, b" EI ");
|
|
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ");
|
|
assert_eq!(parsed.ops.len(), 3);
|
|
}
|
|
for name in ["Indexed", "I"] {
|
|
// A valid 256-entry RGB lookup table; samples are indices regardless of the base space.
|
|
let parameters = format!("/W 4 /H 1 /BPC 8 /CS [/{name} /DeviceRGB 255 <{}>]", "00".repeat(768));
|
|
let parsed = inline_pixels(parameters.as_bytes(), b" EI ");
|
|
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ");
|
|
assert_eq!(parsed.ops.len(), 3);
|
|
// Indexed samples permit at most 8 bits, even though device spaces permit 16, so the
|
|
// length is unknown, and the renderer takes the second `EI`.
|
|
let invalid = parameters.replace("/W 4", "/W 2").replace("/BPC 8", "/BPC 16");
|
|
let parsed = inline_pixels(invalid.as_bytes(), b" EI ");
|
|
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unfiltered_inline_image_terminator_must_follow_its_samples() {
|
|
for separator in [b" ".as_slice(), b"\r\n", b"\0\t\x0c\n"] {
|
|
let mut src = b"BI /W 4 /H 1 /BPC 8 /CS /G ID EI ".to_vec();
|
|
src.extend_from_slice(separator);
|
|
src.extend_from_slice(b"EI/Q q");
|
|
let parsed = parse(&src);
|
|
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b" EI ");
|
|
assert_eq!(&src[parsed.ops[0].span.clone()], &src[..src.len() - b"/Q q".len()]);
|
|
}
|
|
let parsed = parse(b"BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI");
|
|
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b" EI ");
|
|
// EI is a keyword: a prefix of a larger token is not a terminator.
|
|
let parsed = parse(b"BI /W 4 /H 1 /BPC 8 /CS /G ID abcd EIx\nEI Q");
|
|
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abcd EIx");
|
|
}
|
|
|
|
#[test]
|
|
fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them() {
|
|
for parameters in [
|
|
b"/W 4 /H 1 /BPC 8 /CS /G /F /A85".as_slice(),
|
|
b"/W 4 /H 1 /BPC 8 /CS /ResourceColor",
|
|
b"/W 0 /H 1 /BPC 8 /CS /G",
|
|
b"/W -1 /H 1 /BPC 8 /CS /G",
|
|
b"/W 4 /H -1 /BPC 8 /CS /G",
|
|
b"/W 4 /H 1 /BPC 3 /CS /G",
|
|
b"/W 4 /H 1 /IM true /BPC 8",
|
|
b"/W 4 /H 1 /IM true /BPC /Bad",
|
|
b"/Width 4 /W 1 /H 1 /BPC 8 /CS /G",
|
|
b"/W 4 /H 1 /BPC 8 /CS /G /ImageMask false /IM true",
|
|
b"/W 9223372036854775807 /H 9223372036854775807 /BPC 16 /CS /CMYK",
|
|
b"/W 9223372036854775807 /H 2 /BPC 8 /CS /G",
|
|
] {
|
|
// What follows the first `EI` (`!!~>`) does not read as content, so the renderer takes
|
|
// the later one.
|
|
let parsed = inline_pixels(parameters, b"! EI !!~>");
|
|
let label = String::from_utf8_lossy(parameters);
|
|
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"! EI !!~>", "{label}");
|
|
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), [b"q".as_slice(), b"BI", b"Q"], "{label}");
|
|
assert_eq!(parsed.ambiguous_images, 0, "{label}");
|
|
}
|
|
// A short payload is read whole; without an `EI`, the renderer reads no image and this
|
|
// parser's end is a guess.
|
|
for (src, ambiguous) in [(b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), 0), (b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc", 1)] {
|
|
let parsed = parse(src);
|
|
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abc");
|
|
assert_eq!(parsed.ambiguous_images, ambiguous);
|
|
let mut reader = ops(src);
|
|
reader.by_ref().for_each(drop);
|
|
assert_eq!(reader.ambiguous_images(), ambiguous, "the streaming reader counts it too");
|
|
}
|
|
}
|
|
|
|
/// A zlib stream (one stored block) for 64 DeviceGray samples whose bytes hold " EI\n" at offset 41,
|
|
/// the way a stored deflate block copies incompressible samples verbatim (#3579).
|
|
const FLATE_WITH_EI: &str = "789c014000bfffed746159d50788f7ea604984cd08e00b4c8983207f56fe82c77ef8dfb8b9a3a586382045490a3120\
|
|
31206c2025630aaa4353e6813e233b96539c18bc3a2c5009e9d51b81";
|
|
|
|
#[test]
|
|
fn flate_inline_image_is_not_cut_at_an_ei_inside_its_compressed_bytes() {
|
|
let flate: Vec<u8> = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect();
|
|
assert_eq!(flate.len(), 75);
|
|
assert_eq!(&flate[41..45], b" EI\n");
|
|
for parameters in [b"/W 64 /H 1 /BPC 8 /CS /G /F /Fl".as_slice(), b"/W 64 /H 1 /BPC 8 /CS /G /F /Fl /L 75"] {
|
|
let mut src = b"q 200 0 0 200 100 300 cm BI ".to_vec();
|
|
src.extend_from_slice(parameters);
|
|
src.extend_from_slice(b" ID ");
|
|
src.extend_from_slice(&flate);
|
|
src.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET");
|
|
let parsed = parse(&src);
|
|
let label = String::from_utf8_lossy(parameters);
|
|
assert_eq!(parsed.ops[2].inline.as_ref().unwrap().1.len(), 75, "{label}");
|
|
let names: Vec<&[u8]> = parsed.ops.iter().map(|o| o.op.as_slice()).collect();
|
|
assert_eq!(names, [&b"q"[..], b"cm", b"BI", b"Q", b"BT", b"Tf", b"Td", b"Tj", b"ET"], "{label}");
|
|
assert_eq!(parsed.skipped, 0, "{label}");
|
|
assert_eq!(parse(&serialize_ops(&parsed.ops)).ops[2].inline, parsed.ops[2].inline, "{label}");
|
|
}
|
|
}
|
|
|
|
fn inline_pdf(content: &[u8], object_streams: bool) -> Vec<u8> {
|
|
use pdfcraft_cos::{Document, SaveOptions, Stream, write_full};
|
|
|
|
let mut doc = Document::new_empty();
|
|
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
|
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), content)));
|
|
let page = doc.add(Object::Dict(
|
|
[
|
|
(b"Type".to_vec(), Object::name("Page")),
|
|
(b"Parent".to_vec(), Object::Ref(pages)),
|
|
(b"MediaBox".to_vec(), Object::Array([0, 0, 4, 1].map(Object::Int).to_vec())),
|
|
(b"Resources".to_vec(), Object::Dict(Dict::new())),
|
|
(b"Contents".to_vec(), Object::Ref(contents)),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
));
|
|
doc.update_dict(pages, |dict| {
|
|
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
|
dict.set(b"Count".to_vec(), Object::Int(1));
|
|
})
|
|
.unwrap();
|
|
write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn rewritten_inline_image_pixels_survive_saving_and_reopening_a_pdf() {
|
|
use pdfcraft_cos::{Document, Object};
|
|
use std::sync::Arc;
|
|
|
|
let original = b"q 4 0 0 1 0 0 cm BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q";
|
|
let rewritten = serialize_ops(&parse(original).ops);
|
|
for object_streams in [false, true] {
|
|
let bytes = inline_pdf(&rewritten, object_streams);
|
|
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
|
|
assert!(doc.repair_log().is_empty());
|
|
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
|
let pages = doc.get(pages);
|
|
let page = doc.resolve(&pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0]);
|
|
let content = doc.resolve(page.as_dict().unwrap().get(b"Contents").unwrap());
|
|
let Object::Stream(stream) = &*content else { panic!("page content stream") };
|
|
assert_eq!(parse(&stream.decoded_strict().unwrap()).ops[2].inline.as_ref().unwrap().1, b" EI ");
|
|
// Opt-in artifacts for independent readers/renderers; synthetic PDFs are never committed.
|
|
if let Some(dir) = std::env::var_os("PDFCRAFT_INLINE_FIXTURE_DIR") {
|
|
let dir = std::path::PathBuf::from(dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
std::fs::write(dir.join("original.pdf"), inline_pdf(original, false)).unwrap();
|
|
std::fs::write(dir.join(if object_streams { "rewritten-packed.pdf" } else { "rewritten-classic.pdf" }), bytes).unwrap();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn damage_is_skipped_not_fatal() {
|
|
let p = parse(b"1 0 0 RG ) ] 10 20 m 30 40 l S (unterminated");
|
|
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
|
|
assert_eq!(names, [&b"RG"[..], b"m", b"l", b"S"]);
|
|
assert!(p.skipped >= 2);
|
|
assert!(parse(b"").ops.is_empty());
|
|
assert!(parse(b"BI /W 1").ops.len() == 1);
|
|
}
|
|
|
|
/// Issue #1318: an inline image whose dictionary can't be read is skipped up to its `EI`, counted,
|
|
/// and what follows it is parsed.
|
|
#[test]
|
|
fn an_unreadable_inline_image_is_skipped_to_its_end() {
|
|
let src = b"q BI /W 4 H 4 ID aa EI Q BT (After) Tj ET";
|
|
let p = parse(src);
|
|
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
|
|
assert_eq!(names, [&b"q"[..], b"Q", b"BT", b"Tj", b"ET"]);
|
|
assert_eq!(p.skipped, b"BI /W 4 H 4 ID aa EI".len());
|
|
let control = parse(b"q BI /W 4 /H 4 ID aa EI Q BT (After) Tj ET");
|
|
assert_eq!(control.skipped, 0);
|
|
assert_eq!(control.ops.len(), 6);
|
|
}
|
|
|
|
/// Issue #1318, follow-up: without an `EI`, an unreadable inline image runs to the end of the
|
|
/// stream, and those bytes are counted. A readable one whose data is cut short is kept, data and
|
|
/// all, so nothing of it is skipped.
|
|
#[test]
|
|
fn an_unreadable_inline_image_without_ei_is_counted() {
|
|
let p = parse(b"q BI /W 4 H 4 ID aa");
|
|
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
|
|
assert_eq!(names, [&b"q"[..]]);
|
|
assert_eq!(p.skipped, b"BI /W 4 H 4 ID aa".len());
|
|
let control = parse(b"q BI /W 4 /H 4 ID aa");
|
|
assert_eq!(control.skipped, 0);
|
|
let names: Vec<&[u8]> = control.ops.iter().map(|o| o.op.as_slice()).collect();
|
|
assert_eq!(names, [&b"q"[..], b"BI"]);
|
|
assert_eq!(control.ops[1].inline.as_ref().map(|(_, data)| data.as_slice()), Some(&b"aa"[..]));
|
|
}
|
|
|
|
#[test]
|
|
fn matrices_compose_and_invert() {
|
|
let a = Matrix([2.0, 0.0, 0.0, 3.0, 10.0, 20.0]);
|
|
let b = Matrix([0.0, 1.0, -1.0, 0.0, 5.0, 0.0]);
|
|
let ab = a.then(&b);
|
|
let (x, y) = ab.apply(1.0, 1.0);
|
|
let (x1, y1) = a.apply(1.0, 1.0);
|
|
assert_eq!((x, y), b.apply(x1, y1));
|
|
let inv = ab.invert().unwrap();
|
|
let (u, v) = inv.apply(x, y);
|
|
assert!((u - 1.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9);
|
|
assert_eq!(Matrix([2.0, 0.0, 0.0, 2.0, 1.0, 1.0]).bbox([0.0, 0.0, 1.0, 1.0]), [1.0, 1.0, 3.0, 3.0]);
|
|
assert!(overlaps([0.0, 0.0, 10.0, 10.0], [5.0, 5.0, 20.0, 20.0], 0.0));
|
|
assert!(!overlaps([0.0, 0.0, 10.0, 10.0], [10.0, 0.0, 20.0, 10.0], 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn pieces_parse_as_one_stream_and_splice_back() {
|
|
// An operator's operands end one piece and its keyword starts the next (§7.8.2).
|
|
let pieces = Pieces::join(&[&b"BT 72 700 Td [(After) -20 (wards)]"[..], b"TJ ET", b"q Q"]);
|
|
assert_eq!(pieces.len(), 3);
|
|
let ops = pieces.parse().ops;
|
|
let names: Vec<&[u8]> = ops.iter().map(|o| o.op.as_slice()).collect();
|
|
assert_eq!(names, [&b"BT"[..], b"Td", b"TJ", b"ET", b"q", b"Q"]);
|
|
assert_eq!(pieces.pieces_of(&ops[2]), (0, 1));
|
|
assert_eq!(pieces.pieces_of(&ops[3]), (1, 1));
|
|
assert_eq!(pieces.piece_of(usize::MAX), 2);
|
|
// Nothing changed: the pieces come back byte for byte.
|
|
assert_eq!(pieces.splice([]), [&b"BT 72 700 Td [(After) -20 (wards)]"[..], b"TJ ET", b"q Q"]);
|
|
// Replacing the split TJ rewrites both pieces it spans; the third is untouched.
|
|
let out = pieces.splice([(ops[2].span.clone(), b"(New) Tj".to_vec())]);
|
|
assert_eq!(out, [&b"BT 72 700 Td (New) Tj"[..], b" ET", b"q Q"]);
|
|
// Removing it leaves no stray operands or keyword.
|
|
let out = pieces.splice([(ops[2].span.clone(), Vec::new())]);
|
|
assert_eq!(out, [&b"BT 72 700 Td "[..], b" ET", b"q Q"]);
|
|
// Out-of-range or reversed edits are clamped, never a panic.
|
|
let out = pieces.splice([(std::ops::Range { start: 10, end: 5 }, b"x".to_vec()), (1000..2000, Vec::new())]);
|
|
assert_eq!(out.len(), 3);
|
|
assert!(Pieces::join::<&[u8]>(&[]).splice([(0..1, b"x".to_vec())]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn stream_boundary_comment_matches_space_concatenation() {
|
|
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
|
|
let mut tail = b"(HIDDEN) Tj".to_vec();
|
|
tail.extend(eol);
|
|
tail.extend(b"(LIVE) Tj");
|
|
let pieces = Pieces::join(&[b"BT % comment".as_slice(), b"(ALSO HIDDEN) Tj", &tail, b"ET"]);
|
|
let parsed = pieces.parse();
|
|
let shown: Vec<_> = parsed.ops.iter().filter(|op| op.is("Tj")).collect();
|
|
assert_eq!(shown.len(), 1);
|
|
assert_eq!(shown[0].operands[0].as_string().unwrap().bytes, b"LIVE");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn comment_edges_terminate_at_next_piece_byte_zero() {
|
|
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
|
|
let mut next = eol.to_vec();
|
|
next.extend_from_slice(b"q Q");
|
|
let pieces = Pieces::join(&[b"% outgoing".as_slice(), next.as_slice()]);
|
|
let parsed = pieces.parse();
|
|
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)], "EOL {eol:?}");
|
|
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q"]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn comment_edges_span_empty_pieces_and_end_of_content() {
|
|
let pieces = Pieces::join(&[b"q % outgoing".as_slice(), b"", b"", b"ignored\nQ"]);
|
|
let parsed = pieces.parse();
|
|
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true), (true, false)]);
|
|
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q"]);
|
|
|
|
let pieces = Pieces::join(&[b"% outgoing".as_slice(), b"", b"still ignored"]);
|
|
let parsed = pieces.parse();
|
|
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true)]);
|
|
assert!(parsed.ops.is_empty());
|
|
|
|
let pieces = Pieces::join::<&[u8]>(&[]);
|
|
assert!(pieces.comment_edges(&pieces.parse()).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn comment_edges_ignore_percent_inside_operands_and_image_data() {
|
|
for first in [
|
|
b"(literal % not a comment) Tj".as_slice(),
|
|
b"/Percent#25Name MP",
|
|
b"[(literal % not a comment)] TJ",
|
|
b"/P << /ActualText (literal % not a comment) >> BDC EMC",
|
|
b"BI /W 1 /H 1 /BPC 8 /CS /G ID % EI",
|
|
] {
|
|
let pieces = Pieces::join(&[first, b"q Q"]);
|
|
let parsed = pieces.parse();
|
|
assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)], "{first:?}");
|
|
assert_eq!(parsed.ops.last().unwrap().op, b"Q");
|
|
assert_eq!(parsed.skipped, 0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compound_operand_comment_split_is_owned_by_spanning_operator() {
|
|
for parts in [[b"[(A) % ignored".as_slice(), b"still ignored\n(B)] TJ"], [b"/P << /MCID 0 % ignored".as_slice(), b"still ignored\n>> BDC"]] {
|
|
let pieces = Pieces::join(&parts);
|
|
let parsed = pieces.parse();
|
|
// The object lexer consumes this nested comment. Its containing operator spans
|
|
// both pieces, which forces both to be rewritten together by the redactor.
|
|
assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)]);
|
|
assert_eq!(parsed.ops.len(), 1);
|
|
assert_eq!(pieces.pieces_of(&parsed.ops[0]), (0, 1));
|
|
assert_eq!(parsed.skipped, 0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn comment_edges_do_not_confuse_local_comments_with_boundary_comments() {
|
|
let pieces = Pieces::join(&[b"% local\nq % crossing".as_slice(), b"ignored\n% local again\nQ\n% final local\n", b"q Q"]);
|
|
let parsed = pieces.parse();
|
|
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false), (false, false)]);
|
|
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q", b"q", b"Q"]);
|
|
}
|
|
|
|
#[test]
|
|
fn comment_edge_storage_depends_on_piece_count_not_comment_count() {
|
|
let mut first = b"%\n".repeat(16_384);
|
|
first.extend_from_slice(b"q % crossing");
|
|
let pieces = Pieces::join(&[first.as_slice(), b"ignored\nQ"]);
|
|
let parsed = pieces.parse();
|
|
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)]);
|
|
assert_eq!(parsed.ops.len(), 2);
|
|
// Also exercise public parsing without piece-boundary metadata.
|
|
assert_eq!(parse(&first).ops.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn piece_lookup_preserves_exact_boundary_and_empty_piece_mapping() {
|
|
// Joined layout: q<space><space>Q<space>; separator belongs to prior piece.
|
|
let pieces = Pieces::join(&[b"q".as_slice(), b"", b"Q"]);
|
|
for (at, expected) in [(0, 0), (1, 0), (2, 1), (3, 2), (4, 2), (5, 2), (usize::MAX, 2)] {
|
|
assert_eq!(pieces.piece_of(at), expected, "position {at}");
|
|
}
|
|
let empty = Pieces::join::<&[u8]>(&[]);
|
|
assert_eq!(empty.piece_of(0), 0);
|
|
assert_eq!(empty.piece_of(usize::MAX), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn inline_image_terminator_without_preceding_whitespace() {
|
|
let cases: &[(&[u8], &[u8])] = &[
|
|
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F /AHx ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"),
|
|
(b"BI /W 4 /H 1 /BPC 8 /CS /G /Filter /ASCIIHexDecode ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"),
|
|
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/AHx] ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"),
|
|
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/AHx /RL] ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"),
|
|
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"),
|
|
(b"BI /W 4 /H 1 /BPC 8 /CS /G /Filter /ASCII85Decode ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"),
|
|
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/A85 /RL] ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"),
|
|
(b"BI /W 4 /H 1 /BPC 8 /CS /G ID ab()EI Q BT /F1 18 Tf (After) Tj ET", b"ab()"),
|
|
(b"BI /W 4 /H 1 /BPC 8 /CS /G ID ab[]EI Q BT /F1 18 Tf (After) Tj ET", b"ab[]"),
|
|
(b"BI /W 4 /H 1 /BPC 8 /CS /G ID \x20\x60\xa0\xe0EI Q BT /F1 18 Tf (After) Tj ET", b"\x20\x60\xa0\xe0"),
|
|
];
|
|
|
|
for (src, expected_data) in cases {
|
|
let p = parse(src);
|
|
assert_eq!(p.skipped, 0, "stream should have 0 skipped bytes: {}", String::from_utf8_lossy(src));
|
|
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
|
|
assert_eq!(
|
|
names,
|
|
[&b"BI"[..], b"Q", b"BT", b"Tf", b"Tj", b"ET"],
|
|
"operators after inline image must be preserved: {}",
|
|
String::from_utf8_lossy(src)
|
|
);
|
|
let (_, img) = p.ops[0].inline.as_ref().unwrap();
|
|
assert_eq!(img, expected_data, "inline image data must match: {}", String::from_utf8_lossy(src));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ascii85_inline_image_does_not_terminate_at_gt_ei_inside_payload() {
|
|
for src in [
|
|
b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID !!!!>EI !!~>\nEI Q BT /F1 18 Tf (After) Tj ET".as_slice(),
|
|
b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/A85 /AHx] ID !!!!>EI !!~>\nEI Q BT /F1 18 Tf (After) Tj ET".as_slice(),
|
|
] {
|
|
let p = parse(src);
|
|
assert_eq!(p.skipped, 0);
|
|
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
|
|
assert_eq!(names, [&b"BI"[..], b"Q", b"BT", b"Tf", b"Tj", b"ET"]);
|
|
let (_, img) = p.ops[0].inline.as_ref().unwrap();
|
|
assert_eq!(img, b"!!!!>EI !!~>");
|
|
}
|
|
}
|
|
|
|
/// The operators the renderer's tokenizer reads from `data`.
|
|
fn renderer_operators(data: &[u8]) -> Vec<Vec<u8>> {
|
|
let mut iter = hayro_syntax::content::UntypedIter::new(data);
|
|
let mut out = Vec::new();
|
|
while let Some(instruction) = iter.next() {
|
|
out.push(instruction.operator.as_ref().to_vec());
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Inline images end where the renderer ends them, so both read the same operators after one:
|
|
/// an `EI` without whitespace before it in the data, upper-case text ending in "EI" after the
|
|
/// image, and an `EI` inside compressed bytes (#3579).
|
|
#[test]
|
|
fn inline_images_end_where_the_renderer_ends_them() {
|
|
let flate: Vec<u8> = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect();
|
|
let mut stored = b"q BI /W 64 /H 1 /BPC 8 /CS /G /F /Fl ID ".to_vec();
|
|
stored.extend_from_slice(&flate);
|
|
stored.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET");
|
|
let fixtures: [&[u8]; 6] = [
|
|
b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET",
|
|
b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F> EI BT /F1 10 Tf 10 100 Td (ZWEI) Tj (EIN FREI BEI) Tj ET",
|
|
b"q BI /W 2 /H 1 /CS /G /BPC 8 /F /AHx ID 7F7F> EI Q BT (DREI) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI",
|
|
b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID ! EI !!~>\nEI Q",
|
|
b"q BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q",
|
|
&stored,
|
|
];
|
|
for data in fixtures {
|
|
let parsed = parse(data);
|
|
let ours: Vec<Vec<u8>> = parsed.ops.iter().map(|op| op.op.clone()).collect();
|
|
assert_eq!(ours, renderer_operators(data), "{}", String::from_utf8_lossy(data));
|
|
assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data));
|
|
}
|
|
}
|