nigig-org/crates/apps/pdf/pdf-graphics/tests/golden_render.rs

506 lines
18 KiB
Rust

//! Phase 2 golden tests: the interpreter's output for a given content stream
//! is captured as stable text and compared against a checked-in file.
//!
//! This is what makes "renders correctly" a claim that can fail. A change to
//! the interpreter that alters operator sequencing, advances or dropped
//! operations shows up as a diff instead of passing silently.
//!
//! Set `UPDATE_GOLDEN=1` to rewrite the expectations after an intentional
//! change, then review the diff before committing it.
use std::fs;
use std::path::PathBuf;
use nigig_pdf_graphics::content::{parse_content_stream, PdfOp};
use nigig_pdf_graphics::recording::{format_commands, RecordingDevice, RenderCommand};
fn golden_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/golden")
.join(format!("{name}.txt"))
}
/// Interpret `content` and compare the rendered command list against the
/// golden file for `name`.
fn assert_golden(name: &str, content: &[u8]) -> Vec<RenderCommand> {
let ops = parse_content_stream(content).expect("content stream should parse");
let commands = RecordingDevice::from_ops(&ops);
let actual = format_commands(&commands);
let path = golden_path(name);
if std::env::var("UPDATE_GOLDEN").is_ok() {
fs::create_dir_all(path.parent().expect("parent")).expect("create golden dir");
fs::write(&path, &actual).expect("write golden");
return commands;
}
let expected = fs::read_to_string(&path).unwrap_or_else(|_| {
panic!(
"missing golden file {}\nrerun with UPDATE_GOLDEN=1 to create it.\nactual output was:\n{actual}",
path.display()
)
});
assert_eq!(
actual,
expected,
"rendered commands for {name} do not match {}",
path.display()
);
commands
}
#[test]
fn golden_vector_path() {
assert_golden(
"vector_path",
b"q\n0.5 0 0 rg\n2 w\n100 200 m\n300 400 l\n350 450 400 500 450 550 c\nh\nB\nQ\n",
);
}
#[test]
fn golden_text_run() {
assert_golden(
"text_run",
b"BT\n/F1 12 Tf\n1 0 0 1 100 700 Tm\n(Hello World) Tj\nET\n",
);
}
/// The next-line family: `Td`, `TD` (which sets leading), `T*` (which moves
/// by the negative leading), `'` and `"` (which move to the next line and
/// show text). Every one of these was a parse-time or interpret-time no-op
/// before Phase 2.
#[test]
fn golden_text_line_ops() {
assert_golden(
"text_line_ops",
b"BT\n/F1 12 Tf\n1 0 0 1 100 700 Tm\n14 TL\n0 -16 Td\n(Line one) Tj\n\
T*\n(Line two) Tj\n(Line three) '\n1.5 0.8 (Line four) \"\nET\n",
);
}
/// `"` sets word and character spacing before showing its text, and the
/// golden file above cannot witness that: spacing lives in the graphics
/// state and is never emitted as a render command, so dropping both
/// operands would leave `text_line_ops.txt` byte-identical.
///
/// The golden harness is the wrong tool for state that produces no command.
/// This asserts the state directly.
#[test]
fn the_quote_operator_applies_its_spacing_operands() {
let ops = parse_content_stream(b"BT\n/F1 12 Tf\n14 TL\n1.5 0.8 (Line four) \"\nET\n")
.expect("parses");
// The operands must survive parsing in the right order: word spacing
// first, then character spacing (PDF 32000-1 Table 109).
assert!(
ops.iter().any(|o| matches!(
o,
PdfOp::NextLineShowTextSpacing(aw, ac, text)
if *aw == 1.5 && *ac == 0.8 && text == b"Line four"
)),
"the \" operator must keep both operands and its text: {ops:?}"
);
let mut device = RecordingDevice::new();
nigig_pdf_graphics::content::interpret_ops(&ops, &mut device).expect("interprets");
assert_eq!(
device.current_state.word_spacing, 1.5,
"word spacing must reach the graphics state"
);
assert_eq!(
device.current_state.char_spacing, 0.8,
"character spacing must reach the graphics state"
);
}
#[test]
fn golden_text_with_kerning_and_spacing() {
// Exercises Tc/Tw/Tz and a TJ array with kerns both before and after a
// string. Every one of these was silently dropped before Phase 0.
assert_golden(
"text_kerning",
b"BT\n/F1 10 Tf\n0.5 Tc\n1.5 Tw\n90 Tz\n[(A) -120 (W) 50 (kern)] TJ\nET\n",
);
}
#[test]
fn golden_dash_and_stroke_params() {
assert_golden(
"dash_stroke",
b"[3 2] 1 d\n5 w\n1 J\n2 j\n8 M\n10 10 m\n200 10 l\nS\n",
);
}
#[test]
fn golden_rect_fill_rules() {
assert_golden(
"fill_rules",
b"10 20 100 50 re f\n10 20 100 50 re f*\n0 0 10 10 re W n\n",
);
}
#[test]
fn golden_inline_image() {
// BI/ID/EI with a tiny 2x2 grayscale payload.
assert_golden(
"inline_image",
b"q\nBI /W 2 /H 2 /BPC 8 /CS /G ID \x00\xFF\xFF\x00 EI\nQ\n",
);
}
#[test]
fn golden_xobject_and_graphics_state() {
assert_golden(
"xobject",
b"q\n1 0 0 1 50 60 cm\n/Im1 Do\nQ\nq\n/Fm0 Do\nQ\n",
);
}
#[test]
fn golden_cmyk_and_gray_colors() {
assert_golden(
"colors",
b"0.2 g\n0.8 G\n0.1 0.2 0.3 rg\n0.4 0.5 0.6 RG\n/DeviceCMYK cs\n0 0 0 1 sc\n",
);
}
/// The operations Step 2.3 lists as "the lies" must all reach the device.
#[test]
fn previously_dropped_operations_now_reach_the_device() {
let ops = parse_content_stream(b"[2 2] 0 d\nBI /W 1 /H 1 /BPC 8 /CS /G ID \xFF EI\n/Im1 Do\n")
.expect("parse");
let commands = RecordingDevice::from_ops(&ops);
assert!(
commands
.iter()
.any(|c| matches!(c, RenderCommand::SetDash(..))),
"set_dash was a TODO and never reached the device"
);
assert!(
commands
.iter()
.any(|c| matches!(c, RenderCommand::DrawInlineImage { .. })),
"inline images were silently dropped"
);
assert!(
commands
.iter()
.any(|c| matches!(c, RenderCommand::PaintXObject { .. })),
"Do was a no-op"
);
}
#[test]
fn an_inline_image_carries_its_real_geometry_and_payload() {
let ops = parse_content_stream(b"BI /W 2 /H 3 /BPC 8 /CS /RGB ID abcdef EI\n").expect("parse");
let commands = RecordingDevice::from_ops(&ops);
let image = commands
.iter()
.find_map(|c| match c {
RenderCommand::DrawInlineImage {
width,
height,
color_space,
data,
..
} => Some((*width, *height, color_space.clone(), data.clone())),
_ => None,
})
.expect("an inline image command");
assert_eq!(image.0, 2, "abbreviated /W must be read");
assert_eq!(image.1, 3, "abbreviated /H must be read");
assert_eq!(image.2, "DeviceRGB", "/RGB must expand to DeviceRGB");
assert_eq!(image.3, b"abcdef", "the payload must survive parsing");
}
#[test]
fn binary_ei_bytes_inside_image_data_do_not_end_the_image_early() {
// "EI" appearing mid-payload without surrounding delimiters must not be
// mistaken for the terminator.
let ops = parse_content_stream(b"BI /W 4 /H 1 /BPC 8 /CS /G ID aEIb EI\nQ\n").expect("parse");
let commands = RecordingDevice::from_ops(&ops);
let data = commands
.iter()
.find_map(|c| match c {
RenderCommand::DrawInlineImage { data, .. } => Some(data.clone()),
_ => None,
})
.expect("inline image");
assert_eq!(data, b"aEIb", "payload truncated at an embedded EI");
}
#[test]
fn an_unterminated_inline_image_does_not_panic_or_emit_garbage() {
// Malformed input must degrade to "no image", never to invented pixels.
let ops = parse_content_stream(b"BI /W 2 /H 2 /BPC 8 /CS /G ID \x00\x01\x02").expect("parse");
let commands = RecordingDevice::from_ops(&ops);
assert!(!commands
.iter()
.any(|c| matches!(c, RenderCommand::DrawInlineImage { .. })));
}
#[test]
fn the_golden_format_is_deterministic_across_runs() {
let content = b"q\n1 0 0 rg\n10 20 30 40 re f\nBT /F1 9 Tf (x) Tj ET\nQ\n";
let render = || {
let ops = parse_content_stream(content).expect("parse");
format_commands(&RecordingDevice::from_ops(&ops))
};
assert_eq!(render(), render());
}
#[test]
fn negative_zero_does_not_produce_a_spurious_diff() {
let a = parse_content_stream(b"0 0 m\n").expect("parse");
let b = parse_content_stream(b"-0 -0 m\n").expect("parse");
assert_eq!(
format_commands(&RecordingDevice::from_ops(&a)),
format_commands(&RecordingDevice::from_ops(&b)),
"-0 and 0 must format identically"
);
}
/// Every multi-character operator must parse as *itself*, never as a
/// shorter operator that shares its prefix or suffix.
///
/// This bug has now been found three times in this codebase: `cm` swallowed
/// by `m`, `rg`/`RG` shadowed by `r`/`R`, and `gs` parsed as `CloseStroke`
/// (which silently injected a stroked path into every page using a graphics
/// state). Fixing the third without preventing the fourth is not a fix, so
/// this enumerates the operator table instead of spot-checking.
#[test]
fn every_multi_char_operator_parses_as_itself() {
// (operands + operator, a predicate identifying the correct PdfOp)
/// Content bytes, the operator's name, and a predicate identifying the
/// `PdfOp` it must produce.
type OperatorCase = (&'static [u8], &'static str, fn(&PdfOp) -> bool);
let cases: Vec<OperatorCase> = vec![
(b"1 0 0 1 0 0 cm\n", "cm", |o| {
matches!(o, PdfOp::ConcatTransform(..))
}),
(b"0.1 0.2 0.3 rg\n", "rg", |o| {
matches!(o, PdfOp::RgbFill(..))
}),
(b"0.1 0.2 0.3 RG\n", "RG", |o| {
matches!(o, PdfOp::RgbStroke(..))
}),
(b"0 0 0 1 k\n", "k", |o| matches!(o, PdfOp::CmykFill(..))),
(b"0 0 0 1 K\n", "K", |o| matches!(o, PdfOp::CmykStroke(..))),
(b"/GS0 gs\n", "gs", |o| matches!(o, PdfOp::SetExtGState(_))),
(b"BT\n", "BT", |o| matches!(o, PdfOp::BeginText)),
(b"ET\n", "ET", |o| matches!(o, PdfOp::EndText)),
(b"BT /F1 12 Tf ET\n", "Tf", |o| {
matches!(o, PdfOp::SetFont(..))
}),
(b"BT 10 20 Td ET\n", "Td", |o| {
matches!(o, PdfOp::NextLine(..))
}),
(b"BT 10 20 TD ET\n", "TD", |o| {
matches!(o, PdfOp::MoveTextSetLeading(..))
}),
(b"BT 1 0 0 1 0 0 Tm ET\n", "Tm", |o| {
matches!(o, PdfOp::TextMatrix(..))
}),
(b"BT (hi) Tj ET\n", "Tj", |o| {
matches!(o, PdfOp::ShowText(_))
}),
(b"BT [(hi)] TJ ET\n", "TJ", |o| {
matches!(o, PdfOp::ShowTextPositioned(_))
}),
(b"BT 14 TL ET\n", "TL", |o| {
matches!(o, PdfOp::SetLeading(_))
}),
(b"BT 14 TL T* ET\n", "T*", |o| {
matches!(o, PdfOp::NextLineZero)
}),
(b"BT 14 TL (hi) ' ET\n", "'", |o| {
matches!(o, PdfOp::NextLineShowText(_))
}),
(b"BT 14 TL 1 2 (hi) \" ET\n", "\"", |o| {
matches!(o, PdfOp::NextLineShowTextSpacing(..))
}),
(b"BX ET EX\n", "BX", |o| {
matches!(o, PdfOp::BeginCompatibility)
}),
(b"BX ET EX\n", "EX", |o| {
matches!(o, PdfOp::EndCompatibility)
}),
(b"BT 2 Tc ET\n", "Tc", |o| {
matches!(o, PdfOp::SetCharSpacing(_))
}),
(b"BT 2 Tw ET\n", "Tw", |o| {
matches!(o, PdfOp::SetWordSpacing(_))
}),
(b"BT 90 Tz ET\n", "Tz", |o| {
matches!(o, PdfOp::SetHorizontalScale(_))
}),
(b"BT 3 Ts ET\n", "Ts", |o| {
matches!(o, PdfOp::SetTextRise(_))
}),
(b"BT 1 Tr ET\n", "Tr", |o| {
matches!(o, PdfOp::SetTextRendering(_))
}),
(b"/Im1 Do\n", "Do", |o| matches!(o, PdfOp::XObject(_))),
(b"/Sh0 sh\n", "sh", |o| matches!(o, PdfOp::Shading(_))),
(b"/DeviceRGB cs\n", "cs", |o| {
matches!(o, PdfOp::ColorSpaceFill(_))
}),
(b"/DeviceRGB CS\n", "CS", |o| {
matches!(o, PdfOp::ColorSpaceStroke(_))
}),
(b"0.5 sc\n", "sc", |o| matches!(o, PdfOp::FillColor(_))),
(b"0.5 SC\n", "SC", |o| matches!(o, PdfOp::StrokeColor(_))),
(b"0.5 scn\n", "scn", |o| matches!(o, PdfOp::FillColor(_))),
(b"0.5 SCN\n", "SCN", |o| matches!(o, PdfOp::StrokeColor(_))),
(b"W n\n", "W", |o| matches!(o, PdfOp::ClipWinding)),
(b"W* n\n", "W*", |o| matches!(o, PdfOp::ClipEvenOdd)),
(b"f*\n", "f*", |o| matches!(o, PdfOp::FillEvenOdd)),
(b"B*\n", "B*", |o| matches!(o, PdfOp::FillStrokeEvenOdd)),
(b"b*\n", "b*", |o| {
matches!(o, PdfOp::CloseFillStrokeEvenOdd)
}),
(b"b\n", "b", |o| matches!(o, PdfOp::CloseFillStrokeWinding)),
(b"/OC /MC0 BDC\n", "BDC", |o| {
matches!(o, PdfOp::BeginMarkedContentProps { .. })
}),
// BMC was excluded from this table as "genuinely unimplemented".
// It was not merely unimplemented: it failed to parse AND failed to
// clear its operand, so it shifted the operands of every following
// operator and made `1 0 0 rg` render green. Excluding a
// known-broken operator from the test whose purpose is to find
// broken operators is how it survived; the exclusion list is gone.
(b"/Span BMC\n", "BMC", |o| {
matches!(o, PdfOp::BeginMarkedContent(_))
}),
(b"EMC\n", "EMC", |o| matches!(o, PdfOp::EndMarkedContent)),
(b"100 200 d0\n", "d0", |o| {
matches!(o, PdfOp::Type3Width(..))
}),
(b"1000 0 0 0 750 750 d1\n", "d1", |o| {
matches!(o, PdfOp::Type3BBox { .. })
}),
// Tranche 4 (interpreter sweep): `ri` must not shadow `rg`, and
// `MP`/`DP` must parse as themselves rather than `Unknown`/dropped.
(b"/Perceptual ri\n", "ri", |o| {
matches!(o, PdfOp::RenderingIntent(_))
}),
(b"1.5 i\n", "i", |o| matches!(o, PdfOp::Flatness(_))),
(b"/Span MP\n", "MP", |o| matches!(o, PdfOp::MarkedPoint(_))),
(b"/Span /MC0 DP\n", "DP", |o| {
matches!(o, PdfOp::MarkedPointProps { .. })
}),
];
for (content, name, matches_op) in cases {
let ops = parse_content_stream(content)
.unwrap_or_else(|e| panic!("{name}: content should parse: {e}"));
assert!(
ops.iter().any(matches_op),
"operator {name} did not parse as itself.\n input: {:?}\n got: {ops:?}\n\
This is the operator-shadowing bug that hit cm, rg and gs.",
String::from_utf8_lossy(content)
);
}
}
/// The specific regression: `gs` must not become a path-painting operator.
///
/// `/GS0 gs` previously produced `CloseStroke`, so every page that set a
/// graphics state gained a stroked path that the document never asked for.
#[test]
fn gs_does_not_inject_a_spurious_stroke() {
let ops = parse_content_stream(b"/GS0 gs\n1 0 0 rg\n100 100 200 200 re f\n").expect("parses");
assert!(
!ops.iter()
.any(|o| matches!(o, PdfOp::CloseStroke | PdfOp::Stroke)),
"gs was parsed as a stroke operator: {ops:?}"
);
assert!(
ops.iter()
.any(|o| matches!(o, PdfOp::SetExtGState(n) if n == "GS0")),
"gs should name its resource: {ops:?}"
);
// And it must not reach the device as a stroke either.
let cmds = RecordingDevice::from_ops(&ops);
assert!(
!cmds
.iter()
.any(|c| matches!(c, RenderCommand::Stroke | RenderCommand::ClosePath)),
"a spurious stroked path reached the device: {cmds:?}"
);
}
/// `BMC` did not merely fail to parse — it failed to *clear its operand*,
/// so the leftover `/Tag` shifted the operands of every operator after it.
/// A single `BMC` turned `1 0 0 rg` (red) into `RgbFill(0.0, 1.0, 0.0)`
/// (green), on exactly the tagged documents that contain `BMC`.
#[test]
fn bmc_does_not_shift_the_operands_of_following_operators() {
let baseline = parse_content_stream(b"1 0 0 rg\n").expect("parses");
let after_bmc = parse_content_stream(b"/Span BMC 1 0 0 rg\n").expect("parses");
let fill_of = |ops: &[PdfOp]| -> Option<(f64, f64, f64)> {
ops.iter().find_map(|o| match o {
PdfOp::RgbFill(r, g, b) => Some((*r, *g, *b)),
_ => None,
})
};
assert_eq!(fill_of(&baseline), Some((1.0, 0.0, 0.0)), "sanity");
assert_eq!(
fill_of(&after_bmc),
Some((1.0, 0.0, 0.0)),
"an unconsumed BMC operand shifted rg's operands: {after_bmc:?}"
);
}
/// `BDC` must keep its property list: `/MCID` is the only link between a
/// run of page content and the structure element describing it.
#[test]
fn bdc_retains_its_mcid() {
let ops = parse_content_stream(b"/P <</MCID 7>> BDC\n").expect("parses");
let mcid = ops.iter().find_map(|o| match o {
PdfOp::BeginMarkedContentProps { properties, .. } => {
properties.as_dict().and_then(|d| d.get_int("MCID"))
}
_ => None,
});
assert_eq!(mcid, Some(7), "the /MCID was discarded: {ops:?}");
// And it must reach the device, not just the op list.
let cmds = RecordingDevice::from_ops(&ops);
assert!(
cmds.iter()
.any(|c| matches!(c, RenderCommand::BeginMarkedContent { mcid: Some(7), .. })),
"the MCID never reached the device: {cmds:?}"
);
}
/// A content-stream dictionary operand must parse as a dictionary. The
/// lexer treated `<<` as the start of a hex string, so `<</MCID 0>>` became
/// the string "0C0D0" and every property list in every document was
/// unreadable.
#[test]
fn a_dictionary_operand_is_not_read_as_a_hex_string() {
let ops = parse_content_stream(b"/P <</MCID 1 /Lang (en)>> BDC\n").expect("parses");
let props = ops
.iter()
.find_map(|o| match o {
PdfOp::BeginMarkedContentProps { properties, .. } => Some(properties.clone()),
_ => None,
})
.expect("a BDC op");
let dict = props.as_dict().expect("properties must be a dictionary");
assert_eq!(dict.get_int("MCID"), Some(1));
// A single `<` must still be a hex string.
let hex = parse_content_stream(b"<48656C6C6F> Tj\n").expect("parses");
assert!(
hex.iter()
.any(|o| matches!(o, PdfOp::ShowText(t) if t == b"Hello")),
"hex strings regressed: {hex:?}"
);
}