zpdf 0.7.0

Pure Rust PDF parsing library with wgpu GPU rendering
Documentation
//! End-to-end render tests for generated markup & geometric annotation
//! appearances (annotations with no `/AP`). Each builds a tiny PDF, renders
//! page 0 through the CPU backend with annotations wired like the CLI, and
//! checks pixels in page space.
#![cfg(feature = "cpu-render")]

use zpdf::{ContentInterpreter, ImageCache, PdfDocument, RenderBackend};

const SCALE: f32 = 2.0;

fn assemble(objs: &[Vec<u8>]) -> Vec<u8> {
    let mut out = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n".to_vec();
    let mut offsets = Vec::with_capacity(objs.len());
    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_pos = out.len();
    let n = objs.len() + 1;
    out.extend_from_slice(format!("xref\n0 {n}\n").as_bytes());
    out.extend_from_slice(b"0000000000 65535 f \n");
    for off in &offsets {
        out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
    }
    out.extend_from_slice(
        format!("trailer\n<< /Size {n} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n").as_bytes(),
    );
    out
}

fn stream_obj(dict: &str, content: &[u8]) -> Vec<u8> {
    let mut v = format!("<< {dict} /Length {} >>\nstream\n", content.len()).into_bytes();
    v.extend_from_slice(content);
    v.extend_from_slice(b"\nendstream");
    v
}

/// Build a 200×200 page whose content is `page_content` and whose single
/// annotation is `annot` (an object body). Renders page 0 with annotations.
fn render_with_annot(page_content: &[u8], annot: &[u8]) -> zpdf::cpu::RenderedPage {
    let pdf = assemble(&[
        b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
        b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
        b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R \
          /Resources << >> /Annots [5 0 R] >>"
            .to_vec(),
        stream_obj("", page_content),
        annot.to_vec(),
    ]);
    render(pdf)
}

fn render(pdf: Vec<u8>) -> zpdf::cpu::RenderedPage {
    let doc = PdfDocument::open(pdf).expect("open pdf");
    let page = doc.page(0).expect("page 0");
    let mut fonts = doc.load_page_fonts(&page);
    let content = doc.page_content_bytes(&page).expect("content bytes");
    let mut images = ImageCache::new();
    let annotations = doc.page_annotations(&page);
    let interp = ContentInterpreter::new(page.media_box)
        .with_fonts(&mut fonts)
        .with_document(doc.file(), &page.resources)
        .with_images(&mut images)
        .with_annotations(&annotations);
    let dl = interp.interpret(&content);
    zpdf::cpu::CpuRenderer::new()
        .with_fonts(&fonts)
        .with_images(&images)
        .render_display_list(&dl, SCALE)
        .expect("cpu render")
}

/// RGB at page-space (x, y) on a 200pt-high page rendered at SCALE.
fn px(page: &zpdf::cpu::RenderedPage, x: f64, y: f64) -> [u8; 3] {
    let ix = (x * SCALE as f64) as u32;
    let iy = ((200.0 - y) * SCALE as f64) as u32;
    let off = ((iy * page.width + ix) * 4) as usize;
    [page.data[off], page.data[off + 1], page.data[off + 2]]
}

fn assert_near(c: [u8; 3], want: [u8; 3], what: &str) {
    let ok = c
        .iter()
        .zip(want.iter())
        .all(|(a, b)| (*a as i32 - *b as i32).abs() <= 16);
    assert!(ok, "{what}: got {c:?}, want ≈{want:?}");
}

/// A Highlight over black text stays black (Multiply), over white turns yellow.
#[test]
fn highlight_multiplies_over_content() {
    // White background, black rectangle over the left half.
    let content: &[u8] = b"1 1 1 rg 0 0 200 200 re f\n0 0 0 rg 0 0 100 200 re f";
    // Quad covers x[20,180] y[80,120], Acrobat point order (TL TR BL BR).
    let annot: &[u8] = b"<< /Type /Annot /Subtype /Highlight /Rect [20 80 180 120] /F 4 \
        /QuadPoints [20 120 180 120 20 80 180 80] /C [1 1 0] >>";
    let page = render_with_annot(content, annot);

    assert_near(
        px(&page, 150.0, 100.0),
        [255, 255, 0],
        "highlight over white → yellow",
    );
    assert_near(
        px(&page, 50.0, 100.0),
        [0, 0, 0],
        "highlight over black → black",
    );
    assert_near(
        px(&page, 150.0, 150.0),
        [255, 255, 255],
        "above quad untouched (white)",
    );
    assert_near(
        px(&page, 50.0, 150.0),
        [0, 0, 0],
        "above quad untouched (black)",
    );
}

