M2: the content crate's parser is checked against hayro's, operator by operator (#3107, #3108, #3111, #3177) (#3563)

This commit is contained in:
mtlcl410 authored and GitHub committed 2026-11-09 18:23:26 +08:00
1 parent cb919f0d36
commit 66a44f529e
3 files changed
+480

No files matched your search

Generated
+1
View File
@@ -4696,6 +4696,7 @@ dependencies = [
"kurbo 0.13.1",
"log",
"lopdf",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-geom",
+3
View File
@@ -34,6 +34,9 @@ unicode-bidi = "0.3"
getrandom = { version = "0.4", features = ["wasm_js"] }
[dev-dependencies]
# The content crate's parser, compared operator by operator against hayro's in
# `tests/content_differential.rs` (L3 may depend on L2).
pdfcraft-content = { workspace = true }
pdfcraft-testkit = { workspace = true }
# Builds the decompression-bomb streams in the raster tests.
flate2 = { workspace = true }
+476
View File
@@ -0,0 +1,476 @@
//! The two content-stream parsers PdfCraft runs, operator by operator on the same bytes.
//!
//! The renderer, text extraction and Find read a page through hayro's tokenizer
//! (`hayro_syntax::content::UntypedIter`, typed through `TypedIter`). Edit text, redaction,
//! the redaction audit, Office export, accessibility and the automation tools read it through
//! `pdfcraft_content::parse`. Where the two disagree on the same bytes, a feature acts on a
//! page the renderer never showed: #3107, #3108, #3111 and #3177 are that class.
//!
//! Each fixture here is parsed by both and the operator sequences compared. Where the parsers
//! agree, that is asserted. Where they are known to disagree, the test pins what *each* side
//! does today and names the issue, so a change on either side fails here and the expectation
//! is updated with it. Nothing in this file touches product code, and no oracle runs here: the
//! MuPDF and pdftotext readings quoted in comments were taken by hand on one-page files built
//! from the same bytes.
//!
//! Several tests pin what `vendor/hayro-syntax` does today, so a hayro upgrade or a new patch
//! to it can fail them on purpose. When that happens, update the pinned side to the new
//! behaviour and note the upstream change (the version or patch, and its PR) in that test's
//! comment, as the tests under "divergences closed" do.
use std::fmt::Write as _;
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::content::{TypedIter, UntypedIter};
use hayro_syntax::object::{MaybeRef, Object as HObject};
use pdfcraft_cos::Object as CObject;
/// One operator with its operands, normalized so the two parsers can be compared as text.
#[derive(Clone, Debug, PartialEq, Eq)]
struct Tok {
op: String,
operands: Vec<String>,
}
fn tok(op: &str, operands: &[&str]) -> Tok {
Tok { op: op.into(), operands: operands.iter().map(|s| s.to_string()).collect() }
}
fn num(v: f64) -> String {
let r = (v * 1e6).round() / 1e6;
if r.fract() == 0.0 && r.abs() < 1e15 { format!("{}", r as i64) } else { format!("{r}") }
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().fold(String::new(), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
fn norm_cos(o: &CObject) -> String {
match o {
CObject::Null => "null".into(),
CObject::Bool(b) => b.to_string(),
CObject::Int(i) => num(*i as f64),
CObject::Real(r) => num(*r),
CObject::String(s) => format!("({})", hex(&s.bytes)),
CObject::Name(n) => format!("/{}", String::from_utf8_lossy(n)),
CObject::Array(a) => format!("[{}]", a.iter().map(norm_cos).collect::<Vec<_>>().join(" ")),
CObject::Dict(d) => {
let mut entries: Vec<String> = d.iter().map(|(k, v)| format!("/{} {}", String::from_utf8_lossy(k), norm_cos(v))).collect();
entries.sort();
format!("<<{}>>", entries.join(" "))
}
CObject::Ref(r) => format!("{} {} R", r.num, r.generation),
CObject::Stream(_) => "stream".into(),
}
}
fn norm_hayro(o: &HObject<'_>) -> String {
match o {
HObject::Null(_) => "null".into(),
HObject::Boolean(b) => b.to_string(),
HObject::Number(n) => num(n.as_f64()),
HObject::String(s) => format!("({})", hex(s.as_bytes())),
HObject::Name(n) => format!("/{}", String::from_utf8_lossy(n.as_ref())),
HObject::Array(a) => format!("[{}]", a.raw_iter().map(|m| norm_maybe(&m)).collect::<Vec<_>>().join(" ")),
HObject::Dict(d) => {
let mut entries: Vec<String> = d.entries().map(|(k, v)| format!("/{} {}", String::from_utf8_lossy(k.as_ref()), norm_maybe(&v))).collect();
entries.sort();
format!("<<{}>>", entries.join(" "))
}
// An inline image: its dictionary and data. ISO 32000-2 §8.9.7 (p.268) defines the data as
// the bytes between `ID` and `EI` "excluding the white-space delimiting those operators",
// which is what the content crate keeps. hayro keeps exactly the samples of an unfiltered
// image whose length its parameters give (#3438), but a filtered image, which it ends at
// a candidate `EI`, still keeps the white-space byte before that `EI` as its last byte.
// `hayro_keeps_the_delimiter_before_ei_only_in_filtered_image_data` pins both. The
// comparisons strip that byte from filtered images only, so that every other difference
// stands out: stripping it from an unfiltered image would cut a sample that happens to
// be white space.
HObject::Stream(s) => {
let raw = s.raw_data();
let filtered = s.dict().contains_key(b"F") || s.dict().contains_key(b"Filter");
let data = match raw.split_last() {
Some((b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ', rest)) if filtered => rest,
_ => &raw[..],
};
format!("{} data:{}", norm_hayro(&HObject::Dict(s.dict().clone())), hex(data))
}
}
}
fn norm_maybe(m: &MaybeRef<HObject<'_>>) -> String {
match m {
MaybeRef::NotRef(o) => norm_hayro(o),
MaybeRef::Ref(r) => format!("{} {} R", r.obj_number, r.gen_number),
}
}
/// The content crate's operators.
fn content(data: &[u8]) -> Vec<Tok> {
pdfcraft_content::parse(data)
.ops
.iter()
.map(|op| Tok {
op: String::from_utf8_lossy(&op.op).into_owned(),
operands: match &op.inline {
Some((dict, image)) => vec![format!("{} data:{}", norm_cos(&CObject::Dict(dict.clone())), hex(image))],
None => op.operands.iter().map(norm_cos).collect(),
},
})
.collect()
}
/// hayro's untyped instructions: every operator the tokenizer reads, with its operand stack.
fn hayro(data: &[u8]) -> Vec<Tok> {
let mut iter = UntypedIter::new(data);
let mut out = Vec::new();
while let Some(instr) = iter.next() {
out.push(Tok { op: String::from_utf8_lossy(instr.operator).into_owned(), operands: instr.operands().map(norm_hayro).collect() });
}
out
}
/// How many instructions hayro's typed iterator, the layer the renderer consumes, yields before
/// it stops.
fn hayro_typed_len(data: &[u8]) -> usize {
let mut iter = TypedIter::new(data);
let mut n = 0;
while iter.next().is_some() {
n += 1;
}
n
}
fn ops(toks: &[Tok]) -> Vec<&str> {
toks.iter().map(|t| t.op.as_str()).collect()
}
/// Both parsers on `data`, asserting they agree and that the operators are `expected`.
fn agree(name: &str, data: &[u8], expected: &[&str]) {
let (c, h) = (content(data), hayro(data));
assert_eq!(c, h, "{name}: content crate (left) and hayro (right) differ on {:?}", String::from_utf8_lossy(data));
assert_eq!(ops(&c), expected, "{name}");
}
/// An unfiltered 4 × 1 grey inline image whose samples are the four bytes of `samples`.
fn inline_gray(samples: &[u8; 4], after: &[u8]) -> Vec<u8> {
let mut v = b"q BI /W 4 /H 1 /BPC 8 /CS /G ID ".to_vec();
v.extend_from_slice(samples);
v.extend_from_slice(b"\nEI Q ");
v.extend_from_slice(after);
v
}
const GRAY_4X1: &str = "<</BPC 8 /CS /G /H 1 /W 4>>";
const GRAY_1X1: &str = "<</BPC 8 /CS /G /H 1 /W 1>>";
// ── where the two parsers agree ─────────────────────────────────────────────────────────────
#[test]
fn the_two_parsers_agree_on_well_formed_streams_and_common_damage() {
agree("plain", b"q 1 0 0 1 10 20 cm BT /F1 12 Tf (Hi) Tj ET Q", &["q", "cm", "BT", "Tf", "Tj", "ET", "Q"]);
agree("number forms", b"0.5 .5 6. -.5 +3 -0 5 w", &["w"]);
assert_eq!(content(b"0.5 .5 6. -.5 +3 -0 5 w")[0].operands, ["0.5", "0.5", "6", "-0.5", "3", "0", "5"]);
agree("strings", b"BT (a(b)c) Tj (es\\)c\\\\ape) Tj (line\\\ncont) Tj <48 65 6c> Tj ET", &["BT", "Tj", "Tj", "Tj", "Tj", "ET"]);
assert_eq!(content(b"(line\\\ncont) Tj")[0].operands, [format!("({})", hex(b"linecont"))], "a backslash joins the lines");
agree("unterminated string ends both streams", b"BT (open Tj ET q Q", &["BT"]);
agree("names with escapes", b"/A#20B gs /#41 gs", &["gs", "gs"]);
assert_eq!(content(b"/A#20B gs /#41 gs").iter().map(|t| t.operands[0].as_str()).collect::<Vec<_>>(), ["/A B", "/A"]);
agree("array and dictionary operands", b"/Span <</MCID 1 /ActualText (x)>> BDC [(a) -250 (b) 1.5] TJ EMC", &["BDC", "TJ", "EMC"]);
agree("ten operands", b"1 2 3 4 5 6 7 8 9 10 d0 q Q", &["d0", "q", "Q"]);
agree("unknown operators", b"q foo 1 bar Q BT ET", &["q", "foo", "bar", "Q", "BT", "ET"]);
agree("comments everywhere", b"q % c1\n1 0 % c2\n0 1 0 0 cm [1 % c3\n 2] TJ /N % c4\ngs Q % end", &["q", "cm", "TJ", "gs", "Q"]);
agree("comment without end of line", b"q Q % trailing", &["q", "Q"]);
agree("unbalanced q/Q and BT/ET", b"Q Q q BT BT ET q", &["Q", "Q", "q", "BT", "BT", "ET", "q"]);
agree("operands without an operator at the end", b"q 1 0 0 1 5", &["q"]);
agree("empty", b"", &[]);
agree("only a comment", b"% nothing", &[]);
}
/// The content crate exposes two operand conventions: `Op::nums::<N>` takes the last N, as
/// hayro's typed operators do (`0 20 Tc` is `Tc 20` to the renderer), while `Op::num(i)` counts
/// from the front. Both tokenizers keep every operand, so the choice is each consumer's: the
/// edit reader reads `Tc` through `num(0)` and gets 0 where the renderer gets 20, so the editor
/// and the renderer disagree on the character spacing (MuPDF renders the 20). No issue is filed
/// for the convention.
#[test]
fn surplus_operands_are_read_from_the_end_by_hayro_and_either_way_by_the_content_crate() {
let data = b"BT 0 20 Tc (x) Tj ET";
agree("surplus operand before Tc", data, &["BT", "Tc", "Tj", "ET"]);
let parsed = pdfcraft_content::parse(data);
let tc = &parsed.ops[1];
assert_eq!(tc.nums::<1>(), Some([20.0]), "last N, as the renderer");
assert_eq!(tc.num(0), Some(0.0), "the i-th from the front");
let mut iter = TypedIter::new(data);
let mut typed_tc = None;
while let Some(instr) = iter.next() {
if let TypedInstruction::CharacterSpacing(c) = instr {
typed_tc = Some(c.0.as_f64());
}
}
assert_eq!(typed_tc, Some(20.0), "hayro's typed Tc takes the last operand");
}
// ── divergences closed since this file was written: both parsers now agree ─────────────────
/// hayro's typed layer used to end the whole stream at a path operator whose operands don't
/// convert, so the renderer and text extraction showed nothing after it.
/// Closed by #3636 (30ed249a2): `TypedIter::next` (`vendor/hayro-syntax/src/content/mod.rs`)
/// now yields such an operator as `Fallback`, which adds nothing to the path, and reads on, as
/// the content crate, MuPDF and pdftotext do (`After` is shown).
#[test]
fn both_parsers_read_on_past_a_path_operator_with_too_few_operands() {
for (data, expected) in
[(&b"0 0 100 re f BT /F1 12 Tf (After) Tj ET"[..], &["re", "f", "BT", "Tf", "Tj", "ET"][..]), (b"5 m 0 0 100 100 re f", &["m", "re", "f"])]
{
agree("untyped: both keep the short operator", data, expected);
assert_eq!(hayro_typed_len(data), expected.len(), "hayro's typed stream reads on past the short operator");
}
assert_eq!(hayro_typed_len(b"0 0 100 100 re f BT /F1 12 Tf (After) Tj ET"), 6, "control: a complete re is read through");
}
/// hayro's operand stack used to hold ten entries, and an eleventh ended its stream. Closed by
/// #4345 (hayro-syntax patch 28, #4260): an instruction keeps up to 256 operands and past that
/// drops the oldest, so both parsers read all eleven and go on, as MuPDF and pdftotext do
/// (`AFTER` is shown).
#[test]
fn both_parsers_keep_more_than_ten_operands() {
let data = b"1 2 3 4 5 6 7 8 9 10 11 d0 q Q";
agree("eleven operands", data, &["d0", "q", "Q"]);
assert_eq!(content(data)[0].operands.len(), 11);
}
/// A stray closing delimiter used to end hayro's stream while the content crate skipped it.
/// Closed by #3636 (30ed249a2): hayro steps over the malformed token too. MuPDF reports a
/// syntax error and goes on (`AFTER` is shown).
#[test]
fn both_parsers_skip_a_stray_closing_delimiter() {
agree("stray delimiters", b"q ) > ] } { Q", &["q", "Q"]);
}
/// A name whose `#` escape is not followed by two hex digits (ISO 32000-2 §7.3.5, p.28) used to
/// end hayro's stream. Since #3636 (30ed249a2) hayro steps over the name and reads on, so both
/// parsers now read the same operators. One difference is still pinned: the content crate keeps
/// the name as written as the `gs` operand, while hayro drops it, so `gs` reaches the renderer
/// with no operand and does nothing, where the editor reads `/C#2`. No issue is filed. An empty
/// name (`/`) is read by both. MuPDF goes on in both cases, reporting the resource as missing.
#[test]
fn both_parsers_read_on_past_a_malformed_name_escape_but_only_the_content_crate_keeps_the_name() {
let data = b"/C#2 gs q Q";
assert_eq!(content(data), [tok("gs", &["/C#2"]), tok("q", &[]), tok("Q", &[])]);
assert_eq!(hayro(data), [tok("gs", &[]), tok("q", &[]), tok("Q", &[])], "hayro: the operators, without the name");
agree("an empty name", b"/ gs q Q", &["gs", "q", "Q"]);
}
/// #3111 (closed by #3299) made the cos lexer, and so the content crate, read `--280` as -280.
/// hayro used to read it as the two numbers 0 and -280, so `cm` took an extra operand and the
/// renderer drew the page with the matrix `0 0 1 0 -280 10`. Closed by #3630 (766ff9d05): a
/// doubled sign is one number to hayro too, so both read the matrix MuPDF applies (text at
/// x = 120). The content crate skips no byte: without the lexer's tolerance it would skip the
/// first `-` and read the same `-280` from the rest, so the operands alone don't show it.
#[test]
fn both_parsers_read_a_doubled_minus_as_one_number() {
let data = b"1 0 0 1 --280 10 cm";
agree("doubled minus", data, &["cm"]);
assert_eq!(content(data)[0].operands, ["1", "0", "0", "1", "-280", "10"]);
assert_eq!(pdfcraft_content::parse(data).skipped, 0, "--280 is read as one number, not recovered from -280");
}
// ── known divergences, pinned on both sides ─────────────────────────────────────────────────
/// No issue filed; #2877 (open) changes number reading in cos and render. ISO 32000-2 §7.3.3
/// (p.24) says a writer "shall not use … exponential format", so neither reading is the
/// spec's. The content crate reads `1e3` as the number 1000; hayro reads it as an unknown
/// operator and drops the operands before it. MuPDF stops the whole stream at it
/// ("unknown keyword: '1e3'"); pdftotext goes on.
#[test]
fn scientific_notation_is_a_number_to_the_content_crate_and_an_unknown_operator_to_hayro() {
let data = b"1e3 w q Q";
assert_eq!(content(data), [tok("w", &["1000"]), tok("q", &[]), tok("Q", &[])]);
assert_eq!(hayro(data), [tok("w", &[]), tok("q", &[]), tok("Q", &[])]);
// A number glued to letters: the content crate skips the digits and reads the letters as an
// operator; hayro reads the whole token as an operator (and clears the operands before it).
let data = b"12abc w q Q";
assert_eq!(content(data), [tok("abc", &[]), tok("w", &[]), tok("q", &[]), tok("Q", &[])]);
assert_eq!(hayro(data), [tok("w", &[]), tok("q", &[]), tok("Q", &[])]);
}
// ── inline images ───────────────────────────────────────────────────────────────────────────
/// The bytes of the first inline image each parser reads in `data`: the content crate's, then
/// hayro's raw stream data (nothing stripped).
fn inline_bytes(data: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>) {
let parsed = pdfcraft_content::parse(data);
let c = parsed.ops.iter().find_map(|op| op.inline.as_ref().map(|(_, d)| d.clone()));
let mut iter = UntypedIter::new(data);
let mut h = None;
while let Some(instr) = iter.next() {
if let Some(HObject::Stream(s)) = instr.operands().next() {
h = Some(s.raw_data().to_vec());
break;
}
}
(c, h)
}
/// ISO 32000-2 §8.9.7 (p.268): the data lies between `ID` and `EI` "excluding the white-space
/// delimiting those operators". The content crate keeps exactly that. hayro used to keep the
/// delimiter before `EI` as the last byte of every inline image. Since #3438 (f9d37c089) it
/// takes an unfiltered image's length from `/W /H /BPC /CS` and keeps exactly the samples, so
/// both read `ABCD`. A filtered image, which hayro still ends at a candidate `EI`, keeps the
/// delimiter: that part is still pinned. Decoders stop at the filter's end-of-data marker and
/// ignore the extra byte, so the other tests strip it from filtered images (see `norm_hayro`).
#[test]
fn hayro_keeps_the_delimiter_before_ei_only_in_filtered_image_data() {
let unfiltered = inline_bytes(&inline_gray(b"ABCD", b""));
assert_eq!(unfiltered, (Some(b"ABCD".to_vec()), Some(b"ABCD".to_vec())), "unfiltered: both keep exactly the samples");
let filtered = inline_bytes(b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41> EI Q");
assert_eq!(filtered, (Some(b"41>".to_vec()), Some(b"41> ".to_vec())), "filtered: hayro keeps the space before EI");
}
/// Samples that end in a white-space byte (here ` EI ` as four gray samples, the last one 0x20)
/// are all image data when the length is computed from the parameters (4 × 1 × 8 bits = 4
/// bytes, ISO 32000-2 §8.9.7). Both parsers keep all four bytes; hayro's raw data, unstripped,
/// is the same four bytes, so its unfiltered-length path (#3438) does not take a white-space
/// sample for the delimiter before `EI`.
#[test]
fn both_parsers_keep_a_last_sample_that_is_white_space() {
for samples in [b" EI ", b"\x10\x20\x30\x20", b"\x10\x20\x30\x0A"] {
let (c, h) = inline_bytes(&inline_gray(samples, b"q Q"));
assert_eq!(c.as_deref(), Some(&samples[..]), "content crate: {samples:02x?}");
assert_eq!(h.as_deref(), Some(&samples[..]), "hayro: {samples:02x?}");
}
}
#[test]
fn inline_images_agree_when_ei_is_delimited() {
agree("raw samples", &inline_gray(b"ABCD", b"BT (t) Tj ET"), &["q", "BI", "Q", "BT", "Tj", "ET"]);
agree("ASCIIHex with a space before EI", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41> EI Q BT (t) Tj ET", &["q", "BI", "Q", "BT", "Tj", "ET"]);
agree("ASCII85 with a space before EI", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /A85 ID 5l~> EI Q BT (t) Tj ET", &["q", "BI", "Q", "BT", "Tj", "ET"]);
agree("two images in a row", b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A\nEI BI /W 1 /H 1 /BPC 8 /CS /G ID B\nEI Q", &["q", "BI", "BI", "Q"]);
agree("a comment inside the parameters", b"q BI /W 1 % w\n/H 1 /BPC 8 /CS /G ID A\nEI Q", &["q", "BI", "Q"]);
// A fake ` EI ` inside the samples with no text operator after the image: hayro's look-ahead
// keeps searching and finds the real EI, so both read the four bytes.
agree("fake EI, nothing but q/Q after", &inline_gray(b" EI ", b"q Q"), &["q", "BI", "Q", "q", "Q"]);
assert_eq!(content(&inline_gray(b" EI ", b"q Q"))[1].operands, [format!("{GRAY_4X1} data:20454920")]);
}
/// #3107. Unfiltered samples that happen to hold ` EI ` followed by a text operator: hayro's
/// look-ahead used to accept the first `EI` because text follows it, so its image held no
/// samples and the rest of them ran as content (`EI` became an operator). Closed by #3438
/// (f9d37c089): hayro now takes the length from `/W /H /BPC /CS`, as the content crate does, and
/// both keep all four bytes (ISO 32000-2 §8.9.7). MuPDF and pdftotext show the text after.
#[test]
fn both_parsers_keep_a_fake_ei_inside_unfiltered_samples() {
let data = inline_gray(b" EI ", b"BT (t) Tj ET");
agree("fake EI, text after", &data, &["q", "BI", "Q", "BT", "Tj", "ET"]);
assert_eq!(content(&data)[1].operands, [format!("{GRAY_4X1} data:20454920")]);
}
/// #3108. `EI` written straight after the filter's end-of-data marker (`>EI`, `~>EI`): hayro
/// ends the image there, and the content crate used to find no delimited `EI` and swallow the
/// rest of the stream into the image. Closed by #3259 (the content crate accepts `EI` after the
/// marker) and b72078871 (the content crate takes a filtered image's end from hayro's
/// tokenizer): both end the image at the marker and read the text after, as MuPDF and pdftotext
/// do.
#[test]
fn both_parsers_end_the_image_at_ei_glued_to_the_filters_end_marker() {
for (name, data, dict, marker) in [
("ASCIIHex", &b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41>EI Q BT (t) Tj ET"[..], "<</BPC 8 /CS /G /F /AHx /H 1 /W 1>>", "34313e"),
("ASCII85", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /A85 ID 5l~>EI Q BT (t) Tj ET", "<</BPC 8 /CS /G /F /A85 /H 1 /W 1>>", "356c7e3e"),
] {
agree(name, data, &["q", "BI", "Q", "BT", "Tj", "ET"]);
assert_eq!(content(data)[1].operands, [format!("{dict} data:{marker}")], "{name}");
}
}
/// No issue filed. `EI` glued to the next operator (`EIQ`): the content crate swallows the rest
/// of the stream into the image; hayro yields a `BI` with no image at all, skips to the next
/// delimited token and reads on, so the `Q` glued to `EI` is lost too. MuPDF ends the whole
/// stream ("syntax error after inline image").
#[test]
fn ei_glued_to_the_next_operator_loses_the_image_on_both_sides_differently() {
let data = b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A EIQ BT (t) Tj ET";
let c = content(data);
assert_eq!(ops(&c), ["q", "BI"]);
assert_eq!(c[1].operands, [format!("{GRAY_1X1} data:{}", hex(b"A EIQ BT (t) Tj ET"))]);
assert_eq!(hayro(data), [tok("q", &[]), tok("BI", &[]), tok("BT", &[]), tok("Tj", &["(74)"]), tok("ET", &[])]);
}
/// No issue filed. An inline image with no `EI` at all: the content crate makes the rest of the
/// stream the image's data; hayro yields a `BI` with no image and then reads the samples and
/// everything after them as operators. MuPDF ends the whole stream.
#[test]
fn an_inline_image_without_ei_is_data_to_the_content_crate_and_operators_to_hayro() {
let data = b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A Q BT (t) Tj ET";
let c = content(data);
assert_eq!(ops(&c), ["q", "BI"]);
assert_eq!(c[1].operands, [format!("{GRAY_1X1} data:{}", hex(b"A Q BT (t) Tj ET"))]);
assert_eq!(ops(&hayro(data)), ["q", "BI", "A", "Q", "BT", "Tj", "ET"]);
}
/// No issue filed. Parameters that can't be read (`/W` with no value): the content crate skips
/// to the `EI` and goes on; hayro ends its stream. MuPDF reports the dictionary and goes on.
#[test]
fn an_inline_image_with_unreadable_parameters_is_skipped_by_the_content_crate_and_ends_hayros_stream() {
let data = b"q BI /W ID A\nEI Q";
assert_eq!(ops(&content(data)), ["q", "Q"]);
assert_eq!(ops(&hayro(data)), ["q"]);
}
/// One page, 612 × 792, whose content stream is `content`.
fn one_page_pdf(content: &[u8]) -> Vec<u8> {
let mut stream = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
stream.extend_from_slice(content);
stream.extend_from_slice(b"\nendstream");
let objs: [&[u8]; 4] = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>",
&stream,
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for off in offsets {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// End to end: the renderer draws all four samples of an unfiltered 4 × 1 gray inline image
/// whose last sample is a white-space byte (0x20 or 0x0A), so hayro's computed length (#3438)
/// reaches the pixels and no sample is taken for the delimiter before `EI`.
#[test]
fn the_renderer_draws_a_last_sample_that_is_white_space() {
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
for samples in [[0x20, 0x45, 0x49, 0x20], [0x10, 0x60, 0xB0, 0x20], [0x10, 0x60, 0xB0, 0x0A]] {
// Each sample is a 100 × 100 pt block from x = 100, y = 300 (PDF space).
let mut content = b"q 400 0 0 100 100 300 cm BI /W 4 /H 1 /BPC 8 /CS /G ID ".to_vec();
content.extend_from_slice(&samples);
content.extend_from_slice(b"\nEI Q");
let pdf = std::sync::Arc::new(one_page_pdf(&content));
let mut renderer = PageRenderer::new(pdf, RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(page.error.is_none(), "{samples:02x?}: {:?}", page.error);
// Block centres: x = 150 + 100 i, raster y = 792 - 350 = 442.
let drawn: Vec<u8> = (0..4u32)
.map(|i| {
let idx = ((442 * page.width + 150 + 100 * i) * 4) as usize;
page.rgba.get(idx).copied().unwrap_or(0xFF)
})
.collect();
assert_eq!(drawn, samples, "{samples:02x?}: the gray of each block");
}
}