use crate::error::PdfError;
use crate::forms::Widget;
#[cfg(test)]
use crate::geometry::Point;
use crate::geometry::Rectangle;
use crate::graphics::Color;
use crate::objects::{Dictionary, Object, ObjectReference};
#[derive(Debug, Clone)]
pub struct SignatureWidget {
pub widget: Widget,
pub field_ref: Option<ObjectReference>,
pub visual_type: SignatureVisualType,
pub handler_ref: Option<String>,
}
#[derive(Debug, Clone)]
pub enum SignatureVisualType {
Text {
show_name: bool,
show_date: bool,
show_reason: bool,
show_location: bool,
},
Graphic {
image_data: Vec<u8>,
format: ImageFormat,
maintain_aspect: bool,
},
Mixed {
image_data: Vec<u8>,
format: ImageFormat,
text_position: TextPosition,
show_details: bool,
},
InkSignature {
strokes: Vec<InkStroke>,
color: Color,
width: f64,
},
}
#[derive(Debug, Clone, Copy)]
pub enum ImageFormat {
PNG,
JPEG,
}
#[derive(Debug, Clone, Copy)]
pub enum TextPosition {
Above,
Below,
Left,
Right,
Overlay,
}
#[derive(Debug, Clone)]
pub struct InkStroke {
pub points: Vec<(f64, f64)>,
pub pressures: Option<Vec<f64>>,
}
impl SignatureWidget {
pub fn new(rect: Rectangle, visual_type: SignatureVisualType) -> Self {
Self {
widget: Widget::new(rect),
field_ref: None,
visual_type,
handler_ref: None,
}
}
pub fn with_field_ref(mut self, field_ref: ObjectReference) -> Self {
self.field_ref = Some(field_ref);
self
}
pub fn with_handler(mut self, handler: impl Into<String>) -> Self {
self.handler_ref = Some(handler.into());
self
}
pub fn generate_appearance_stream(
&self,
signed: bool,
signer_name: Option<&str>,
reason: Option<&str>,
location: Option<&str>,
date: Option<&str>,
) -> Result<Vec<u8>, PdfError> {
let mut stream = Vec::new();
let rect = &self.widget.rect;
let width = rect.width();
let height = rect.height();
stream.extend(b"q\n");
if let Some(bg_color) = &self.widget.appearance.background_color {
Self::set_fill_color(&mut stream, bg_color);
stream.extend(format!("0 0 {} {} re f\n", width, height).as_bytes());
}
if self.widget.appearance.border_width > 0.0 {
if let Some(border_color) = &self.widget.appearance.border_color {
Self::set_stroke_color(&mut stream, border_color);
stream.extend(format!("{} w\n", self.widget.appearance.border_width).as_bytes());
stream.extend(format!("0 0 {} {} re S\n", width, height).as_bytes());
}
}
match &self.visual_type {
SignatureVisualType::Text {
show_name,
show_date,
show_reason,
show_location,
} => {
self.generate_text_appearance(
&mut stream,
signed,
signer_name,
reason,
location,
date,
*show_name,
*show_date,
*show_reason,
*show_location,
)?;
}
SignatureVisualType::Graphic {
image_data,
format,
maintain_aspect,
} => {
self.generate_graphic_appearance(
&mut stream,
image_data,
*format,
*maintain_aspect,
)?;
}
SignatureVisualType::Mixed {
image_data,
format,
text_position,
show_details,
} => {
self.generate_mixed_appearance(
&mut stream,
image_data,
*format,
*text_position,
*show_details,
signed,
signer_name,
reason,
date,
)?;
}
SignatureVisualType::InkSignature {
strokes,
color,
width,
} => {
self.generate_ink_appearance(&mut stream, strokes, color, *width)?;
}
}
stream.extend(b"Q\n");
Ok(stream)
}
#[allow(clippy::too_many_arguments)]
fn generate_text_appearance(
&self,
stream: &mut Vec<u8>,
signed: bool,
signer_name: Option<&str>,
reason: Option<&str>,
location: Option<&str>,
date: Option<&str>,
show_name: bool,
show_date: bool,
show_reason: bool,
show_location: bool,
) -> Result<(), PdfError> {
let rect = &self.widget.rect;
let width = rect.width();
let height = rect.height();
stream.extend(b"BT\n");
stream.extend(b"/Helv 10 Tf\n");
stream.extend(b"0 g\n");
let mut y_offset = height - 15.0;
let x_offset = 5.0;
if signed {
if show_name && signer_name.is_some() {
stream.extend(format!("{} {} Td\n", x_offset, y_offset).as_bytes());
if let Some(name) = signer_name {
stream.extend(format!("(Digitally signed by: {}) Tj\n", name).as_bytes());
}
y_offset -= 12.0;
let _ = y_offset;
}
if show_date && date.is_some() {
stream.extend(b"0 -12 Td\n");
if let Some(d) = date {
stream.extend(format!("(Date: {}) Tj\n", d).as_bytes());
}
y_offset -= 12.0;
let _ = y_offset;
}
if show_reason && reason.is_some() {
stream.extend(b"0 -12 Td\n");
if let Some(r) = reason {
stream.extend(format!("(Reason: {}) Tj\n", r).as_bytes());
}
y_offset -= 12.0;
let _ = y_offset;
}
if show_location && location.is_some() {
stream.extend(b"0 -12 Td\n");
if let Some(l) = location {
stream.extend(format!("(Location: {}) Tj\n", l).as_bytes());
}
}
} else {
stream.extend(format!("{} {} Td\n", width / 2.0 - 30.0, height / 2.0).as_bytes());
stream.extend(b"(Click to sign) Tj\n");
}
stream.extend(b"ET\n");
Ok(())
}
fn generate_graphic_appearance(
&self,
stream: &mut Vec<u8>,
_image_data: &[u8],
_format: ImageFormat,
maintain_aspect: bool,
) -> Result<(), PdfError> {
let rect = &self.widget.rect;
let width = rect.width();
let height = rect.height();
stream.extend(b"q\n");
if maintain_aspect {
stream.extend(format!("{} 0 0 {} 0 0 cm\n", width * 0.8, height * 0.8).as_bytes());
} else {
stream.extend(format!("{} 0 0 {} 0 0 cm\n", width, height).as_bytes());
}
stream.extend(b"/Img1 Do\n");
stream.extend(b"Q\n");
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn generate_mixed_appearance(
&self,
stream: &mut Vec<u8>,
_image_data: &[u8],
_format: ImageFormat,
text_position: TextPosition,
show_details: bool,
signed: bool,
signer_name: Option<&str>,
_reason: Option<&str>,
date: Option<&str>,
) -> Result<(), PdfError> {
let rect = &self.widget.rect;
let width = rect.width();
let height = rect.height();
let (img_rect, text_rect) = match text_position {
TextPosition::Above => {
let text_height = height * 0.3;
(
(0.0, 0.0, width, height - text_height),
(0.0, height - text_height, width, text_height),
)
}
TextPosition::Below => {
let text_height = height * 0.3;
(
(0.0, text_height, width, height - text_height),
(0.0, 0.0, width, text_height),
)
}
TextPosition::Left => {
let text_width = width * 0.4;
(
(text_width, 0.0, width - text_width, height),
(0.0, 0.0, text_width, height),
)
}
TextPosition::Right => {
let text_width = width * 0.4;
(
(0.0, 0.0, width - text_width, height),
(width - text_width, 0.0, text_width, height),
)
}
TextPosition::Overlay => ((0.0, 0.0, width, height), (0.0, 0.0, width, height * 0.3)),
};
stream.extend(b"q\n");
stream.extend(
format!(
"{} 0 0 {} {} {} cm\n",
img_rect.2, img_rect.3, img_rect.0, img_rect.1
)
.as_bytes(),
);
stream.extend(b"/Img1 Do\n");
stream.extend(b"Q\n");
if show_details && signed {
stream.extend(b"BT\n");
stream.extend(b"/Helv 8 Tf\n");
stream.extend(b"0 g\n");
let mut y_pos = text_rect.1 + text_rect.3 - 10.0;
if let Some(name) = signer_name {
stream.extend(format!("{} {} Td\n", text_rect.0 + 2.0, y_pos).as_bytes());
stream.extend(format!("({}) Tj\n", name).as_bytes());
y_pos -= 10.0;
let _ = y_pos;
}
if let Some(d) = date {
stream.extend(b"0 -10 Td\n");
stream.extend(format!("({}) Tj\n", d).as_bytes());
}
stream.extend(b"ET\n");
}
Ok(())
}
fn generate_ink_appearance(
&self,
stream: &mut Vec<u8>,
strokes: &[InkStroke],
color: &Color,
width: f64,
) -> Result<(), PdfError> {
Self::set_stroke_color(stream, color);
stream.extend(format!("{} w\n", width).as_bytes());
stream.extend(b"1 J\n"); stream.extend(b"1 j\n");
for stroke in strokes {
if stroke.points.len() < 2 {
continue;
}
let first = &stroke.points[0];
stream.extend(format!("{} {} m\n", first.0, first.1).as_bytes());
for point in &stroke.points[1..] {
stream.extend(format!("{} {} l\n", point.0, point.1).as_bytes());
}
stream.extend(b"S\n");
}
Ok(())
}
fn set_fill_color(stream: &mut Vec<u8>, color: &Color) {
match color {
Color::Rgb(r, g, b) => {
stream.extend(format!("{} {} {} rg\n", r, g, b).as_bytes());
}
Color::Gray(v) => {
stream.extend(format!("{} g\n", v).as_bytes());
}
Color::Cmyk(c, m, y, k) => {
stream.extend(format!("{} {} {} {} k\n", c, m, y, k).as_bytes());
}
}
}
fn set_stroke_color(stream: &mut Vec<u8>, color: &Color) {
match color {
Color::Rgb(r, g, b) => {
stream.extend(format!("{} {} {} RG\n", r, g, b).as_bytes());
}
Color::Gray(v) => {
stream.extend(format!("{} G\n", v).as_bytes());
}
Color::Cmyk(c, m, y, k) => {
stream.extend(format!("{} {} {} {} K\n", c, m, y, k).as_bytes());
}
}
}
pub fn to_widget_dict(&self) -> Dictionary {
let mut dict = Dictionary::new();
dict.set("Type", Object::Name("Annot".to_string()));
dict.set("Subtype", Object::Name("Widget".to_string()));
let rect = &self.widget.rect;
dict.set(
"Rect",
Object::Array(vec![
Object::Real(rect.lower_left.x),
Object::Real(rect.lower_left.y),
Object::Real(rect.upper_right.x),
Object::Real(rect.upper_right.y),
]),
);
if let Some(ref field_ref) = self.field_ref {
dict.set("Parent", Object::Reference(*field_ref));
}
let mut bs_dict = Dictionary::new();
bs_dict.set("Type", Object::Name("Border".to_string()));
bs_dict.set("W", Object::Real(self.widget.appearance.border_width));
bs_dict.set(
"S",
Object::Name(self.widget.appearance.border_style.pdf_name().to_string()),
);
dict.set("BS", Object::Dictionary(bs_dict));
let mut mk_dict = Dictionary::new();
if let Some(ref bg_color) = self.widget.appearance.background_color {
mk_dict.set("BG", Self::color_to_array(bg_color));
}
if let Some(ref border_color) = self.widget.appearance.border_color {
mk_dict.set("BC", Self::color_to_array(border_color));
}
dict.set("MK", Object::Dictionary(mk_dict));
dict.set("F", Object::Integer(4));
dict
}
fn color_to_array(color: &Color) -> Object {
match color {
Color::Gray(v) => Object::Array(vec![Object::Real(*v)]),
Color::Rgb(r, g, b) => {
Object::Array(vec![Object::Real(*r), Object::Real(*g), Object::Real(*b)])
}
Color::Cmyk(c, m, y, k) => Object::Array(vec![
Object::Real(*c),
Object::Real(*m),
Object::Real(*y),
Object::Real(*k),
]),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_signature_widget_creation() {
let rect = Rectangle::new(Point::new(100.0, 100.0), Point::new(300.0, 150.0));
let visual = SignatureVisualType::Text {
show_name: true,
show_date: true,
show_reason: false,
show_location: false,
};
let widget = SignatureWidget::new(rect, visual);
assert!(widget.field_ref.is_none());
assert!(widget.handler_ref.is_none());
}
#[test]
fn test_text_appearance_generation() {
let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(200.0, 50.0));
let visual = SignatureVisualType::Text {
show_name: true,
show_date: true,
show_reason: true,
show_location: false,
};
let widget = SignatureWidget::new(rect, visual);
let appearance = widget.generate_appearance_stream(
true,
Some("John Doe"),
Some("Approval"),
None,
Some("2025-08-13"),
);
assert!(appearance.is_ok());
let stream = appearance.unwrap();
assert!(!stream.is_empty());
let stream_str = String::from_utf8_lossy(&stream);
assert!(stream_str.contains("John Doe"));
assert!(stream_str.contains("2025-08-13"));
assert!(stream_str.contains("Approval"));
}
#[test]
fn test_ink_signature_appearance() {
let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(150.0, 50.0));
let stroke1 = InkStroke {
points: vec![(10.0, 10.0), (20.0, 20.0), (30.0, 15.0)],
pressures: None,
};
let stroke2 = InkStroke {
points: vec![(40.0, 25.0), (50.0, 30.0), (60.0, 25.0)],
pressures: None,
};
let visual = SignatureVisualType::InkSignature {
strokes: vec![stroke1, stroke2],
color: Color::black(),
width: 2.0,
};
let widget = SignatureWidget::new(rect, visual);
let appearance = widget.generate_appearance_stream(true, None, None, None, None);
assert!(appearance.is_ok());
let stream = appearance.unwrap();
let stream_str = String::from_utf8_lossy(&stream);
assert!(stream_str.contains("m")); assert!(stream_str.contains("l")); assert!(stream_str.contains("S")); }
#[test]
fn test_widget_dict_generation() {
let rect = Rectangle::new(Point::new(100.0, 100.0), Point::new(300.0, 150.0));
let visual = SignatureVisualType::Text {
show_name: true,
show_date: false,
show_reason: false,
show_location: false,
};
let mut widget = SignatureWidget::new(rect, visual);
widget.widget.appearance.background_color = Some(Color::gray(0.9));
widget.widget.appearance.border_color = Some(Color::black());
let dict = widget.to_widget_dict();
assert_eq!(dict.get("Type"), Some(&Object::Name("Annot".to_string())));
assert_eq!(
dict.get("Subtype"),
Some(&Object::Name("Widget".to_string()))
);
assert!(dict.get("Rect").is_some());
assert!(dict.get("BS").is_some());
assert!(dict.get("MK").is_some());
}
#[test]
fn test_signature_widget_with_field_ref() {
let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 50.0));
let visual = SignatureVisualType::Text {
show_name: true,
show_date: true,
show_reason: false,
show_location: false,
};
let field_ref = ObjectReference::new(10, 0);
let widget = SignatureWidget::new(rect, visual).with_field_ref(field_ref);
assert_eq!(widget.field_ref, Some(field_ref));
let dict = widget.to_widget_dict();
assert_eq!(dict.get("Parent"), Some(&Object::Reference(field_ref)));
}
#[test]
fn test_signature_widget_with_handler() {
let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 50.0));
let visual = SignatureVisualType::Text {
show_name: true,
show_date: false,
show_reason: false,
show_location: false,
};
let widget = SignatureWidget::new(rect, visual).with_handler("Adobe.PPKLite");
assert_eq!(widget.handler_ref, Some("Adobe.PPKLite".to_string()));
}
#[test]
fn test_graphic_signature_visual_type() {
let image_data = vec![0xFF, 0xD8, 0xFF, 0xE0]; let visual = SignatureVisualType::Graphic {
image_data: image_data.clone(),
format: ImageFormat::JPEG,
maintain_aspect: true,
};
match visual {
SignatureVisualType::Graphic {
image_data: data,
format,
maintain_aspect,
} => {
assert_eq!(data, image_data);
matches!(format, ImageFormat::JPEG);
assert!(maintain_aspect);
}
_ => panic!("Expected Graphic visual type"),
}
}
#[test]
fn test_mixed_signature_visual_type() {
let image_data = vec![0x89, 0x50, 0x4E, 0x47]; let visual = SignatureVisualType::Mixed {
image_data: image_data.clone(),
format: ImageFormat::PNG,
text_position: TextPosition::Below,
show_details: true,
};
match visual {
SignatureVisualType::Mixed {
image_data: data,
format,
text_position,
show_details,
} => {
assert_eq!(data, image_data);
matches!(format, ImageFormat::PNG);
matches!(text_position, TextPosition::Below);
assert!(show_details);
}
_ => panic!("Expected Mixed visual type"),
}
}
#[test]
fn test_ink_stroke_with_pressure() {
let stroke = InkStroke {
points: vec![(10.0, 10.0), (20.0, 20.0), (30.0, 15.0)],
pressures: Some(vec![0.5, 0.7, 0.6]),
};
assert_eq!(stroke.points.len(), 3);
assert_eq!(stroke.pressures.as_ref().unwrap().len(), 3);
assert_eq!(stroke.points[0], (10.0, 10.0));
assert_eq!(stroke.pressures.as_ref().unwrap()[1], 0.7);
}
#[test]
fn test_text_position_variants() {
let positions = vec![
TextPosition::Above,
TextPosition::Below,
TextPosition::Left,
TextPosition::Right,
TextPosition::Overlay,
];
for pos in positions {
match pos {
TextPosition::Above => assert!(true),
TextPosition::Below => assert!(true),
TextPosition::Left => assert!(true),
TextPosition::Right => assert!(true),
TextPosition::Overlay => assert!(true),
}
}
}
#[test]
fn test_image_format_variants() {
let png = ImageFormat::PNG;
let jpeg = ImageFormat::JPEG;
matches!(png, ImageFormat::PNG);
matches!(jpeg, ImageFormat::JPEG);
}
#[test]
fn test_color_to_array() {
let gray = Color::gray(0.5);
let gray_array = SignatureWidget::color_to_array(&gray);
assert_eq!(gray_array, Object::Array(vec![Object::Real(0.5)]));
let rgb = Color::rgb(1.0, 0.0, 0.0);
let rgb_array = SignatureWidget::color_to_array(&rgb);
assert_eq!(
rgb_array,
Object::Array(vec![
Object::Real(1.0),
Object::Real(0.0),
Object::Real(0.0),
])
);
let cmyk = Color::cmyk(0.0, 1.0, 1.0, 0.0);
let cmyk_array = SignatureWidget::color_to_array(&cmyk);
assert_eq!(
cmyk_array,
Object::Array(vec![
Object::Real(0.0),
Object::Real(1.0),
Object::Real(1.0),
Object::Real(0.0),
])
);
}
#[test]
fn test_set_fill_color() {
let mut stream = Vec::new();
let rgb = Color::rgb(1.0, 0.5, 0.0);
SignatureWidget::set_fill_color(&mut stream, &rgb);
let result = String::from_utf8_lossy(&stream);
assert!(result.contains("1 0.5 0 rg"));
stream.clear();
let gray = Color::gray(0.7);
SignatureWidget::set_fill_color(&mut stream, &gray);
let result = String::from_utf8_lossy(&stream);
assert!(result.contains("0.7 g"));
stream.clear();
let cmyk = Color::cmyk(0.2, 0.3, 0.4, 0.1);
SignatureWidget::set_fill_color(&mut stream, &cmyk);
let result = String::from_utf8_lossy(&stream);
assert!(result.contains("0.2 0.3 0.4 0.1 k"));
}
#[test]
fn test_set_stroke_color() {
let mut stream = Vec::new();
let rgb = Color::rgb(0.0, 0.0, 1.0);
SignatureWidget::set_stroke_color(&mut stream, &rgb);
let result = String::from_utf8_lossy(&stream);
assert!(result.contains("0 0 1 RG"));
stream.clear();
let gray = Color::gray(0.3);
SignatureWidget::set_stroke_color(&mut stream, &gray);
let result = String::from_utf8_lossy(&stream);
assert!(result.contains("0.3 G"));
stream.clear();
let cmyk = Color::cmyk(1.0, 0.0, 0.0, 0.0);
SignatureWidget::set_stroke_color(&mut stream, &cmyk);
let result = String::from_utf8_lossy(&stream);
assert!(result.contains("1 0 0 0 K"));
}
#[test]
fn test_empty_text_signature() {
let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(200.0, 50.0));
let visual = SignatureVisualType::Text {
show_name: false,
show_date: false,
show_reason: false,
show_location: false,
};
let widget = SignatureWidget::new(rect, visual);
let appearance = widget.generate_appearance_stream(false, None, None, None, None);
assert!(appearance.is_ok());
let stream = appearance.unwrap();
let stream_str = String::from_utf8_lossy(&stream);
assert!(stream_str.contains("q")); assert!(stream_str.contains("Q")); }
#[test]
fn test_full_text_signature() {
let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(300.0, 100.0));
let visual = SignatureVisualType::Text {
show_name: true,
show_date: true,
show_reason: true,
show_location: true,
};
let widget = SignatureWidget::new(rect, visual);
let appearance = widget.generate_appearance_stream(
true,
Some("Jane Smith"),
Some("Document Review"),
Some("New York"),
Some("2025-08-14"),
);
assert!(appearance.is_ok());
let stream = appearance.unwrap();
let stream_str = String::from_utf8_lossy(&stream);
assert!(stream_str.contains("Jane Smith"));
assert!(stream_str.contains("Document Review"));
assert!(stream_str.contains("New York"));
assert!(stream_str.contains("2025-08-14"));
}
#[test]
fn test_widget_with_border_styles() {
let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 50.0));
let visual = SignatureVisualType::Text {
show_name: true,
show_date: false,
show_reason: false,
show_location: false,
};
let mut widget = SignatureWidget::new(rect, visual);
widget.widget.appearance.border_width = 2.0;
widget.widget.appearance.border_color = Some(Color::rgb(0.0, 0.0, 1.0));
let dict = widget.to_widget_dict();
if let Some(Object::Dictionary(bs_dict)) = dict.get("BS") {
assert_eq!(bs_dict.get("W"), Some(&Object::Real(2.0)));
assert!(bs_dict.get("S").is_some());
} else {
panic!("Expected BS dictionary");
}
}
#[test]
fn test_multiple_ink_strokes() {
let _rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(200.0, 100.0));
let strokes = vec![
InkStroke {
points: vec![(10.0, 10.0), (20.0, 20.0)],
pressures: None,
},
InkStroke {
points: vec![(30.0, 30.0), (40.0, 40.0), (50.0, 35.0)],
pressures: Some(vec![0.3, 0.5, 0.4]),
},
InkStroke {
points: vec![(60.0, 20.0), (70.0, 25.0)],
pressures: None,
},
];
let visual = SignatureVisualType::InkSignature {
strokes: strokes,
color: Color::rgb(0.0, 0.0, 0.5),
width: 1.5,
};
match visual {
SignatureVisualType::InkSignature {
strokes: s,
color: _,
width,
} => {
assert_eq!(s.len(), 3);
assert_eq!(width, 1.5);
assert_eq!(s[1].points.len(), 3);
assert!(s[1].pressures.is_some());
}
_ => panic!("Expected InkSignature"),
}
}
}