/// An Underline draws a coloured line near the bottom of its quad; the middle
/// of the quad stays clear.
#[test]
fn underline_strokes_near_quad_bottom() {
    let content: &[u8] = b"1 1 1 rg 0 0 200 200 re f";
    let annot: &[u8] = b"<< /Type /Annot /Subtype /Underline /Rect [20 80 180 120] /F 4 \
        /QuadPoints [20 120 180 120 20 80 180 80] /C [1 0 0] >>";
    let page = render_with_annot(content, annot);

    // Line at y = 80 + 40*0.12 = 84.8, ~2.4pt thick.
    assert_near(
        px(&page, 100.0, 85.0),
        [255, 0, 0],
        "red underline near bottom",
    );
    assert_near(
        px(&page, 100.0, 105.0),
        [255, 255, 255],
        "quad middle is clear",
    );
}

/// A Square with an interior colour fills green and strokes red.
#[test]
fn square_fills_and_strokes() {
    let content: &[u8] = b"1 1 1 rg 0 0 200 200 re f";
    let annot: &[u8] = b"<< /Type /Annot /Subtype /Square /Rect [40 40 160 160] /F 4 \
        /IC [0 1 0] /C [1 0 0] /BS << /W 4 >> >>";
    let page = render_with_annot(content, annot);

    assert_near(px(&page, 100.0, 100.0), [0, 255, 0], "green interior");
    assert_near(px(&page, 42.0, 100.0), [255, 0, 0], "red left border");
    assert_near(px(&page, 20.0, 100.0), [255, 255, 255], "outside untouched");
}

/// A Line annotation strokes between its two endpoints.
#[test]
fn line_strokes_between_endpoints() {
    let content: &[u8] = b"1 1 1 rg 0 0 200 200 re f";
    let annot: &[u8] = b"<< /Type /Annot /Subtype /Line /Rect [40 90 160 110] /F 4 \
        /L [40 100 160 100] /C [0 0 1] /BS << /W 3 >> >>";
    let page = render_with_annot(content, annot);

    assert_near(px(&page, 100.0, 100.0), [0, 0, 255], "blue line at y=100");
    assert_near(
        px(&page, 100.0, 130.0),
        [255, 255, 255],
        "off the line is clear",
    );
}

/// A Polygon fills its interior (/IC) and strokes the default black border.
#[test]
fn polygon_fills_interior() {
    let content: &[u8] = b"1 1 1 rg 0 0 200 200 re f";
    let annot: &[u8] = b"<< /Type /Annot /Subtype /Polygon /Rect [40 40 160 160] /F 4 \
        /Vertices [50 50 150 50 100 150] /IC [0 0 1] >>";
    let page = render_with_annot(content, annot);

    assert_near(
        px(&page, 100.0, 70.0),
        [0, 0, 255],
        "blue triangle interior",
    );
    assert_near(
        px(&page, 20.0, 100.0),
        [255, 255, 255],
        "outside the triangle",
    );
}

/// A rotated text-markup quad fills the actual (oriented) quadrilateral, not
/// its axis-aligned bounding box. A diamond's bbox corners stay clear.
#[test]
fn rotated_highlight_fills_only_the_quad() {
    let content: &[u8] = b"1 1 1 rg 0 0 200 200 re f";
    // A square rotated 45° (a diamond) centred at (100,100): its corners sit at
    // the midpoints of the [40,40,160,160] bounding box, so the bbox corners
    // lie OUTSIDE the quad — the old bbox fill would have painted them yellow.
    let annot: &[u8] = b"<< /Type /Annot /Subtype /Highlight /Rect [40 40 160 160] /F 4 \
        /QuadPoints [100 160 160 100 40 100 100 40] /C [1 1 0] >>";
    let page = render_with_annot(content, annot);

    assert_near(
        px(&page, 100.0, 100.0),
        [255, 255, 0],
        "diamond centre is yellow",
    );
    assert_near(
        px(&page, 130.0, 100.0),
        [255, 255, 0],
        "inside the diamond is yellow",
    );
    assert_near(
        px(&page, 48.0, 48.0),
        [255, 255, 255],
        "bbox corner outside the quad stays white",
    );
}

/// A Line with a closed arrowhead fills the head at the endpoint with the
/// interior colour (/IC).
#[test]
fn line_closed_arrowhead_fills_at_endpoint() {
    let content: &[u8] = b"1 1 1 rg 0 0 200 200 re f";
    let annot: &[u8] = b"<< /Type /Annot /Subtype /Line /Rect [20 80 180 120] /F 4 \
        /L [40 100 160 100] /C [0 0 0] /IC [1 0 0] /LE [/None /ClosedArrow] /BS << /W 3 >> >>";
    let page = render_with_annot(content, annot);

    assert_near(px(&page, 80.0, 100.0), [0, 0, 0], "black line shaft");
    assert_near(
        px(&page, 155.0, 100.0),
        [255, 0, 0],
        "red filled arrowhead at the end",
    );
    assert_near(
        px(&page, 80.0, 125.0),
        [255, 255, 255],
        "off the line is clear",
    );
}

