#![cfg(feature = "signatures")]
use super::signer::PdfSigner;
use super::types::{SignOptions, SigningCredentials};
use crate::error::{Error, Result};
use crate::object::encode_pdf_text_string;
const BR_FIELD_W: usize = 10;
const BR_PLACEHOLDER: &str = "0000000000 0000000000 0000000000 0000000000";
pub fn sign_pdf_bytes(
pdf_data: &[u8],
credentials: &SigningCredentials,
opts: SignOptions,
) -> Result<Vec<u8>> {
let signer = PdfSigner::new(credentials.clone(), opts);
sign_pdf_bytes_with(pdf_data, signer, &|s, sb| s.sign(sb))
}
fn sign_pdf_bytes_with(
pdf_data: &[u8],
signer: PdfSigner,
cms_fn: &dyn Fn(&PdfSigner, &[u8]) -> Result<Vec<u8>>,
) -> Result<Vec<u8>> {
sign_pdf_bytes_with_cms(pdf_data, signer, cms_fn).map(|(out, _cms)| out)
}
fn sign_pdf_bytes_with_cms(
pdf_data: &[u8],
signer: PdfSigner,
cms_fn: &dyn Fn(&PdfSigner, &[u8]) -> Result<Vec<u8>>,
) -> Result<(Vec<u8>, Vec<u8>)> {
let prev_startxref = scan_startxref(pdf_data)
.ok_or_else(|| Error::InvalidPdf("cannot find startxref in existing PDF".into()))?;
let root_ref = scan_root_ref(pdf_data)
.ok_or_else(|| Error::InvalidPdf("cannot find /Root ref in existing PDF".into()))?;
let next_obj_num = scan_next_obj_num(pdf_data).ok_or_else(|| {
Error::InvalidPdf("cannot determine next object number from PDF trailer".into())
})?;
let sig_dict_text = build_sig_dict_text(&signer, next_obj_num);
let contents_in_dict = find_contents_offset_in_text(sig_dict_text.as_bytes())
.ok_or_else(|| Error::InvalidPdf("cannot find /Contents in built sig dict".into()))?;
let sig_dict_start = pdf_data.len(); let xref_start = sig_dict_start + sig_dict_text.len();
let xref_entry = format!("{:010} 00000 n \r\n", sig_dict_start);
let xref_section = format!("xref\n{} 1\n{}", next_obj_num, xref_entry);
let trailer_section = format!(
"trailer\n<< /Size {} /Prev {} /Root {} >>\n",
next_obj_num + 1,
prev_startxref,
root_ref,
);
let startxref_section = format!("startxref\n{}\n%%EOF\n", xref_start);
let total_len = sig_dict_start
+ sig_dict_text.len()
+ xref_section.len()
+ trailer_section.len()
+ startxref_section.len();
let contents_abs = sig_dict_start + contents_in_dict; let contents_size = signer.placeholder_size(); let after_contents = contents_abs + contents_size;
let byte_range: [i64; 4] = [
0,
contents_abs as i64,
after_contents as i64,
(total_len - after_contents) as i64,
];
let patched_sig_dict = patch_byterange(sig_dict_text, &byte_range);
let mut output = Vec::with_capacity(total_len);
output.extend_from_slice(pdf_data);
output.extend_from_slice(patched_sig_dict.as_bytes());
output.extend_from_slice(xref_section.as_bytes());
output.extend_from_slice(trailer_section.as_bytes());
output.extend_from_slice(startxref_section.as_bytes());
debug_assert_eq!(
output.len(),
total_len,
"assembled output length must match pre-computed total_len"
);
let signed_bytes =
super::byterange::ByteRangeCalculator::extract_signed_bytes(&output, &byte_range)?;
let cms_blob = cms_fn(&signer, &signed_bytes)?;
signer.insert_signature(&mut output, contents_abs, &cms_blob)?;
let contents_len = (signer.placeholder_size() - 2) / 2;
let mut contents_string = cms_blob;
contents_string.resize(contents_len, 0);
Ok((output, contents_string))
}
#[allow(clippy::too_many_arguments)]
pub fn sign_pdf_bytes_pades(
pdf_data: &[u8],
credentials: &SigningCredentials,
opts: SignOptions,
level: crate::signatures::PadesLevel,
timestamper: Option<&dyn Fn(&[u8]) -> Result<Vec<u8>>>,
material: &crate::signatures::RevocationMaterial,
) -> Result<Vec<u8>> {
use crate::signatures::PadesLevel;
if matches!(level, PadesLevel::BT | PadesLevel::BLt | PadesLevel::BLta) && timestamper.is_none()
{
return Err(Error::Unsupported(
"PAdES-B-T/B-LT/B-LTA require a timestamper (RFC 3161 token source)".into(),
));
}
let dts_size = opts.estimated_size.max(8192);
let signer = PdfSigner::new(credentials.clone(), opts);
let (signed, contents_string) = match level {
PadesLevel::BB => sign_pdf_bytes_with_cms(pdf_data, signer, &|s, sb| s.sign_pades(sb)),
PadesLevel::BT | PadesLevel::BLt | PadesLevel::BLta => {
let ts = timestamper.expect("checked above");
sign_pdf_bytes_with_cms(pdf_data, signer, &|s, sb| s.sign_pades_t(sb, ts))
},
}?;
if level == PadesLevel::BB || level == PadesLevel::BT {
return Ok(signed);
}
let doc = crate::document::PdfDocument::from_bytes(signed.clone())?;
let keys: Vec<String> = crate::signatures::pades::vri_key(&contents_string)
.into_iter()
.collect();
let blt = crate::signatures::pades::append_dss(&signed, &doc, material, &keys)?;
if level == PadesLevel::BLt {
return Ok(blt);
}
let ts = timestamper.expect("checked above");
append_doc_timestamp(&blt, ts, dts_size)
}
fn build_sig_dict_text(signer: &PdfSigner, obj_num: u64) -> String {
let opts = signer.options();
let contents_placeholder = signer.generate_contents_placeholder();
let mut dict = format!(
"{} 0 obj\n<< /Type /Sig\n/Filter /Adobe.PPKLite\n/SubFilter /{}\n",
obj_num,
opts.sub_filter.as_pdf_name(),
);
dict.push_str(&format!("/ByteRange [{}]\n", BR_PLACEHOLDER));
dict.push_str(&format!("/Contents {}\n", contents_placeholder));
if let Some(ref r) = opts.reason {
dict.push_str(&format!("/Reason {}\n", pdf_text_hex(r)));
}
if let Some(ref l) = opts.location {
dict.push_str(&format!("/Location {}\n", pdf_text_hex(l)));
}
if let Some(ref n) = opts.name {
dict.push_str(&format!("/Name {}\n", pdf_text_hex(n)));
}
if let Some(ref c) = opts.contact_info {
dict.push_str(&format!("/ContactInfo {}\n", pdf_text_hex(c)));
}
dict.push_str(&format!("/M ({})\n", format_pdf_date()));
dict.push_str(">>\nendobj\n");
dict
}
fn build_doctimestamp_dict_text(obj_num: u64, contents_placeholder: &str) -> String {
let mut dict = format!(
"{} 0 obj\n<< /Type /DocTimeStamp\n/Filter /Adobe.PPKLite\n/SubFilter /ETSI.RFC3161\n",
obj_num,
);
dict.push_str(&format!("/ByteRange [{}]\n", BR_PLACEHOLDER));
dict.push_str(&format!("/Contents {}\n", contents_placeholder));
dict.push_str(&format!("/M ({})\n", format_pdf_date()));
dict.push_str(">>\nendobj\n");
dict
}
fn append_doc_timestamp(
pdf_data: &[u8],
timestamper: &dyn Fn(&[u8]) -> Result<Vec<u8>>,
est_size: usize,
) -> Result<Vec<u8>> {
let prev_startxref = scan_startxref(pdf_data)
.ok_or_else(|| Error::InvalidPdf("B-LTA: cannot find startxref".into()))?;
let root_ref = scan_root_ref(pdf_data)
.ok_or_else(|| Error::InvalidPdf("B-LTA: cannot find /Root ref".into()))?;
let next_obj_num = scan_next_obj_num(pdf_data)
.ok_or_else(|| Error::InvalidPdf("B-LTA: cannot determine next object number".into()))?;
let calc = super::byterange::ByteRangeCalculator::with_placeholder_size(est_size * 2 + 2);
let placeholder = calc.generate_placeholder();
let dict_text = build_doctimestamp_dict_text(next_obj_num, &placeholder);
let contents_in_dict = find_contents_offset_in_text(dict_text.as_bytes())
.ok_or_else(|| Error::InvalidPdf("B-LTA: cannot find /Contents in DocTimeStamp".into()))?;
let sig_dict_start = pdf_data.len();
let xref_start = sig_dict_start + dict_text.len();
let xref_entry = format!("{:010} 00000 n \r\n", sig_dict_start);
let xref_section = format!("xref\n{} 1\n{}", next_obj_num, xref_entry);
let trailer_section = format!(
"trailer\n<< /Size {} /Prev {} /Root {} >>\n",
next_obj_num + 1,
prev_startxref,
root_ref,
);
let startxref_section = format!("startxref\n{}\n%%EOF\n", xref_start);
let total_len = sig_dict_start
+ dict_text.len()
+ xref_section.len()
+ trailer_section.len()
+ startxref_section.len();
let contents_abs = sig_dict_start + contents_in_dict;
let contents_size = calc.placeholder_size();
let after_contents = contents_abs + contents_size;
let byte_range: [i64; 4] = [
0,
contents_abs as i64,
after_contents as i64,
(total_len - after_contents) as i64,
];
let patched = patch_byterange(dict_text, &byte_range);
let mut output = Vec::with_capacity(total_len);
output.extend_from_slice(pdf_data);
output.extend_from_slice(patched.as_bytes());
output.extend_from_slice(xref_section.as_bytes());
output.extend_from_slice(trailer_section.as_bytes());
output.extend_from_slice(startxref_section.as_bytes());
debug_assert_eq!(output.len(), total_len, "B-LTA assembled length mismatch");
let signed_bytes =
super::byterange::ByteRangeCalculator::extract_signed_bytes(&output, &byte_range)?;
let token = timestamper(&signed_bytes)?;
let token_hex: String = token.iter().map(|b| format!("{b:02X}")).collect();
calc.insert_signature(&mut output, contents_abs, &token_hex)?;
Ok(output)
}
fn patch_byterange(mut text: String, br: &[i64; 4]) -> String {
let replacement = format!(
"{:>BR_FIELD_W$} {:>BR_FIELD_W$} {:>BR_FIELD_W$} {:>BR_FIELD_W$}",
br[0], br[1], br[2], br[3],
);
assert_eq!(
replacement.len(),
BR_PLACEHOLDER.len(),
"replacement must have the same length as the placeholder"
);
if let Some(pos) = text.find(BR_PLACEHOLDER) {
text.replace_range(pos..pos + BR_PLACEHOLDER.len(), &replacement);
}
text
}
fn find_contents_offset_in_text(data: &[u8]) -> Option<usize> {
let pattern = b"/Contents ";
let pos = data.windows(pattern.len()).position(|w| w == pattern)?;
let after = pos + pattern.len();
for (i, &b) in data[after..].iter().enumerate() {
if b == b'<' {
return Some(after + i);
}
if b != b' ' && b != b'\t' && b != b'\r' && b != b'\n' {
break;
}
}
None
}
fn scan_startxref(data: &[u8]) -> Option<u64> {
let window = &data[data.len().saturating_sub(4096)..];
let pos = window.windows(9).rposition(|w| w == b"startxref")?;
let after = &window[pos + 9..];
let s = std::str::from_utf8(after).ok()?;
let trimmed = s.trim_start_matches([' ', '\r', '\n']);
let end = trimmed
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(trimmed.len());
trimmed[..end].parse().ok()
}
fn scan_root_ref(data: &[u8]) -> Option<String> {
let window = &data[data.len().saturating_sub(4096)..];
let pattern = b"/Root ";
let pos = window.windows(pattern.len()).rposition(|w| w == pattern)?;
let after = &window[pos + pattern.len()..];
let end = after
.iter()
.position(|&b| b == b'/' || b == b'>' || b == b'\n')
.unwrap_or(after.len().min(40));
let s = std::str::from_utf8(&after[..end]).ok()?.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
fn scan_next_obj_num(data: &[u8]) -> Option<u64> {
let window = &data[data.len().saturating_sub(4096)..];
let pattern = b"/Size ";
let pos = window.windows(pattern.len()).rposition(|w| w == pattern)?;
let after = &window[pos + pattern.len()..];
let s = std::str::from_utf8(after).ok()?;
let trimmed = s.trim_start_matches(' ');
let end = trimmed
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(trimmed.len());
trimmed[..end].parse().ok()
}
fn pdf_text_hex(s: &str) -> String {
let bytes = encode_pdf_text_string(s);
let mut out = String::with_capacity(bytes.len() * 2 + 2);
out.push('<');
for b in &bytes {
out.push_str(&format!("{:02X}", b));
}
out.push('>');
out
}
fn format_pdf_date() -> String {
super::pdf_date::format_pdf_date_utc()
}
#[cfg(test)]
fn der_sequence_len_from_hex(hex: &str) -> usize {
let lb = u8::from_str_radix(&hex[2..4], 16).expect("DER len byte");
if lb < 0x80 {
(lb as usize) + 2
} else {
let n = (lb & 0x7f) as usize;
let mut len = 0usize;
for i in 0..n {
let b = u8::from_str_radix(&hex[(4 + i * 2)..(6 + i * 2)], 16).expect("DER len");
len = (len << 8) | (b as usize);
}
len + 2 + n
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signatures::cms_verify::SignerVerify;
use crate::signatures::verify_signer_detached;
use crate::signatures::ByteRangeCalculator;
fn load_test_creds() -> SigningCredentials {
let cert =
std::fs::read_to_string("tests/fixtures/test_signing_cert.pem").expect("cert fixture");
let key =
std::fs::read_to_string("tests/fixtures/test_signing_key.pem").expect("key fixture");
SigningCredentials::from_pem(&cert, &key).expect("creds load")
}
fn minimal_pdf() -> Vec<u8> {
b"%PDF-1.4\n\
1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n\
3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n\
xref\n0 4\n0000000000 65535 f \r\n0000000009 00000 n \r\n\
0000000058 00000 n \r\n0000000115 00000 n \r\n\
trailer\n<< /Size 4 /Root 1 0 R >>\n\
startxref\n187\n%%EOF\n"
.to_vec()
}
#[test]
fn test_scan_startxref() {
let pdf = minimal_pdf();
let xref = scan_startxref(&pdf).expect("must find startxref");
assert!(xref > 0, "startxref must be positive");
}
#[test]
fn test_scan_root_ref() {
let pdf = minimal_pdf();
let root = scan_root_ref(&pdf).expect("must find root");
assert!(root.contains("1 0 R"), "root ref must be '1 0 R': got {root}");
}
#[test]
fn test_scan_next_obj_num() {
let pdf = minimal_pdf();
let n = scan_next_obj_num(&pdf).expect("must find /Size");
assert_eq!(n, 4, "/Size must be 4 for this minimal PDF");
}
#[test]
fn test_patch_byterange_same_length() {
let text = format!("pre {} post", BR_PLACEHOLDER);
let original_len = text.len();
let br = [0i64, 12345, 99999, 200];
let patched = patch_byterange(text, &br);
assert_eq!(patched.len(), original_len, "patch must not change text length");
assert!(!patched.contains(BR_PLACEHOLDER), "placeholder must be replaced");
}
fn hex_decode(s: &str) -> Vec<u8> {
s.as_bytes()
.chunks(2)
.map(|c| u8::from_str_radix(std::str::from_utf8(c).unwrap(), 16).unwrap())
.collect()
}
#[test]
fn test_sign_pdf_bytes_roundtrip() {
let pdf = minimal_pdf();
let creds = load_test_creds();
let opts = SignOptions {
estimated_size: 4096,
..Default::default()
};
let signed = sign_pdf_bytes(&pdf, &creds, opts).expect("sign_pdf_bytes must succeed");
let tail = &signed[pdf.len()..];
let tail_str = std::str::from_utf8(tail).unwrap();
let br_pos = tail_str
.find("/ByteRange [")
.expect("/ByteRange must exist");
let after_br = &tail_str[br_pos + 12..];
let end = after_br.find(']').expect("] must follow /ByteRange");
let nums: Vec<i64> = after_br[..end]
.split_whitespace()
.map(|s| s.parse().unwrap())
.collect();
assert_eq!(nums.len(), 4);
let byte_range: [i64; 4] = [nums[0], nums[1], nums[2], nums[3]];
assert_eq!(byte_range[0], 0);
assert!(byte_range[1] > 0);
assert!(byte_range[2] > byte_range[1]);
assert!(byte_range[3] > 0);
assert_eq!(
byte_range[2] + byte_range[3],
signed.len() as i64,
"ByteRange must cover the whole file"
);
let ct_pos = tail_str.find("/Contents <").expect("/Contents must exist");
let after_ct = &tail_str[ct_pos + 11..]; let close = after_ct.find('>').expect("> must follow /Contents <");
let hex_str = &after_ct[..close];
let cms_len = der_sequence_len_from_hex(hex_str);
let cms_blob = hex_decode(&hex_str[..cms_len * 2]);
let signed_content = ByteRangeCalculator::extract_signed_bytes(&signed, &byte_range)
.expect("extract_signed_bytes must succeed");
let result =
verify_signer_detached(&cms_blob, &signed_content).expect("verify must not error");
assert_eq!(result, SignerVerify::Valid, "end-to-end PDF signature must verify as Valid");
}
fn verify_appended_signature(
orig_len: usize,
signed: &[u8],
) -> (SignerVerify, Vec<u8>, Vec<u8>) {
let tail = &signed[orig_len..];
let br = tail
.windows(12)
.position(|w| w == b"/ByteRange [")
.expect("/ByteRange");
let after = &tail[br + 12..];
let end = after.iter().position(|&b| b == b']').unwrap();
let n: Vec<i64> = std::str::from_utf8(&after[..end])
.unwrap()
.split_whitespace()
.map(|s| s.parse().unwrap())
.collect();
let byte_range = [n[0], n[1], n[2], n[3]];
let ct = tail
.windows(11)
.position(|w| w == b"/Contents <")
.expect("/Contents");
let after_ct = &tail[ct + 11..];
let close = after_ct.iter().position(|&b| b == b'>').unwrap();
let hex_str = std::str::from_utf8(&after_ct[..close]).unwrap();
let cms_len = der_sequence_len_from_hex(hex_str);
let cms = hex_decode(&hex_str[..cms_len * 2]);
let contents_string = hex_decode(hex_str);
let content = ByteRangeCalculator::extract_signed_bytes(signed, &byte_range).unwrap();
let v = verify_signer_detached(&cms, &content).expect("verify must not error");
(v, cms, contents_string)
}
#[test]
#[cfg(feature = "signatures")]
fn test_sign_pdf_bytes_pades_levels() {
use crate::signatures::pades::vri_key;
use crate::signatures::{
classify_pades_level, read_dss, sign_pdf_bytes_pades, PadesLevel, RevocationMaterial,
SignatureInfo,
};
let info_with = |contents: Vec<u8>| SignatureInfo {
contents: Some(contents),
..Default::default()
};
let pdf = minimal_pdf();
let creds = load_test_creds();
let mk_opts = || SignOptions {
estimated_size: 4096,
..Default::default()
};
let ts: &dyn Fn(&[u8]) -> Result<Vec<u8>> =
&|_sig| Ok(vec![0x30, 0x07, 0x02, 0x01, 0x01, 0x04, 0x02, b't', b's']);
let bb = sign_pdf_bytes_pades(
&pdf,
&creds,
mk_opts(),
PadesLevel::BB,
None,
&RevocationMaterial::default(),
)
.expect("B-B sign");
let (v_bb, cms_bb, contents_bb) = verify_appended_signature(pdf.len(), &bb);
assert_eq!(v_bb, SignerVerify::Valid);
const ESS_OID: &[u8] = &[
0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x09, 0x10, 0x02, 0x2F,
];
assert!(
cms_bb.windows(ESS_OID.len()).any(|w| w == ESS_OID),
"B-B CMS carries the ESS signing-certificate-v2 attribute"
);
assert_eq!(classify_pades_level(&info_with(contents_bb), None), PadesLevel::BB);
assert!(matches!(
sign_pdf_bytes_pades(
&pdf,
&creds,
mk_opts(),
PadesLevel::BT,
None,
&RevocationMaterial::default()
),
Err(Error::Unsupported(_))
));
let bt = sign_pdf_bytes_pades(
&pdf,
&creds,
mk_opts(),
PadesLevel::BT,
Some(ts),
&RevocationMaterial::default(),
)
.expect("B-T sign");
let (v_bt, cms_bt, contents_bt) = verify_appended_signature(pdf.len(), &bt);
assert_eq!(v_bt, SignerVerify::Valid);
const TS_OID: &[u8] = &[
0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x09, 0x10, 0x02, 0x0E,
];
assert!(
cms_bt.windows(TS_OID.len()).any(|w| w == TS_OID),
"B-T CMS carries the signature-time-stamp unsigned attribute"
);
assert_eq!(classify_pades_level(&info_with(contents_bt), None), PadesLevel::BT);
let material = RevocationMaterial {
certificates: vec![creds.certificate.clone()],
..RevocationMaterial::default()
};
let blt =
sign_pdf_bytes_pades(&pdf, &creds, mk_opts(), PadesLevel::BLt, Some(ts), &material)
.expect("B-LT sign");
assert!(blt.len() > bt.len());
let (v_blt, _cms_blt, contents_blt) = verify_appended_signature(pdf.len(), &blt);
assert_eq!(
v_blt,
SignerVerify::Valid,
"I1: the B-T signature still verifies in the full B-LT file"
);
let doc_blt = crate::document::PdfDocument::from_bytes(blt).unwrap();
let dss = read_dss(&doc_blt)
.expect("read_dss ok")
.expect("DSS present after B-LT");
assert_eq!(dss.certificates, vec![creds.certificate.clone()]);
let key = vri_key(&contents_blt).expect("provider supports SHA-1");
assert!(
dss.vri_for(&key).is_some(),
"DSS /VRI is keyed by SHA-1(/Contents) — write/read parity"
);
assert_eq!(
classify_pades_level(&info_with(contents_blt), Some(&dss)),
PadesLevel::BLt,
"signature classifies as B-LT with the DSS+VRI present"
);
let blta =
sign_pdf_bytes_pades(&pdf, &creds, mk_opts(), PadesLevel::BLta, Some(ts), &material)
.expect("B-LTA sign");
let (v_blta, _, _) = verify_appended_signature(pdf.len(), &blta);
assert_eq!(v_blta, SignerVerify::Valid, "B-LTA: original sig still valid");
assert!(
crate::signatures::has_document_timestamp(&blta),
"B-LTA carries a /DocTimeStamp ETSI.RFC3161 object"
);
let dts_pos = blta
.windows(13)
.position(|w| w == b"/DocTimeStamp")
.expect("/DocTimeStamp present");
let dss_pos = blta
.windows(4)
.position(|w| w == b"/DSS")
.expect("/DSS present");
assert!(dss_pos < dts_pos, "DocTimeStamp is appended after the DSS");
assert!(matches!(
sign_pdf_bytes_pades(&pdf, &creds, mk_opts(), PadesLevel::BLta, None, &material),
Err(Error::Unsupported(_))
));
}
#[test]
fn test_scan_root_ref_ignores_body_occurrence() {
let mut pdf = minimal_pdf();
let filler = b"% /Root 99 0 R this is inside a comment not a trailer\n";
let padding = filler.repeat(100); let mut data = padding;
data.extend_from_slice(&pdf);
let root = scan_root_ref(&data).expect("must find root in last 4 KB");
assert!(
root.contains("1 0 R"),
"must return trailer /Root, not body occurrence; got: {root}"
);
let first = data.windows(b"/Root ".len()).position(|w| w == b"/Root ");
assert!(first.unwrap() < data.len() - 4096, "misleading /Root is before the 4 KB window");
let _ = pdf.drain(..);
}
#[test]
fn test_pdf_text_hex_ascii_roundtrip() {
let h = pdf_text_hex("Hello");
assert!(h.starts_with('<') && h.ends_with('>'));
let bytes = hex_decode(&h[1..h.len() - 1]);
assert_eq!(bytes, b"Hello");
}
#[test]
fn test_pdf_text_hex_latin1_no_bom() {
let h = pdf_text_hex("é");
let bytes = hex_decode(&h[1..h.len() - 1]);
assert_eq!(bytes, &[0xE9], "PDFDocEncoding for é must be 0xE9, not multi-byte UTF-8");
}
#[test]
fn test_pdf_text_hex_portuguese_reason() {
let h = pdf_text_hex("Aprovado Lógico");
let bytes = hex_decode(&h[1..h.len() - 1]);
assert!(
!bytes.windows(2).any(|w| w == [0xC3, 0xB3]),
"raw UTF-8 bytes for ó must not appear; got {:X?}",
bytes
);
assert!(bytes.contains(&0xF3), "PDFDocEncoding 0xF3 for ó must be present");
}
#[test]
fn test_pdf_text_hex_cjk_uses_utf16be_bom() {
let h = pdf_text_hex("中文");
let bytes = hex_decode(&h[1..h.len() - 1]);
assert_eq!(&bytes[..2], &[0xFE, 0xFF], "UTF-16BE BOM must be present for CJK");
}
#[test]
fn test_sign_metadata_non_ascii_encoded_in_sig_dict() {
let pdf = minimal_pdf();
let creds = load_test_creds();
let opts = SignOptions {
reason: Some("Aprovado Lógico".to_string()), location: Some("São Paulo".to_string()), name: Some("中文签名人".to_string()),
estimated_size: 8192,
..Default::default()
};
let signed = sign_pdf_bytes(&pdf, &creds, opts).expect("sign must succeed");
let tail = &signed[pdf.len()..];
let tail_str = std::str::from_utf8(tail).unwrap();
let reason_hex = tail_str.find("/Reason <").is_some();
let location_hex = tail_str.find("/Location <").is_some();
let name_hex = tail_str.find("/Name <").is_some();
assert!(reason_hex, "/Reason must use hex string syntax");
assert!(location_hex, "/Location must use hex string syntax");
assert!(name_hex, "/Name must use hex string syntax");
let c3b3 = tail.windows(2).any(|w| w == [0xC3, 0xB3]);
assert!(!c3b3, "raw UTF-8 bytes for ó must not appear in signed output");
}
}