printpdf 0.12.0

Rust library for reading and writing PDF files
Documentation
// tests/op_tests.rs

use printpdf::{
    Color, CurTransMat, LayerInternalId, Line, LineCapStyle, LineDashPattern,
    LineJoinStyle, LinePoint, Mm, Op, PaintMode, PdfDocument, PdfPage, PdfParseOptions,
    PdfSaveOptions, Point, Polygon, PolygonRing, Pt, Rgb, TextItem, TextMatrix, WindingOrder,
};

// Helper function to test operation serialization/deserialization
fn test_op(op: Op, op_name: &str) -> bool {
    let mut doc = PdfDocument::new(&format!("test_{}", op_name));
    let page = PdfPage::new(Mm(210.0), Mm(297.0), vec![op.clone()]);
    doc.pages.push(page);

    let mut warnings = Vec::new();
    let bytes = doc.save(&PdfSaveOptions::default(), &mut warnings);

    match PdfDocument::parse(&bytes, &PdfParseOptions::default(), &mut Vec::new()) {
        Ok(dd) => {
            if dd.pages.is_empty() || dd.pages[0].ops.is_empty() {
                panic!("empty pages for encoded {:?}", [op.clone()]);
            }
            pretty_assertions::assert_eq!(dd.pages[0].ops, vec![op.clone()]);
            true
        }
        Err(e) => {
            panic!("{}", e);
        }
    }
}

#[test]
fn test_op_marker() {
    assert!(test_op(
        Op::Marker {
            id: "test_marker".to_string()
        },
        "marker"
    ));
}

#[test]
fn test_op_graphics_state() {
    assert!(test_op(Op::SaveGraphicsState, "save_graphics_state"));
    assert!(test_op(Op::RestoreGraphicsState, "restore_graphics_state"));
}

#[test]
fn test_op_layer() {
    // `BeginOptionalContent` and `BeginLayer` both serialize to `BDC /OC /X`,
    // so the parser cannot tell them apart. It deliberately returns
    // `BeginLayer`: that is the op the serializer correlates with the page's
    // /Properties dictionary, which keeps the layer reference resolvable
    // across a save/parse round trip.
    let layer_id = LayerInternalId::new();
    let mut doc = PdfDocument::new("test_begin_optional_content");
    doc.pages.push(PdfPage::new(
        Mm(210.0),
        Mm(297.0),
        vec![Op::BeginOptionalContent {
            layer_id: layer_id.clone(),
        }],
    ));
    let bytes = doc.save(&PdfSaveOptions::default(), &mut Vec::new());
    let parsed = PdfDocument::parse(&bytes, &PdfParseOptions::default(), &mut Vec::new()).unwrap();
    pretty_assertions::assert_eq!(
        parsed.pages[0].ops,
        vec![Op::BeginLayer {
            layer_id: layer_id.clone()
        }]
    );
}

#[test]
fn test_op_text_section() {
    assert!(test_op(Op::StartTextSection, "start_text_section"));
    assert!(test_op(Op::EndTextSection, "end_text_section"));
}

#[test]
fn test_op_write_text() {
    // Test that text operations with proper font setup roundtrip correctly
    let mut doc = PdfDocument::new("test_write_text");
    let page = PdfPage::new(
        Mm(210.0),
        Mm(297.0),
        vec![
            Op::StartTextSection,
            Op::SetFont {
                font: printpdf::ops::PdfFontHandle::Builtin(printpdf::BuiltinFont::Helvetica),
                size: Pt(12.0),
            },
            Op::ShowText {
                items: vec![TextItem::Text("Hello, World!".to_string())],
            },
            Op::EndTextSection,
        ],
    );
    doc.pages.push(page);

    let mut warnings = Vec::new();
    let bytes = doc.save(&PdfSaveOptions::default(), &mut warnings);

    match PdfDocument::parse(&bytes, &PdfParseOptions::default(), &mut Vec::new()) {
        Ok(dd) => {
            if dd.pages.is_empty() || dd.pages[0].ops.is_empty() {
                panic!("empty pages");
            }
            
            // The parsed ops should now emit new SetFont and ShowText operations
            pretty_assertions::assert_eq!(dd.pages[0].ops.len(), 4);
            
            // Check each operation type and content
            assert!(matches!(dd.pages[0].ops[0], Op::StartTextSection));
            // Deserialization now emits SetFont instead of SetFontSizeBuiltinFont
            assert!(matches!(
                &dd.pages[0].ops[1],
                Op::SetFont { font: printpdf::ops::PdfFontHandle::Builtin(printpdf::BuiltinFont::Helvetica), size } if *size == Pt(12.0)
            ));
            // Deserialization now emits ShowText instead of WriteTextBuiltinFont
            assert!(matches!(
                &dd.pages[0].ops[2],
                Op::ShowText { items } if items.len() == 1
            ));
            assert!(matches!(dd.pages[0].ops[3], Op::EndTextSection));
        }
        Err(e) => {
            panic!("{}", e);
        }
    }
}

