use crate::error::{DocError, DocResult};
use crate::form_field::{FormField, FormFieldType};
pub fn generate_text_appearance(
value: &str,
font_size: f32,
_rect_width: f32,
_rect_height: f32,
) -> Vec<u8> {
let escaped = escape_pdf_string(value);
format!("BT\n/Helv {font_size:.1} Tf\n2 2 Td\n({escaped}) Tj\nET\n").into_bytes()
}
pub fn generate_checkbox_appearance(checked: bool, size: f32) -> Vec<u8> {
if !checked {
return Vec::new();
}
let scale = size / 12.0;
let mut buf = Vec::new();
buf.extend_from_slice(
format!(
"q\n{scale:.4} 0 0 {scale:.4} 0 0 cm\n\
1.5 4.5 m\n5 9 l\n10.5 1.5 l\nS\nQ\n"
)
.as_bytes(),
);
buf
}
pub fn generate_for_field(field: &FormField) -> DocResult<Vec<u8>> {
match field.field_type {
FormFieldType::Text => {
let value = field.value.as_deref().unwrap_or("");
Ok(generate_text_appearance(value, 12.0, 200.0, 20.0))
}
FormFieldType::Button => {
let checked = field
.appearance_state
.as_deref()
.is_some_and(|s| s != "Off");
Ok(generate_checkbox_appearance(checked, 12.0))
}
FormFieldType::Choice => {
let value = field.value.as_deref().unwrap_or("");
Ok(generate_text_appearance(value, 12.0, 200.0, 20.0))
}
FormFieldType::Signature => Err(DocError::TypeMismatch {
expected: "Text, Button, or Choice".to_string(),
got: "Signature".to_string(),
}),
}
}
#[derive(Debug, Clone)]
pub struct ParsedDefaultAppearance {
pub font_name: Option<String>,
pub font_size: f64,
pub color: Option<Vec<f64>>,
}
pub fn parse_default_appearance(da: &str) -> ParsedDefaultAppearance {
let mut font_name = None;
let mut font_size = 0.0;
let mut color = None;
let mut num_stack: Vec<f64> = Vec::new();
let tokens: Vec<&str> = da.split_whitespace().collect();
for token in &tokens {
if let Ok(n) = token.parse::<f64>() {
num_stack.push(n);
} else if let Some(stripped) = token.strip_prefix('/') {
num_stack.clear();
font_name = Some(stripped.to_string());
} else {
match *token {
"Tf" => {
if let Some(size) = num_stack.pop() {
font_size = size;
}
num_stack.clear();
}
"g" => {
if !num_stack.is_empty() {
color = Some(std::mem::take(&mut num_stack));
}
}
"rg" => {
if num_stack.len() >= 3 {
color = Some(std::mem::take(&mut num_stack));
}
}
"k" => {
if num_stack.len() >= 4 {
color = Some(std::mem::take(&mut num_stack));
}
}
_ => {
num_stack.clear();
}
}
}
}
ParsedDefaultAppearance {
font_name,
font_size,
color,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextAlignment {
Left,
Center,
Right,
}
pub fn generate_text_appearance_rich(
da: &ParsedDefaultAppearance,
value: &str,
rect_width: f32,
rect_height: f32,
alignment: TextAlignment,
) -> Vec<u8> {
let font_name = da.font_name.as_deref().unwrap_or("Helv");
let font_size = if da.font_size > 0.0 {
da.font_size as f32
} else {
12.0
};
let char_width = font_size * 0.5;
let margin = 2.0;
let usable_width = (rect_width - 2.0 * margin).max(0.0);
let leading = font_size * 1.2;
let lines = word_wrap(value, usable_width, char_width);
let mut buf = String::new();
buf.push_str("BT\n");
buf.push_str(&format!("/{font_name} {font_size:.1} Tf\n"));
if let Some(ref c) = da.color {
match c.len() {
1 => buf.push_str(&format!("{:.3} g\n", c[0])),
3 => buf.push_str(&format!("{:.3} {:.3} {:.3} rg\n", c[0], c[1], c[2])),
4 => buf.push_str(&format!(
"{:.3} {:.3} {:.3} {:.3} k\n",
c[0], c[1], c[2], c[3]
)),
_ => {}
}
}
let total_text_height = lines.len() as f32 * leading;
let start_y = if total_text_height < rect_height - 2.0 * margin {
rect_height - margin - (rect_height - total_text_height) / 2.0 - font_size
} else {
rect_height - margin - font_size
};
for (i, line) in lines.iter().enumerate() {
let escaped = escape_pdf_string(line);
let line_width = line.len() as f32 * char_width;
let x = match alignment {
TextAlignment::Left => margin,
TextAlignment::Center => margin + (usable_width - line_width).max(0.0) / 2.0,
TextAlignment::Right => margin + (usable_width - line_width).max(0.0),
};
let y = start_y - (i as f32 * leading);
buf.push_str(&format!("{x:.1} {y:.1} Td\n"));
buf.push_str(&format!("({escaped}) Tj\n"));
}
buf.push_str("ET\n");
buf.into_bytes()
}
fn word_wrap(text: &str, max_width: f32, char_width: f32) -> Vec<String> {
if text.is_empty() {
return vec![String::new()];
}
let max_chars = if char_width > 0.0 {
(max_width / char_width).floor() as usize
} else {
usize::MAX
};
if max_chars == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_len: usize = 0;
for word in text.split(' ') {
if current_line.is_empty() {
current_line.push_str(word);
current_len = word.len();
} else if current_len + 1 + word.len() <= max_chars {
current_line.push(' ');
current_line.push_str(word);
current_len += 1 + word.len();
} else {
lines.push(current_line);
current_line = word.to_string();
current_len = word.len();
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn escape_pdf_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'(' => out.push_str("\\("),
')' => out.push_str("\\)"),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::form_field::{ChoiceOption, FieldValue, FormFieldFlags};
fn make_text_field(name: &str, value: Option<&str>, max_len: Option<u32>) -> FormField {
FormField {
name: name.to_string(),
field_type: FormFieldType::Text,
value: value.map(|s| s.to_string()),
default_value: None,
flags: FormFieldFlags::from_bits(0),
tooltip: None,
alternate_name: None,
mapping_name: None,
max_len,
options: Vec::new(),
appearance_state: None,
children: Vec::new(),
controls: Vec::new(),
dirty: false,
selected_indices: Vec::new(),
additional_actions: None,
}
}
fn make_button_field(name: &str, state: Option<&str>) -> FormField {
FormField {
name: name.to_string(),
field_type: FormFieldType::Button,
value: None,
default_value: None,
flags: FormFieldFlags::from_bits(0),
tooltip: None,
alternate_name: None,
mapping_name: None,
max_len: None,
options: Vec::new(),
appearance_state: state.map(|s| s.to_string()),
children: Vec::new(),
controls: Vec::new(),
dirty: false,
selected_indices: Vec::new(),
additional_actions: None,
}
}
#[test]
fn test_text_appearance_contains_value() {
let bytes = generate_text_appearance("Hello World", 12.0, 200.0, 20.0);
let content = String::from_utf8(bytes).unwrap();
assert!(content.contains("BT"));
assert!(content.contains("/Helv 12.0 Tf"));
assert!(content.contains("(Hello World) Tj"));
assert!(content.contains("ET"));
}
#[test]
fn test_text_appearance_escapes_parens() {
let bytes = generate_text_appearance("a(b)c\\d", 10.0, 100.0, 20.0);
let content = String::from_utf8(bytes).unwrap();
assert!(content.contains("(a\\(b\\)c\\\\d) Tj"));
}
#[test]
fn test_checkbox_checked_produces_path() {
let bytes = generate_checkbox_appearance(true, 12.0);
let content = String::from_utf8(bytes).unwrap();
assert!(content.contains("m\n"));
assert!(content.contains("l\n"));
assert!(content.contains("S\n"));
}
#[test]
fn test_checkbox_unchecked_is_empty() {
let bytes = generate_checkbox_appearance(false, 12.0);
assert!(bytes.is_empty());
}
#[test]
fn test_generate_for_text_field() {
let field = make_text_field("name", Some("Alice"), None);
let bytes = generate_for_field(&field).unwrap();
let content = String::from_utf8(bytes).unwrap();
assert!(content.contains("(Alice) Tj"));
}
#[test]
fn test_generate_for_checked_button() {
let field = make_button_field("agree", Some("Yes"));
let bytes = generate_for_field(&field).unwrap();
assert!(!bytes.is_empty());
}
#[test]
fn test_generate_for_unchecked_button() {
let field = make_button_field("agree", Some("Off"));
let bytes = generate_for_field(&field).unwrap();
assert!(bytes.is_empty());
}
#[test]
fn test_generate_for_choice_field() {
let mut field = FormField {
name: "color".to_string(),
field_type: FormFieldType::Choice,
value: None,
default_value: None,
flags: FormFieldFlags::from_bits(0),
tooltip: None,
alternate_name: None,
mapping_name: None,
max_len: None,
options: vec![
ChoiceOption {
export_value: "R".to_string(),
display_value: "Red".to_string(),
},
ChoiceOption {
export_value: "G".to_string(),
display_value: "Green".to_string(),
},
],
appearance_state: None,
children: Vec::new(),
controls: Vec::new(),
dirty: false,
selected_indices: Vec::new(),
additional_actions: None,
};
field.set_value(FieldValue::Choice(1)).unwrap();
let bytes = generate_for_field(&field).unwrap();
let content = String::from_utf8(bytes).unwrap();
assert!(content.contains("(G) Tj"));
}
#[test]
fn test_generate_for_signature_errors() {
let field = FormField {
name: "sig".to_string(),
field_type: FormFieldType::Signature,
value: None,
default_value: None,
flags: FormFieldFlags::from_bits(0),
tooltip: None,
alternate_name: None,
mapping_name: None,
max_len: None,
options: Vec::new(),
appearance_state: None,
children: Vec::new(),
controls: Vec::new(),
dirty: false,
selected_indices: Vec::new(),
additional_actions: None,
};
assert!(generate_for_field(&field).is_err());
}
#[test]
fn test_parse_da_basic() {
let da = parse_default_appearance("/Helv 12 Tf 0 0 0 rg");
assert_eq!(da.font_name.as_deref(), Some("Helv"));
assert_eq!(da.font_size, 12.0);
assert_eq!(da.color, Some(vec![0.0, 0.0, 0.0]));
}
#[test]
fn test_parse_da_gray_color() {
let da = parse_default_appearance("/Cour 10 Tf 0.5 g");
assert_eq!(da.font_name.as_deref(), Some("Cour"));
assert_eq!(da.font_size, 10.0);
assert_eq!(da.color, Some(vec![0.5]));
}
#[test]
fn test_parse_da_cmyk_color() {
let da = parse_default_appearance("/Helv 8 Tf 0 0 0 1 k");
assert_eq!(da.font_size, 8.0);
assert_eq!(da.color, Some(vec![0.0, 0.0, 0.0, 1.0]));
}
#[test]
fn test_parse_da_no_color() {
let da = parse_default_appearance("/Helv 14 Tf");
assert_eq!(da.font_name.as_deref(), Some("Helv"));
assert_eq!(da.font_size, 14.0);
assert!(da.color.is_none());
}
#[test]
fn test_parse_da_empty() {
let da = parse_default_appearance("");
assert!(da.font_name.is_none());
assert_eq!(da.font_size, 0.0);
assert!(da.color.is_none());
}
#[test]
fn test_word_wrap_single_line() {
let lines = word_wrap("Hello World", 100.0, 6.0);
assert_eq!(lines, vec!["Hello World"]);
}
#[test]
fn test_word_wrap_multiple_lines() {
let lines = word_wrap("Hello World Test", 30.0, 6.0);
assert_eq!(lines, vec!["Hello", "World", "Test"]);
}
#[test]
fn test_word_wrap_empty_string() {
let lines = word_wrap("", 100.0, 6.0);
assert_eq!(lines, vec![""]);
}
#[test]
fn test_generate_rich_left_aligned() {
let da = ParsedDefaultAppearance {
font_name: Some("Helv".to_string()),
font_size: 12.0,
color: Some(vec![0.0, 0.0, 0.0]),
};
let bytes = generate_text_appearance_rich(&da, "Test", 200.0, 20.0, TextAlignment::Left);
let content = String::from_utf8(bytes).unwrap();
assert!(content.contains("BT"));
assert!(content.contains("/Helv 12.0 Tf"));
assert!(content.contains("(Test) Tj"));
assert!(content.contains("ET"));
}
#[test]
fn test_generate_rich_center_aligned() {
let da = ParsedDefaultAppearance {
font_name: Some("Helv".to_string()),
font_size: 12.0,
color: None,
};
let bytes = generate_text_appearance_rich(&da, "Hi", 200.0, 20.0, TextAlignment::Center);
let content = String::from_utf8(bytes).unwrap();
assert!(content.contains("(Hi) Tj"));
}
#[test]
#[ignore = "FindTagParamFromStart not yet implemented"]
fn test_cpdf_default_appearance_find_tag_param_from_start() {
todo!("Port FindTagParamFromStart and its test data");
}
#[test]
fn test_generate_rich_uses_default_font() {
let da = ParsedDefaultAppearance {
font_name: None,
font_size: 0.0,
color: None,
};
let bytes = generate_text_appearance_rich(&da, "Text", 200.0, 20.0, TextAlignment::Left);
let content = String::from_utf8(bytes).unwrap();
assert!(content.contains("/Helv 12.0 Tf"));
}
}