pub mod gradient;
pub mod outline;
pub mod page;
pub mod types;
pub use types::{
LinkAnnotation, LinkDestination, PdfDocument, PdfExtGState, PdfGradient, PdfInfo, PdfObject,
PdfOutline, PdfOutlineItem, PdfPage, PdfValue,
};
use fop_types::{FopError, Gradient, Result};
use crate::pdf::compliance::{generate_xmp_metadata, PdfCompliance, SRGB_ICC_PROFILE};
use crate::pdf::image::ImageXObject;
use crate::pdf::security::EncryptionDict;
use gradient::write_gradient_objects;
use outline::{count_outline_objects, write_outline_objects};
impl PdfDocument {
pub fn new() -> Self {
Self {
version: "1.4".to_string(),
objects: Vec::new(),
pages: Vec::new(),
info: PdfInfo::default(),
image_xobjects: Vec::new(),
gradients: Vec::new(),
ext_g_states: Vec::new(),
outline: None,
font_manager: crate::pdf::font::FontManager::new(),
encryption: None,
file_id: None,
compliance: PdfCompliance::Standard,
}
}
pub fn set_compliance(&mut self, compliance: PdfCompliance) -> Result<()> {
if compliance.requires_pdfa() && self.encryption.is_some() {
return Err(FopError::Generic(
"PDF/A-1b compliance is incompatible with encryption (ISO 19005-1 §6.1.1)"
.to_string(),
));
}
if compliance.requires_pdfa() {
self.version = "1.4".to_string();
}
self.compliance = compliance;
Ok(())
}
pub fn add_page(&mut self, page: PdfPage) {
self.pages.push(page);
}
pub fn add_image_xobject(&mut self, xobject: ImageXObject) -> usize {
self.image_xobjects.push(xobject);
self.image_xobjects.len() - 1
}
pub fn add_gradient(&mut self, gradient: Gradient) -> usize {
self.gradients.push(PdfGradient {
gradient,
object_id: 0, });
self.gradients.len() - 1
}
pub fn add_ext_g_state(&mut self, fill_opacity: f64, stroke_opacity: f64) -> usize {
for (idx, gs) in self.ext_g_states.iter().enumerate() {
if (gs.fill_opacity - fill_opacity).abs() < f64::EPSILON
&& (gs.stroke_opacity - stroke_opacity).abs() < f64::EPSILON
{
return idx;
}
}
self.ext_g_states.push(PdfExtGState {
fill_opacity,
stroke_opacity,
object_id: 0, });
self.ext_g_states.len() - 1
}
pub fn set_outline(&mut self, outline: PdfOutline) {
self.outline = Some(outline);
}
pub fn set_encryption(&mut self, encryption: EncryptionDict, file_id: Vec<u8>) -> Result<()> {
if self.compliance.requires_pdfa() {
return Err(FopError::Generic(
"PDF/A-1b compliance is incompatible with encryption (ISO 19005-1 §6.1.1)"
.to_string(),
));
}
self.encryption = Some(encryption);
self.file_id = Some(file_id);
Ok(())
}
fn encrypt_stream(&self, data: &[u8], obj_num: u32) -> Vec<u8> {
if let Some(ref enc) = self.encryption {
enc.encrypt_data(data, obj_num, 0)
} else {
data.to_vec()
}
}
pub fn embed_font(&mut self, font_data: Vec<u8>) -> Result<usize> {
self.font_manager.embed_font(font_data)
}
pub fn to_bytes(&self) -> Result<Vec<u8>> {
let mut bytes = Vec::new();
let mut xref_offsets = Vec::new();
bytes.extend_from_slice(format!("%PDF-{}\n", self.version).as_bytes());
bytes.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");
xref_offsets.push(0);
let outline_obj_count = if let Some(ref outline) = self.outline {
count_outline_objects(outline)
} else {
0
};
let encrypt_obj_count = if self.encryption.is_some() { 1 } else { 0 };
let font_obj_id = 3;
let first_outline_obj_id = 4;
let num_embedded_fonts = self.font_manager.font_count();
let encrypt_obj_id = first_outline_obj_id + outline_obj_count;
let compliance_base_id = encrypt_obj_id + encrypt_obj_count;
let needs_compliance = self.compliance != PdfCompliance::Standard;
let xmp_obj_count = if needs_compliance { 1 } else { 0 };
let xmp_obj_id = compliance_base_id; let oi_obj_count = if self.compliance.requires_pdfa() {
2
} else {
0
};
let output_intent_obj_id = compliance_base_id + xmp_obj_count; let icc_profile_obj_id = output_intent_obj_id + 1; let struct_tree_obj_count = if self.compliance.requires_pdfua() {
1
} else {
0
};
let struct_tree_obj_id = compliance_base_id + xmp_obj_count + oi_obj_count; let total_compliance_obj_count = xmp_obj_count + oi_obj_count + struct_tree_obj_count;
let first_embedded_font_obj_id = compliance_base_id + total_compliance_obj_count;
xref_offsets.push(bytes.len());
bytes.extend_from_slice(b"1 0 obj\n");
bytes.extend_from_slice(b"<<\n");
bytes.extend_from_slice(b"/Type /Catalog\n");
bytes.extend_from_slice(b"/Pages 2 0 R\n");
if self.outline.is_some() {
bytes.extend_from_slice(b"/Outlines 4 0 R\n");
}
if needs_compliance {
bytes.extend_from_slice(format!("/Metadata {} 0 R\n", xmp_obj_id).as_bytes());
}
if self.compliance.requires_pdfa() {
bytes.extend_from_slice(
format!("/OutputIntents [{} 0 R]\n", output_intent_obj_id).as_bytes(),
);
}
if self.compliance.requires_pdfua() {
bytes.extend_from_slice(b"/MarkInfo <<\n/Marked true\n>>\n");
let lang = self.info.lang.as_deref().unwrap_or("en-US");
bytes.extend_from_slice(format!("/Lang ({})\n", lang).as_bytes());
bytes.extend_from_slice(
format!("/StructTreeRoot {} 0 R\n", struct_tree_obj_id).as_bytes(),
);
bytes.extend_from_slice(b"/ViewerPreferences <<\n/DisplayDocTitle true\n>>\n");
} else if let Some(ref lang) = self.info.lang {
bytes.extend_from_slice(format!("/Lang ({})\n", lang).as_bytes());
}
bytes.extend_from_slice(b">>\n");
bytes.extend_from_slice(b"endobj\n");
let first_image_obj_id = first_embedded_font_obj_id + num_embedded_fonts * 6; let num_images = self.image_xobjects.len();
let first_gradient_obj_id = first_image_obj_id + num_images;
let num_gradients = self.gradients.len();
let first_ext_g_state_obj_id = first_gradient_obj_id + num_gradients * 2; let num_ext_g_states = self.ext_g_states.len();
let first_page_obj_id = first_ext_g_state_obj_id + num_ext_g_states;
#[allow(unused_variables)]
let total_annotations: usize = self.pages.iter().map(|p| p.link_annotations.len()).sum();
let first_annotation_obj_id = first_page_obj_id + self.pages.len() * 2;
xref_offsets.push(bytes.len());
bytes.extend_from_slice(b"2 0 obj\n");
bytes.extend_from_slice(b"<<\n");
bytes.extend_from_slice(b"/Type /Pages\n");
let page_obj_ids: Vec<usize> = (0..self.pages.len())
.map(|i| first_page_obj_id + i * 2)
.collect();
bytes.extend_from_slice(b"/Kids [");
for page_id in &page_obj_ids {
bytes.extend_from_slice(format!("{} 0 R ", page_id).as_bytes());
}
bytes.extend_from_slice(b"]\n");
bytes.extend_from_slice(format!("/Count {}\n", self.pages.len()).as_bytes());
bytes.extend_from_slice(b">>\n");
bytes.extend_from_slice(b"endobj\n");
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", font_obj_id).as_bytes());
bytes.extend_from_slice(b"<<\n");
bytes.extend_from_slice(b"/Type /Font\n");
bytes.extend_from_slice(b"/Subtype /Type1\n");
bytes.extend_from_slice(b"/BaseFont /Helvetica\n");
bytes.extend_from_slice(b">>\n");
bytes.extend_from_slice(b"endobj\n");
if let Some(ref outline) = self.outline {
write_outline_objects(
outline,
&mut bytes,
&mut xref_offsets,
first_outline_obj_id,
&page_obj_ids,
);
}
if let Some(ref enc) = self.encryption {
xref_offsets.push(bytes.len());
let enc_dict_str = enc.to_pdf_dict(encrypt_obj_id);
bytes.extend_from_slice(enc_dict_str.as_bytes());
}
if needs_compliance {
let title_ref = self.info.title.as_deref();
let creator_tool = format!("fop-rs {}", env!("CARGO_PKG_VERSION"));
let xmp_content = generate_xmp_metadata(title_ref, &creator_tool, self.compliance);
let xmp_bytes = xmp_content.as_bytes();
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", xmp_obj_id).as_bytes());
bytes.extend_from_slice(b"<<\n");
bytes.extend_from_slice(b"/Type /Metadata\n");
bytes.extend_from_slice(b"/Subtype /XML\n");
bytes.extend_from_slice(format!("/Length {}\n", xmp_bytes.len()).as_bytes());
bytes.extend_from_slice(b">>\nstream\n");
bytes.extend_from_slice(xmp_bytes);
bytes.extend_from_slice(b"\nendstream\nendobj\n");
}
if self.compliance.requires_pdfa() {
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", output_intent_obj_id).as_bytes());
bytes.extend_from_slice(b"<<\n");
bytes.extend_from_slice(b"/Type /OutputIntent\n");
bytes.extend_from_slice(b"/S /GTS_PDFA1\n");
bytes.extend_from_slice(b"/OutputConditionIdentifier (sRGB)\n");
bytes.extend_from_slice(b"/RegistryName (http://www.color.org)\n");
bytes.extend_from_slice(
format!("/DestOutputProfile {} 0 R\n", icc_profile_obj_id).as_bytes(),
);
bytes.extend_from_slice(b">>\nendobj\n");
let icc_data = SRGB_ICC_PROFILE;
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", icc_profile_obj_id).as_bytes());
bytes.extend_from_slice(b"<<\n");
bytes.extend_from_slice(b"/N 3\n"); bytes.extend_from_slice(format!("/Length {}\n", icc_data.len()).as_bytes());
bytes.extend_from_slice(b">>\nstream\n");
bytes.extend_from_slice(icc_data);
bytes.extend_from_slice(b"\nendstream\nendobj\n");
}
if self.compliance.requires_pdfua() {
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", struct_tree_obj_id).as_bytes());
bytes.extend_from_slice(b"<<\n");
bytes.extend_from_slice(b"/Type /StructTreeRoot\n");
bytes.extend_from_slice(b">>\nendobj\n");
}
if num_embedded_fonts > 0 {
use crate::pdf::font::{
generate_cidfont_dict, generate_font_descriptor, generate_font_dictionary,
generate_font_stream_header, generate_to_unicode_cmap,
};
let font_objects = self
.font_manager
.generate_font_objects(first_embedded_font_obj_id)?;
for (
font_idx,
(
descriptor_id,
stream_id,
cidfont_id,
type0_dict_id,
to_unicode_id,
cidtogidmap_id,
font,
),
) in font_objects.iter().enumerate()
{
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", descriptor_id).as_bytes());
bytes.extend_from_slice(generate_font_descriptor(font, *stream_id).as_bytes());
bytes.extend_from_slice(b"\nendobj\n");
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", stream_id).as_bytes());
bytes.extend_from_slice(generate_font_stream_header(font).as_bytes());
bytes.extend_from_slice(b"\nstream\n");
bytes.extend_from_slice(&font.font_data);
bytes.extend_from_slice(b"\nendstream\n");
bytes.extend_from_slice(b"endobj\n");
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", cidfont_id).as_bytes());
bytes.extend_from_slice(
generate_cidfont_dict(font, *descriptor_id, *cidtogidmap_id).as_bytes(),
);
bytes.extend_from_slice(b"\nendobj\n");
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", type0_dict_id).as_bytes());
bytes.extend_from_slice(
generate_font_dictionary(font, *cidfont_id, Some(*to_unicode_id)).as_bytes(),
);
bytes.extend_from_slice(b"\nendobj\n");
let cmap_content = generate_to_unicode_cmap(font);
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", to_unicode_id).as_bytes());
bytes.extend_from_slice(b"<<\n/Length ");
bytes.extend_from_slice(cmap_content.len().to_string().as_bytes());
bytes.extend_from_slice(b"\n>>\nstream\n");
bytes.extend_from_slice(cmap_content.as_bytes());
bytes.extend_from_slice(b"\nendstream\nendobj\n");
let used_chars = if let Some(subsetter) = self.font_manager.get_subsetter(font_idx)
{
subsetter.used_chars()
} else {
&std::collections::BTreeSet::new()
};
let cidtogidmap_data = crate::pdf::cidfont::generate_cidtogidmap_stream(
&font.char_to_glyph,
used_chars,
);
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", cidtogidmap_id).as_bytes());
bytes.extend_from_slice(b"<<\n/Length ");
bytes.extend_from_slice(cidtogidmap_data.len().to_string().as_bytes());
bytes.extend_from_slice(b"\n>>\nstream\n");
bytes.extend_from_slice(&cidtogidmap_data);
bytes.extend_from_slice(b"\nendstream\nendobj\n");
}
}
for (img_idx, xobject) in self.image_xobjects.iter().enumerate() {
let obj_id = first_image_obj_id + img_idx;
xref_offsets.push(bytes.len());
let stream_header = xobject.to_pdf_stream(obj_id as u32);
bytes.extend_from_slice(stream_header.as_bytes());
bytes.extend_from_slice(xobject.stream_data());
bytes.extend_from_slice(ImageXObject::stream_end().as_bytes());
}
for (grad_idx, pdf_gradient) in self.gradients.iter().enumerate() {
let function_obj_id = first_gradient_obj_id + grad_idx * 2;
let shading_obj_id = function_obj_id + 1;
write_gradient_objects(
&pdf_gradient.gradient,
function_obj_id,
shading_obj_id,
&mut bytes,
&mut xref_offsets,
);
}
for (gs_idx, ext_g_state) in self.ext_g_states.iter().enumerate() {
let obj_id = first_ext_g_state_obj_id + gs_idx;
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", obj_id).as_bytes());
bytes.extend_from_slice(b"<<\n");
bytes.extend_from_slice(b"/Type /ExtGState\n");
bytes.extend_from_slice(format!("/ca {:.3}\n", ext_g_state.fill_opacity).as_bytes());
bytes.extend_from_slice(format!("/CA {:.3}\n", ext_g_state.stroke_opacity).as_bytes());
bytes.extend_from_slice(b">>\n");
bytes.extend_from_slice(b"endobj\n");
}
let mut current_annotation_obj_id = first_annotation_obj_id;
for (page_idx, page) in self.pages.iter().enumerate() {
let page_obj_id = first_page_obj_id + page_idx * 2;
let content_obj_id = page_obj_id + 1;
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", page_obj_id).as_bytes());
bytes.extend_from_slice(b"<<\n");
bytes.extend_from_slice(b"/Type /Page\n");
bytes.extend_from_slice(b"/Parent 2 0 R\n");
bytes.extend_from_slice(
format!(
"/MediaBox [0 0 {} {}]\n",
page.width.to_pt(),
page.height.to_pt()
)
.as_bytes(),
);
bytes.extend_from_slice(b"/Resources <<\n");
bytes.extend_from_slice(b" /Font <<\n");
bytes.extend_from_slice(format!(" /F1 {} 0 R\n", font_obj_id).as_bytes());
if num_embedded_fonts > 0 {
for font_idx in 0..num_embedded_fonts {
let type0_dict_obj_id = first_embedded_font_obj_id + font_idx * 6 + 3; bytes.extend_from_slice(
format!(" /F{} {} 0 R\n", font_idx + 2, type0_dict_obj_id).as_bytes(),
);
}
}
bytes.extend_from_slice(b" >>\n");
if !self.image_xobjects.is_empty() {
bytes.extend_from_slice(b" /XObject <<\n");
for img_idx in 0..self.image_xobjects.len() {
let obj_id = first_image_obj_id + img_idx;
bytes.extend_from_slice(
format!(" /Im{} {} 0 R\n", img_idx, obj_id).as_bytes(),
);
}
bytes.extend_from_slice(b" >>\n");
}
if !self.gradients.is_empty() {
bytes.extend_from_slice(b" /Shading <<\n");
for grad_idx in 0..self.gradients.len() {
let shading_obj_id = first_gradient_obj_id + grad_idx * 2 + 1; bytes.extend_from_slice(
format!(" /Sh{} {} 0 R\n", grad_idx, shading_obj_id).as_bytes(),
);
}
bytes.extend_from_slice(b" >>\n");
}
if !self.ext_g_states.is_empty() {
bytes.extend_from_slice(b" /ExtGState <<\n");
for gs_idx in 0..self.ext_g_states.len() {
let gs_obj_id = first_ext_g_state_obj_id + gs_idx;
bytes.extend_from_slice(
format!(" /GS{} {} 0 R\n", gs_idx, gs_obj_id).as_bytes(),
);
}
bytes.extend_from_slice(b" >>\n");
}
bytes.extend_from_slice(b">>\n");
bytes.extend_from_slice(format!("/Contents {} 0 R\n", content_obj_id).as_bytes());
if !page.link_annotations.is_empty() {
bytes.extend_from_slice(b"/Annots [");
for annot_idx in 0..page.link_annotations.len() {
bytes.extend_from_slice(
format!("{} 0 R ", current_annotation_obj_id + annot_idx).as_bytes(),
);
}
bytes.extend_from_slice(b"]\n");
current_annotation_obj_id += page.link_annotations.len();
}
bytes.extend_from_slice(b">>\n");
bytes.extend_from_slice(b"endobj\n");
let stream_data = self.encrypt_stream(&page.content, content_obj_id as u32);
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", content_obj_id).as_bytes());
bytes.extend_from_slice(b"<<\n");
bytes.extend_from_slice(format!("/Length {}\n", stream_data.len()).as_bytes());
bytes.extend_from_slice(b">>\n");
bytes.extend_from_slice(b"stream\n");
bytes.extend_from_slice(&stream_data);
bytes.extend_from_slice(b"\nendstream\n");
bytes.extend_from_slice(b"endobj\n");
}
if total_annotations > 0 {
let mut annot_obj_id = first_annotation_obj_id;
for (page_idx, page) in self.pages.iter().enumerate() {
let page_obj_id = first_page_obj_id + page_idx * 2;
for annot in &page.link_annotations {
xref_offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", annot_obj_id).as_bytes());
bytes.extend_from_slice(b"<<\n");
bytes.extend_from_slice(b"/Type /Annot\n");
bytes.extend_from_slice(b"/Subtype /Link\n");
bytes.extend_from_slice(
format!(
"/Rect [{:.2} {:.2} {:.2} {:.2}]\n",
annot.rect[0], annot.rect[1], annot.rect[2], annot.rect[3]
)
.as_bytes(),
);
bytes.extend_from_slice(format!("/P {} 0 R\n", page_obj_id).as_bytes());
bytes.extend_from_slice(b"/Border [0 0 0]\n");
match &annot.destination {
LinkDestination::External(url) => {
bytes.extend_from_slice(b"/A <<\n");
bytes.extend_from_slice(b" /S /URI\n");
bytes.extend_from_slice(
format!(" /URI ({})\n", outline::escape_pdf_string(url))
.as_bytes(),
);
bytes.extend_from_slice(b">>\n");
}
LinkDestination::Internal(dest_id) => {
bytes.extend_from_slice(
format!("/Dest ({})\n", outline::escape_pdf_string(dest_id))
.as_bytes(),
);
}
}
bytes.extend_from_slice(b">>\n");
bytes.extend_from_slice(b"endobj\n");
annot_obj_id += 1;
}
}
}
let xref_offset = bytes.len();
bytes.extend_from_slice(b"xref\n");
bytes.extend_from_slice(format!("0 {}\n", xref_offsets.len()).as_bytes());
bytes.extend_from_slice(b"0000000000 65535 f \n"); for offset in xref_offsets.iter().skip(1) {
bytes.extend_from_slice(format!("{:010} 00000 n \n", offset).as_bytes());
}
bytes.extend_from_slice(b"trailer\n");
bytes.extend_from_slice(b"<<\n");
bytes.extend_from_slice(format!("/Size {}\n", xref_offsets.len()).as_bytes());
bytes.extend_from_slice(b"/Root 1 0 R\n");
if self.encryption.is_some() {
bytes.extend_from_slice(format!("/Encrypt {} 0 R\n", encrypt_obj_id).as_bytes());
}
if let Some(ref file_id) = self.file_id {
let hex = file_id
.iter()
.map(|b| format!("{:02X}", b))
.collect::<String>();
bytes.extend_from_slice(format!("/ID [<{}> <{}>]\n", hex, hex).as_bytes());
}
if self.info.title.is_some()
|| self.info.author.is_some()
|| self.info.subject.is_some()
|| self.info.creation_date.is_some()
{
bytes.extend_from_slice(b"/Info <<\n");
if let Some(ref title) = self.info.title {
bytes.extend_from_slice(format!(" /Title ({})\n", title).as_bytes());
}
if let Some(ref author) = self.info.author {
bytes.extend_from_slice(format!(" /Author ({})\n", author).as_bytes());
}
if let Some(ref subject) = self.info.subject {
bytes.extend_from_slice(format!(" /Subject ({})\n", subject).as_bytes());
}
if let Some(ref creation_date) = self.info.creation_date {
bytes
.extend_from_slice(format!(" /CreationDate ({})\n", creation_date).as_bytes());
}
bytes.extend_from_slice(b">>\n");
}
bytes.extend_from_slice(b">>\n");
bytes.extend_from_slice(b"startxref\n");
bytes.extend_from_slice(format!("{}\n", xref_offset).as_bytes());
bytes.extend_from_slice(b"%%EOF\n");
Ok(bytes)
}
}
impl Default for PdfDocument {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pdf_document_creation() {
let doc = PdfDocument::new();
assert_eq!(doc.version, "1.4");
assert_eq!(doc.pages.len(), 0);
}
#[test]
fn test_pdf_page() {
let mut page = PdfPage::new(
fop_types::Length::from_mm(210.0),
fop_types::Length::from_mm(297.0),
);
page.add_text(
"Hello World",
fop_types::Length::from_pt(100.0),
fop_types::Length::from_pt(700.0),
fop_types::Length::from_pt(12.0),
);
assert!(!page.content.is_empty());
let content_str = String::from_utf8_lossy(&page.content);
assert!(content_str.contains("Hello World"));
assert!(content_str.contains("BT")); assert!(content_str.contains("ET")); }
#[test]
fn test_pdf_bytes() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let header = String::from_utf8_lossy(&bytes[..8]);
assert!(header.starts_with("%PDF-"));
}
#[test]
fn test_pdf_encrypted_bytes() {
use crate::pdf::security::{generate_file_id, PdfPermissions, PdfSecurity};
let mut doc = PdfDocument::new();
let mut page = PdfPage::new(
fop_types::Length::from_mm(210.0),
fop_types::Length::from_mm(297.0),
);
page.add_text(
"Secret Text",
fop_types::Length::from_pt(100.0),
fop_types::Length::from_pt(700.0),
fop_types::Length::from_pt(12.0),
);
doc.add_page(page);
let permissions = PdfPermissions {
allow_print: false,
allow_copy: false,
..Default::default()
};
let security = PdfSecurity::new("owner123", "user456", permissions);
let file_id = generate_file_id("test-encrypted");
let encryption_dict = security.compute_encryption_dict(&file_id);
doc.set_encryption(encryption_dict, file_id)
.expect("test: should succeed");
let bytes = doc.to_bytes().expect("test: should succeed");
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("%PDF-"));
assert!(content.contains("/Filter /Standard"));
assert!(content.contains("/V 2")); assert!(content.contains("/R 3")); assert!(content.contains("/Length 128"));
assert!(content.contains("/Encrypt")); assert!(content.contains("/ID [<"));
assert!(!content.contains("Secret Text"));
}
#[test]
fn test_pdf_without_encryption_has_plaintext() {
let mut doc = PdfDocument::new();
let mut page = PdfPage::new(
fop_types::Length::from_mm(210.0),
fop_types::Length::from_mm(297.0),
);
page.add_text(
"Visible Text",
fop_types::Length::from_pt(100.0),
fop_types::Length::from_pt(700.0),
fop_types::Length::from_pt(12.0),
);
doc.add_page(page);
let bytes = doc.to_bytes().expect("test: should succeed");
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Visible Text"));
assert!(!content.contains("/Encrypt"));
assert!(!content.contains("/Filter /Standard"));
}
}
#[cfg(test)]
mod tests_extended {
use super::*;
use crate::pdf::compliance::PdfCompliance;
use crate::pdf::security::{generate_file_id, PdfPermissions, PdfSecurity};
use fop_types::Length;
#[test]
fn test_pdf_document_default() {
let doc = PdfDocument::default();
assert_eq!(doc.version, "1.4");
assert!(doc.pages.is_empty());
}
#[test]
fn test_pdf_document_add_multiple_pages() {
let mut doc = PdfDocument::new();
for _ in 0..3 {
let page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
doc.add_page(page);
}
assert_eq!(doc.pages.len(), 3);
}
#[test]
fn test_pdf_page_new_has_correct_dimensions() {
let page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
assert_eq!(page.width, Length::from_mm(210.0));
assert_eq!(page.height, Length::from_mm(297.0));
assert!(page.content.is_empty());
}
#[test]
fn test_pdf_page_add_text_generates_bt_et() {
let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
page.add_text(
"Test",
Length::from_pt(72.0),
Length::from_pt(700.0),
Length::from_pt(12.0),
);
let content = String::from_utf8_lossy(&page.content);
assert!(content.contains("BT"));
assert!(content.contains("ET"));
}
#[test]
fn test_pdf_page_add_background() {
let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
page.add_background(
Length::ZERO,
Length::ZERO,
Length::from_pt(595.0),
Length::from_pt(842.0),
fop_types::Color::WHITE,
);
let content = String::from_utf8_lossy(&page.content);
assert!(content.contains("re f"));
}
#[test]
fn test_pdf_compliance_pdfa1b_adds_version_info() {
let mut doc = PdfDocument::new();
doc.set_compliance(PdfCompliance::PdfA1b)
.expect("test: should succeed");
let bytes = doc.to_bytes().expect("test: should succeed");
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("%PDF-1.4"));
}
#[test]
fn test_pdf_document_to_bytes_starts_with_header() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn test_pdf_document_to_bytes_ends_with_eof() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("%%EOF"));
}
#[test]
fn test_pdf_document_aes256_encryption() {
let mut doc = PdfDocument::new();
let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
page.add_text(
"Private",
Length::from_pt(72.0),
Length::from_pt(700.0),
Length::from_pt(12.0),
);
doc.add_page(page);
let sec = PdfSecurity::new_aes256("owner", "user", PdfPermissions::default());
let file_id = generate_file_id("aes-doc");
let dict = sec.compute_encryption_dict(&file_id);
doc.set_encryption(dict, file_id)
.expect("test: should succeed");
let bytes = doc.to_bytes().expect("test: should succeed");
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/V 5")); assert!(content.contains("/R 6")); assert!(content.contains("/OE <")); }
#[test]
fn test_pdf_outline_structure() {
let mut doc = PdfDocument::new();
let outline = PdfOutline {
items: vec![
PdfOutlineItem {
title: "Chapter 1".to_string(),
page_index: Some(0),
external_destination: None,
children: vec![],
},
PdfOutlineItem {
title: "Chapter 2".to_string(),
page_index: Some(1),
external_destination: None,
children: vec![],
},
],
};
doc.set_outline(outline);
for _ in 0..2 {
let page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
doc.add_page(page);
}
let bytes = doc.to_bytes().expect("test: should succeed");
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Chapter 1"));
assert!(content.contains("Chapter 2"));
assert!(content.contains("/Outlines"));
}
#[test]
fn test_pdf_page_add_rule() {
let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
page.add_rule(
Length::from_pt(50.0),
Length::from_pt(400.0),
Length::from_pt(400.0),
Length::from_pt(2.0),
fop_types::Color::BLACK,
"solid",
);
let content = String::from_utf8_lossy(&page.content);
assert!(!content.is_empty());
}
}
#[cfg(test)]
mod tests_document_comprehensive {
use super::*;
use fop_types::Length;
#[test]
fn test_new_produces_non_empty_output() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
assert!(!bytes.is_empty());
}
#[test]
fn test_new_version_is_1_4() {
let doc = PdfDocument::new();
assert_eq!(doc.version, "1.4");
}
#[test]
fn test_new_has_no_pages() {
let doc = PdfDocument::new();
assert_eq!(doc.pages.len(), 0);
}
#[test]
fn test_new_has_no_images() {
let doc = PdfDocument::new();
assert_eq!(doc.image_xobjects.len(), 0);
}
#[test]
fn test_new_has_no_outline() {
let doc = PdfDocument::new();
assert!(doc.outline.is_none());
}
#[test]
fn test_pdf_header_starts_with_pdf_1_4() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
assert!(bytes.starts_with(b"%PDF-1.4"));
}
#[test]
fn test_pdf_header_present_in_output() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("%PDF-"));
}
#[test]
fn test_page_count_zero_pages_in_catalog() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/Count 0"));
}
#[test]
fn test_page_count_one_page_in_catalog() {
let mut doc = PdfDocument::new();
doc.add_page(PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0)));
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/Count 1"));
}
#[test]
fn test_page_count_three_pages_in_catalog() {
let mut doc = PdfDocument::new();
for _ in 0..3 {
doc.add_page(PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0)));
}
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/Count 3"));
assert_eq!(doc.pages.len(), 3);
}
#[test]
fn test_info_title_appears_in_output() {
let mut doc = PdfDocument::new();
doc.info.title = Some("My Test Document".to_string());
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/Title (My Test Document)"));
}
#[test]
fn test_info_author_appears_in_output() {
let mut doc = PdfDocument::new();
doc.info.author = Some("Jane Doe".to_string());
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/Author (Jane Doe)"));
}
#[test]
fn test_info_subject_appears_in_output() {
let mut doc = PdfDocument::new();
doc.info.subject = Some("Unit Testing".to_string());
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/Subject (Unit Testing)"));
}
#[test]
fn test_info_creation_date_appears_in_output() {
let mut doc = PdfDocument::new();
doc.info.creation_date = Some("D:20260220120000".to_string());
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/CreationDate (D:20260220120000)"));
}
#[test]
fn test_info_lang_field_roundtrip() {
let mut info = PdfInfo::default();
assert!(info.lang.is_none());
info.lang = Some("ja".to_string());
assert_eq!(info.lang.as_deref(), Some("ja"));
}
#[test]
fn test_info_no_metadata_omits_info_dict() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(!s.contains("/Info <<"));
}
#[test]
fn test_info_all_fields_set() {
let mut doc = PdfDocument::new();
doc.info.title = Some("Full Meta".to_string());
doc.info.author = Some("Author A".to_string());
doc.info.subject = Some("Subject S".to_string());
doc.info.creation_date = Some("D:20260101".to_string());
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/Title (Full Meta)"));
assert!(s.contains("/Author (Author A)"));
assert!(s.contains("/Subject (Subject S)"));
assert!(s.contains("/CreationDate (D:20260101)"));
}
#[test]
fn test_xref_table_present() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("xref\n"));
}
#[test]
fn test_xref_free_object_zero() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("0000000000 65535 f "));
}
#[test]
fn test_xref_entries_use_n_type() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains(" 00000 n "));
}
#[test]
fn test_trailer_has_root_reference() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/Root 1 0 R"));
}
#[test]
fn test_trailer_has_size_entry() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/Size "));
}
#[test]
fn test_startxref_keyword_present() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("startxref\n"));
}
#[test]
fn test_startxref_offset_is_nonzero() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
let idx = s.find("startxref\n").expect("test: should succeed");
let after = &s[idx + "startxref\n".len()..];
let offset_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
let offset: usize = offset_str.parse().expect("test: should succeed");
assert!(offset > 0);
}
#[test]
fn test_eof_marker_present() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("%%EOF"));
}
#[test]
fn test_pdfinfo_default_all_none() {
let info = PdfInfo::default();
assert!(info.title.is_none());
assert!(info.author.is_none());
assert!(info.subject.is_none());
assert!(info.creation_date.is_none());
assert!(info.lang.is_none());
}
#[test]
fn test_pdfinfo_clone() {
let info = PdfInfo {
title: Some("Clone Me".to_string()),
..Default::default()
};
let cloned = info.clone();
assert_eq!(cloned.title.as_deref(), Some("Clone Me"));
}
#[test]
fn test_file_id_appears_in_trailer_as_id_array() {
use crate::pdf::security::generate_file_id;
let mut doc = PdfDocument::new();
let fid = generate_file_id("id-test");
doc.file_id = Some(fid);
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/ID [<"));
}
#[test]
fn test_add_ext_g_state_deduplication() {
let mut doc = PdfDocument::new();
let idx1 = doc.add_ext_g_state(0.5, 0.5);
let idx2 = doc.add_ext_g_state(0.5, 0.5);
assert_eq!(idx1, idx2);
assert_eq!(doc.ext_g_states.len(), 1);
}
#[test]
fn test_add_ext_g_state_different_values_creates_two() {
let mut doc = PdfDocument::new();
let idx1 = doc.add_ext_g_state(0.3, 0.3);
let idx2 = doc.add_ext_g_state(0.7, 0.7);
assert_ne!(idx1, idx2);
assert_eq!(doc.ext_g_states.len(), 2);
}
#[test]
fn test_add_gradient_returns_index() {
use fop_types::{Color, ColorStop, Gradient, Length, Point};
let mut doc = PdfDocument::new();
let gradient = Gradient::linear(
Point::new(Length::from_pt(0.0), Length::from_pt(0.0)),
Point::new(Length::from_pt(100.0), Length::from_pt(0.0)),
vec![
ColorStop::new(0.0, Color::BLACK),
ColorStop::new(1.0, Color::WHITE),
],
);
let idx = doc.add_gradient(gradient);
assert_eq!(idx, 0);
assert_eq!(doc.gradients.len(), 1);
}
#[test]
fn test_catalog_type_present() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/Type /Catalog"));
}
#[test]
fn test_catalog_pages_reference_present() {
let doc = PdfDocument::new();
let bytes = doc.to_bytes().expect("test: should succeed");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/Pages 2 0 R"));
}
#[test]
fn test_pdfpage_new_empty_content() {
let page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
assert!(page.content.is_empty());
assert!(page.link_annotations.is_empty());
}
#[test]
fn test_pdfpage_add_text_with_spacing_produces_tc_tw() {
let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
page.add_text_with_spacing(
"Hello",
Length::from_pt(72.0),
Length::from_pt(700.0),
Length::from_pt(12.0),
Some(Length::from_pt(1.0)),
Some(Length::from_pt(2.0)),
);
let content = String::from_utf8_lossy(&page.content);
assert!(content.contains("Tc"));
assert!(content.contains("Tw"));
}
#[test]
fn test_pdfpage_add_background_generates_rg_and_re() {
let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
page.add_background(
Length::from_pt(10.0),
Length::from_pt(10.0),
Length::from_pt(200.0),
Length::from_pt(100.0),
fop_types::Color::rgb(255, 0, 0),
);
let content = String::from_utf8_lossy(&page.content);
assert!(content.contains("rg"));
assert!(content.contains("re f"));
}
#[test]
fn test_pdfpage_add_link_annotation_stores_annotation() {
let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
page.add_link_annotation(
Length::from_pt(50.0),
Length::from_pt(700.0),
Length::from_pt(100.0),
Length::from_pt(12.0),
LinkDestination::External("https://example.com".to_string()),
);
assert_eq!(page.link_annotations.len(), 1);
}
#[test]
fn test_pdfpage_link_annotation_rect_values() {
let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
page.add_link_annotation(
Length::from_pt(10.0),
Length::from_pt(20.0),
Length::from_pt(80.0),
Length::from_pt(14.0),
LinkDestination::Internal("section-1".to_string()),
);
let ann = &page.link_annotations[0];
assert!((ann.rect[0] - 10.0).abs() < 0.01);
assert!((ann.rect[1] - 20.0).abs() < 0.01);
assert!((ann.rect[2] - 90.0).abs() < 0.01);
assert!((ann.rect[3] - 34.0).abs() < 0.01);
}
#[test]
fn test_pdfpage_multiple_texts_accumulate_in_content() {
let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
page.add_text(
"First",
Length::from_pt(72.0),
Length::from_pt(700.0),
Length::from_pt(12.0),
);
page.add_text(
"Second",
Length::from_pt(72.0),
Length::from_pt(680.0),
Length::from_pt(12.0),
);
let content = String::from_utf8_lossy(&page.content);
assert!(content.contains("First"));
assert!(content.contains("Second"));
}
#[test]
fn test_pdf_value_boolean() {
let v = PdfValue::Boolean(true);
if let PdfValue::Boolean(b) = v {
assert!(b);
} else {
panic!("Expected Boolean");
}
}
#[test]
fn test_pdf_value_integer() {
let v = PdfValue::Integer(42);
if let PdfValue::Integer(n) = v {
assert_eq!(n, 42);
} else {
panic!("Expected Integer");
}
}
#[test]
#[allow(clippy::approx_constant)]
fn test_pdf_value_real() {
let v = PdfValue::Real(3.14);
if let PdfValue::Real(f) = v {
assert!((f - 3.14).abs() < f64::EPSILON);
} else {
panic!("Expected Real");
}
}
#[test]
fn test_pdf_value_name() {
let v = PdfValue::Name("Font".to_string());
if let PdfValue::Name(s) = v {
assert_eq!(s, "Font");
} else {
panic!("Expected Name");
}
}
#[test]
fn test_pdf_value_null() {
let v = PdfValue::Null;
assert!(matches!(v, PdfValue::Null));
}
#[test]
fn test_set_compliance_pdfa_with_encryption_returns_error() {
use crate::pdf::compliance::PdfCompliance;
use crate::pdf::security::{generate_file_id, PdfPermissions, PdfSecurity};
let mut doc = PdfDocument::new();
let sec = PdfSecurity::new("owner", "user", PdfPermissions::default());
let fid = generate_file_id("enc");
let dict = sec.compute_encryption_dict(&fid);
doc.set_encryption(dict, fid).expect("test: should succeed");
let result = doc.set_compliance(PdfCompliance::PdfA1b);
assert!(result.is_err());
}
}