#[test]
fn test_op_write_text_without_font_setup() {
    // Test that text operations without font setup in text mode still work
    // (font was set before BT, or implicitly uses last font)
    let mut doc = PdfDocument::new("test_write_text_no_font");
    let page = PdfPage::new(
        Mm(210.0),
        Mm(297.0),
        vec![
            // Note: In new API, ShowText without SetFont will serialize but may produce empty/invalid text
            // This tests the edge case where font is implicit from context
            Op::StartTextSection,
            Op::ShowText {
                items: vec![TextItem::Text("Hello, World!".to_string())],
            },
            Op::EndTextSection,
        ],
    );
    doc.pages.push(page);

    let mut warnings = Vec::new();
    let bytes = doc.save(&PdfSaveOptions::default(), &mut warnings);

    // Parse the PDF back
    let mut parse_warnings = Vec::new();
    match PdfDocument::parse(&bytes, &PdfParseOptions::default(), &mut parse_warnings) {
        Ok(dd) => {
            // Should have warnings about missing Tf operator
            assert!(!parse_warnings.is_empty(), "Expected warnings about missing font setup");
            
            if dd.pages.is_empty() || dd.pages[0].ops.is_empty() {
                panic!("empty pages");
            }
            
            // Should still parse the text section markers
            assert!(dd.pages[0].ops.len() >= 2);
            assert!(matches!(dd.pages[0].ops[0], Op::StartTextSection));
            // ShowText may be empty or present depending on serialization behavior
        }
        Err(e) => {
            panic!("{}", e);
        }
    }
}

#[test]
fn test_op_draw_line() {
    let line = Line {
        points: vec![
            LinePoint {
                p: Point {
                    x: Pt(10.0),
                    y: Pt(10.0),
                },
                bezier: false,
            },
            LinePoint {
                p: Point {
                    x: Pt(100.0),
                    y: Pt(100.0),
                },
                bezier: false,
            },
        ],
        is_closed: false,
    };

    assert!(test_op(Op::DrawLine { line }, "draw_line"));
}

#[test]
fn test_op_draw_polygon() {
    let polygon = Polygon {
        rings: vec![PolygonRing {
            points: vec![
                LinePoint {
                    p: Point {
                        x: Pt(10.0),
                        y: Pt(10.0),
                    },
                    bezier: false,
                },
                LinePoint {
                    p: Point {
                        x: Pt(100.0),
                        y: Pt(100.0),
                    },
                    bezier: false,
                },
                LinePoint {
                    p: Point {
                        x: Pt(100.0),
                        y: Pt(10.0),
                    },
                    bezier: false,
                },
            ],
        }],
        mode: PaintMode::Fill,
        winding_order: WindingOrder::NonZero,
    };

    assert!(test_op(Op::DrawPolygon { polygon }, "draw_polygon"));
}

#[test]
fn test_op_color_settings() {
    assert!(test_op(
        Op::SetFillColor {
            col: Color::Rgb(Rgb {
                r: 1.0,
                g: 0.0,
                b: 0.0,
                icc_profile: None,
            })
        },
        "set_fill_color"
    ));

    assert!(test_op(
        Op::SetOutlineColor {
            col: Color::Rgb(Rgb {
                r: 0.0,
                g: 1.0,
                b: 0.0,
                icc_profile: None,
            })
        },
        "set_outline_color"
    ));

    assert!(test_op(
        Op::SetOutlineThickness { pt: Pt(2.0) },
        "set_outline_thickness"
    ));
}

#[test]
fn test_op_line_style() {
    assert!(test_op(
        Op::SetLineCapStyle {
            cap: LineCapStyle::Round
        },
        "set_line_cap_style"
    ));

    assert!(test_op(
        Op::SetLineJoinStyle {
            join: LineJoinStyle::Bevel
        },
        "set_line_join_style"
    ));

    assert!(test_op(
        Op::SetLineDashPattern {
            dash: LineDashPattern::new(0.0, &[5.0, 5.0]),
        },
        "set_line_dash_pattern"
    ));
}

#[test]
fn test_op_transform() {
    assert!(test_op(
        Op::SetTransformationMatrix {
            matrix: CurTransMat::Translate(Pt(50.0), Pt(50.0))
        },
        "set_transformation_matrix"
    ));

    assert!(test_op(
        Op::SetTextMatrix {
            matrix: TextMatrix::Translate(Pt(20.0), Pt(20.0))
        },
        "set_text_matrix"
    ));
}

/// #254: `ShowText` outside `StartTextSection`/`EndTextSection` produces a PDF
/// most viewers show as blank. The save must say so instead of staying silent.
#[test]
fn show_text_outside_text_section_warns() {
    let mut doc = PdfDocument::new("no_text_section");
    let ops = vec![Op::ShowText {
        items: vec![TextItem::Text("invisible".to_string())],
    }];
    doc.pages.push(PdfPage::new(Mm(210.0), Mm(297.0), ops));

    let mut warnings = Vec::new();
    let _ = doc.save(&PdfSaveOptions::default(), &mut warnings);
    assert!(
        warnings
            .iter()
            .any(|w| w.severity == printpdf::PdfParseErrorSeverity::Error
                && w.msg.contains("outside of a text section")),
        "expected an outside-text-section error, got: {warnings:#?}"
    );

    // The properly wrapped equivalent must NOT warn.
    let mut doc = PdfDocument::new("with_text_section");
    let ops = vec![
        Op::StartTextSection,
        Op::ShowText {
            items: vec![TextItem::Text("visible".to_string())],
        },
        Op::EndTextSection,
    ];
    doc.pages.push(PdfPage::new(Mm(210.0), Mm(297.0), ops));
    let mut warnings = Vec::new();
    let _ = doc.save(&PdfSaveOptions::default(), &mut warnings);
    assert!(
        !warnings.iter().any(|w| w.msg.contains("outside of a text section")),
        "wrapped text must not warn: {warnings:#?}"
    );
}