/// A FreeText annotation paints its /C background and renders /Contents as
/// glyphs through the reused text-layout engine.
#[test]
fn freetext_paints_background_and_text() {
    let content: &[u8] = b"1 1 1 rg 0 0 200 200 re f";
    let annot: &[u8] = b"<< /Type /Annot /Subtype /FreeText /Rect [20 60 180 140] /F 4 \
        /Contents (Hello) /DA (/Helv 36 Tf 0 0 0 rg) /C [1 1 0] >>";
    let page = render_with_annot(content, annot);

    // The yellow background fills the rect…
    assert_near(
        px(&page, 30.0, 70.0),
        [255, 255, 0],
        "yellow background near a corner",
    );
    assert_near(
        px(&page, 195.0, 195.0),
        [255, 255, 255],
        "outside the rect is white",
    );
    // …and at least one dark glyph pixel appears in the text band.
    let mut glyph = false;
    let mut y = 60;
    while y < 140 {
        let mut x = 20;
        while x < 180 {
            let c = px(&page, x as f64, y as f64);
            if c[0] < 128 && c[1] < 128 && c[2] < 128 {
                glyph = true;
            }
            x += 2;
        }
        y += 2;
    }
    assert!(glyph, "FreeText rendered at least one glyph");
}

/// A hidden (/F 2) markup annotation with no /AP generates nothing visible.
#[test]
fn hidden_markup_is_not_painted() {
    let content: &[u8] = b"1 1 1 rg 0 0 200 200 re f";
    let annot: &[u8] = b"<< /Type /Annot /Subtype /Square /Rect [40 40 160 160] /F 2 \
        /IC [0 1 0] /C [1 0 0] /BS << /W 4 >> >>";
    let page = render_with_annot(content, annot);
    assert_near(
        px(&page, 100.0, 100.0),
        [255, 255, 255],
        "hidden annot not painted",
    );
}

/// A Caret fills a red insertion wedge inside its /Rect; outside stays clear.
#[test]
fn caret_paints_red_wedge() {
    let content: &[u8] = b"1 1 1 rg 0 0 200 200 re f";
    // /Rect [80 80 120 140] → wedge with apex at (100, 140).
    let annot: &[u8] = b"<< /Type /Annot /Subtype /Caret /Rect [80 80 120 140] /F 4 /C [1 0 0] >>";
    let page = render_with_annot(content, annot);
    assert_near(px(&page, 100.0, 120.0), [255, 0, 0], "red wedge body");
    assert_near(px(&page, 40.0, 100.0), [255, 255, 255], "clear to the left");
    assert_near(
        px(&page, 160.0, 100.0),
        [255, 255, 255],
        "clear to the right",
    );
}

/// A Redact annotation marks its /QuadPoints region: green /IC fill, red /C
/// outline — the renderer shows the *marked* state (it does not redact).
#[test]
fn redact_marks_region_fill_and_outline() {
    let content: &[u8] = b"1 1 1 rg 0 0 200 200 re f";
    // QuadPoints region inset within /Rect so the full 4pt outline is visible
    // (a quad flush with /Rect would have its outer half clipped away).
    let annot: &[u8] = b"<< /Type /Annot /Subtype /Redact /Rect [40 40 160 160] /F 4 \
        /QuadPoints [50 150 150 150 50 50 150 50] /IC [0 1 0] /C [1 0 0] /BS << /W 4 >> >>";
    let page = render_with_annot(content, annot);
    assert_near(px(&page, 100.0, 100.0), [0, 255, 0], "green redaction fill");
    // The 4pt outline is inset by half its width (centre at x≈52) so it stays
    // inside /Rect rather than being clipped on the boundary.
    assert_near(px(&page, 52.0, 100.0), [255, 0, 0], "red mark outline");
    assert_near(
        px(&page, 45.0, 100.0),
        [255, 255, 255],
        "outside the marked region",
    );
}

/// A Redact with no /QuadPoints and no /IC outlines the whole /Rect in black,
/// leaving the interior (and underlying content) visible.
#[test]
fn redact_rect_fallback_outline_only() {
    let content: &[u8] = b"1 1 1 rg 0 0 200 200 re f";
    let annot: &[u8] = b"<< /Type /Annot /Subtype /Redact /Rect [40 40 160 160] /F 4 \
        /BS << /W 4 >> >>";
    let page = render_with_annot(content, annot);
    assert_near(px(&page, 42.0, 100.0), [0, 0, 0], "black outline");
    assert_near(
        px(&page, 100.0, 100.0),
        [255, 255, 255],
        "interior not filled",
    );
}