use super::types::{ComplianceError, ErrorCode, PdfALevel, ValidationResult};
use super::PdfAValidator;
use crate::document::PdfDocument;
use crate::editor::DocumentEditor;
use crate::error::Result;
use crate::object::{Object, ObjectRef};
#[derive(Debug, Clone)]
pub struct ConversionConfig {
pub embed_fonts: bool,
pub remove_javascript: bool,
pub remove_encryption: bool,
pub flatten_transparency: bool,
pub remove_embedded_files: bool,
pub add_structure: bool,
pub icc_profile: Option<Vec<u8>>,
}
impl Default for ConversionConfig {
fn default() -> Self {
Self {
embed_fonts: true,
remove_javascript: true,
remove_encryption: true,
flatten_transparency: true,
remove_embedded_files: true,
add_structure: false,
icc_profile: None,
}
}
}
impl ConversionConfig {
pub fn new() -> Self {
Self::default()
}
pub fn embed_fonts(mut self, embed: bool) -> Self {
self.embed_fonts = embed;
self
}
pub fn remove_javascript(mut self, remove: bool) -> Self {
self.remove_javascript = remove;
self
}
pub fn flatten_transparency(mut self, flatten: bool) -> Self {
self.flatten_transparency = flatten;
self
}
pub fn add_structure(mut self, add: bool) -> Self {
self.add_structure = add;
self
}
pub fn with_icc_profile(mut self, profile: Vec<u8>) -> Self {
self.icc_profile = Some(profile);
self
}
}
#[derive(Debug, Clone)]
pub struct ConversionResult {
pub success: bool,
pub level: PdfALevel,
pub validation: ValidationResult,
pub actions: Vec<ConversionAction>,
pub errors: Vec<ConversionError>,
}
impl ConversionResult {
fn new(level: PdfALevel) -> Self {
Self {
success: false,
level,
validation: ValidationResult::new(level),
actions: Vec::new(),
errors: Vec::new(),
}
}
fn add_action(&mut self, action: ConversionAction) {
self.actions.push(action);
}
fn add_error(&mut self, error: ConversionError) {
self.errors.push(error);
}
}
#[derive(Debug, Clone)]
pub struct ConversionAction {
pub action_type: ActionType,
pub description: String,
pub fixed_error: Option<ErrorCode>,
}
impl ConversionAction {
fn new(action_type: ActionType, description: impl Into<String>) -> Self {
Self {
action_type,
description: description.into(),
fixed_error: None,
}
}
fn with_fixed_error(mut self, code: ErrorCode) -> Self {
self.fixed_error = Some(code);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionType {
AddedXmpMetadata,
AddedPdfaIdentification,
EmbeddedFont,
AddedOutputIntent,
RemovedJavaScript,
RemovedEncryption,
FlattenedTransparency,
RemovedEmbeddedFiles,
AddedStructure,
FixedAnnotation,
AddedLanguage,
}
#[derive(Debug, Clone)]
pub struct ConversionError {
pub error_code: ErrorCode,
pub reason: String,
}
impl ConversionError {
fn new(error_code: ErrorCode, reason: impl Into<String>) -> Self {
Self {
error_code,
reason: reason.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct PdfAConverter {
level: PdfALevel,
config: ConversionConfig,
validator: PdfAValidator,
}
impl PdfAConverter {
pub fn new(level: PdfALevel) -> Self {
Self {
level,
config: ConversionConfig::default(),
validator: PdfAValidator::new(),
}
}
pub fn with_config(mut self, config: ConversionConfig) -> Self {
self.config = config;
self
}
pub fn level(&self) -> PdfALevel {
self.level
}
pub fn convert(&self, document: &mut PdfDocument) -> Result<ConversionResult> {
let mut editor = DocumentEditor::from_bytes(document.source_bytes.clone())?;
let result = self.convert_with_editor(&mut editor)?;
editor.commit_in_place()?;
*document = editor.into_source();
Ok(result)
}
fn convert_with_editor(&self, editor: &mut DocumentEditor) -> Result<ConversionResult> {
use std::collections::HashSet;
let mut result = ConversionResult::new(self.level);
let initial_validation = self.validator.validate(editor.source_mut(), self.level)?;
if initial_validation.is_compliant {
result.success = true;
result.validation = initial_validation;
return Ok(result);
}
let mut applied: HashSet<ErrorCode> = HashSet::new();
for error in &initial_validation.errors {
self.try_fix_error(editor, error, &mut result, &mut applied)?;
}
if !applied.contains(&ErrorCode::MissingOutputIntent)
&& !applied.contains(&ErrorCode::DeviceColorWithoutIntent)
{
self.add_output_intent(editor, &mut result)?;
}
editor.commit_in_place()?;
let mut final_validation = self.validator.validate(editor.source_mut(), self.level)?;
downgrade_known_unembeddable_fonts(&mut final_validation);
result.validation = final_validation.clone();
result.success = final_validation.is_compliant;
Ok(result)
}
fn try_fix_error(
&self,
editor: &mut DocumentEditor,
error: &ComplianceError,
result: &mut ConversionResult,
applied: &mut std::collections::HashSet<ErrorCode>,
) -> Result<()> {
if error.code != ErrorCode::FontNotEmbedded && !applied.insert(error.code) {
return Ok(());
}
match error.code {
ErrorCode::MissingXmpMetadata => {
self.add_xmp_metadata(editor, result)?;
},
ErrorCode::MissingPdfaIdentification => {
self.add_pdfa_identification(editor, result)?;
},
ErrorCode::FontNotEmbedded => {
if self.config.embed_fonts {
self.embed_font(editor, error, result)?;
} else {
result.add_error(ConversionError::new(
error.code,
"Font embedding disabled in configuration",
));
}
},
ErrorCode::MissingOutputIntent => {
self.add_output_intent(editor, result)?;
},
ErrorCode::DeviceColorWithoutIntent => {
self.add_output_intent(editor, result)?;
},
ErrorCode::JavaScriptNotAllowed => {
if self.config.remove_javascript {
self.remove_javascript(editor, result)?;
} else {
result.add_error(ConversionError::new(
error.code,
"JavaScript removal disabled in configuration",
));
}
},
ErrorCode::EncryptionNotAllowed => {
if self.config.remove_encryption {
self.remove_encryption(editor, result)?;
} else {
result.add_error(ConversionError::new(
error.code,
"Document is encrypted and encryption removal is disabled",
));
}
},
ErrorCode::TransparencyNotAllowed => {
if self.config.flatten_transparency && !self.level.allows_transparency() {
self.flatten_transparency(editor, result)?;
} else if !self.level.allows_transparency() {
result.add_error(ConversionError::new(
error.code,
"Transparency flattening disabled for PDF/A-1",
));
}
},
ErrorCode::EmbeddedFileNotAllowed => {
if self.config.remove_embedded_files && !self.level.allows_embedded_files() {
self.remove_embedded_files(editor, result)?;
} else if !self.level.allows_embedded_files() {
result.add_error(ConversionError::new(
error.code,
"Embedded file removal disabled",
));
}
},
ErrorCode::MissingDocumentStructure => {
if self.config.add_structure && self.level.requires_structure() {
self.add_structure(editor, result)?;
} else if self.level.requires_structure() {
result.add_error(ConversionError::new(
error.code,
"Structure tree generation not available; consider using PDF/A-*b level",
));
}
},
ErrorCode::MissingLanguage => {
self.add_language(editor, result)?;
},
ErrorCode::MissingAppearanceStream => {
self.fix_annotation_appearance(editor, error, result)?;
},
ErrorCode::FontMissingTables
| ErrorCode::FontInvalidEncoding
| ErrorCode::FontMissingToUnicode
| ErrorCode::InvalidIccProfile
| ErrorCode::IccProfileVersionMismatch
| ErrorCode::InvalidImageColorSpace
| ErrorCode::UnsupportedImageCompression
| ErrorCode::LzwCompressionNotAllowed
| ErrorCode::InvalidStructureTree
| ErrorCode::MultimediaNotAllowed
| ErrorCode::ExternalContentNotAllowed
| ErrorCode::InvalidAnnotation
| ErrorCode::InvalidAction
| ErrorCode::LaunchActionNotAllowed
| ErrorCode::MissingAfRelationship
| ErrorCode::PostScriptNotAllowed
| ErrorCode::ReferenceXObjectNotAllowed
| ErrorCode::OptionalContentIssue
| ErrorCode::InvalidPdfaIdentification
| ErrorCode::XmpMetadataMismatch => {
result.add_error(ConversionError::new(
error.code,
format!("Cannot automatically fix: {}", error.message),
));
},
}
Ok(())
}
fn add_xmp_metadata(
&self,
editor: &mut DocumentEditor,
result: &mut ConversionResult,
) -> Result<()> {
let xmp_bytes = self.generate_xmp_metadata().into_bytes();
let mut stream_dict = std::collections::HashMap::new();
stream_dict.insert("Type".to_string(), Object::Name("Metadata".to_string()));
stream_dict.insert("Subtype".to_string(), Object::Name("XML".to_string()));
stream_dict.insert("Length".to_string(), Object::Integer(xmp_bytes.len() as i64));
let xmp_stream = Object::Stream {
dict: stream_dict,
data: bytes::Bytes::from(xmp_bytes),
};
let xmp_id = editor.alloc_id();
editor.insert_modified(xmp_id, xmp_stream);
let mut catalog = load_catalog_for_edit(editor)?;
catalog.insert("Metadata".to_string(), Object::Reference(ObjectRef::new(xmp_id, 0)));
let catalog_id = catalog_object_id(editor)?;
editor.insert_modified(catalog_id, Object::Dictionary(catalog));
result.add_action(
ConversionAction::new(ActionType::AddedXmpMetadata, "Added XMP metadata stream")
.with_fixed_error(ErrorCode::MissingXmpMetadata),
);
Ok(())
}
fn add_pdfa_identification(
&self,
editor: &mut DocumentEditor,
result: &mut ConversionResult,
) -> Result<()> {
let catalog = load_catalog_for_edit(editor)?;
let metadata_ref = match catalog.get("Metadata").and_then(|o| o.as_reference()) {
Some(r) => r,
None => return self.add_xmp_metadata(editor, result),
};
let current = editor
.get_modified(metadata_ref.id)
.cloned()
.or_else(|| editor.source().load_object(metadata_ref).ok());
let current_xml = match ¤t {
Some(obj @ Object::Stream { .. }) => {
let decoded = obj.decode_stream_data().unwrap_or_else(|_| {
if let Object::Stream { data, .. } = obj {
data.to_vec()
} else {
vec![]
}
});
String::from_utf8_lossy(&decoded).into_owned()
},
Some(_) | None => return self.add_xmp_metadata(editor, result),
};
let mut stream_dict = match current {
Some(Object::Stream { dict, .. }) => dict,
_ => unreachable!(),
};
let patched =
inject_pdfaid(¤t_xml, self.level.xmp_part(), self.level.xmp_conformance());
if patched == current_xml {
return self.add_xmp_metadata(editor, result);
}
let patched_bytes = patched.into_bytes();
stream_dict.insert("Length".to_string(), Object::Integer(patched_bytes.len() as i64));
stream_dict.remove("Filter");
stream_dict.remove("DecodeParms");
editor.insert_modified(
metadata_ref.id,
Object::Stream {
dict: stream_dict,
data: bytes::Bytes::from(patched_bytes),
},
);
result.add_action(
ConversionAction::new(
ActionType::AddedPdfaIdentification,
format!(
"Added PDF/A-{}{} identification",
self.level.xmp_part(),
self.level.xmp_conformance().to_lowercase()
),
)
.with_fixed_error(ErrorCode::MissingPdfaIdentification),
);
Ok(())
}
#[cfg(feature = "rendering")]
fn embed_font(
&self,
editor: &mut DocumentEditor,
error: &ComplianceError,
result: &mut ConversionResult,
) -> Result<()> {
let font_name = error.location.as_deref().unwrap_or("Unknown").to_string();
let font_objects = collect_font_objects_by_name(editor.source_mut(), &font_name)?;
if font_objects.is_empty() {
result.add_error(ConversionError::new(
ErrorCode::FontNotEmbedded,
format!("Font '{}' not found in document resources", font_name),
));
return Ok(());
}
let font_bytes = match load_system_font_bytes(&font_name) {
Some(b) => b,
None => {
result.add_error(ConversionError::new(
ErrorCode::FontNotEmbedded,
format!(
"Font '{}' not found in system fonts; install the font to embed it",
font_name
),
));
return Ok(());
},
};
let mut ff_dict = std::collections::HashMap::new();
ff_dict.insert("Length".to_string(), Object::Integer(font_bytes.len() as i64));
let ff_id = editor.alloc_id();
editor.insert_modified(
ff_id,
Object::Stream {
dict: ff_dict,
data: bytes::Bytes::from(font_bytes),
},
);
for (font_id, mut font_dict) in font_objects {
let desc_id = match font_dict
.get("FontDescriptor")
.and_then(|o| o.as_reference())
{
Some(r) => {
if let Ok(existing) = editor.source().load_object(r) {
if let Some(mut d) = existing.as_dict().cloned() {
d.insert(
"FontFile2".to_string(),
Object::Reference(ObjectRef::new(ff_id, 0)),
);
editor.insert_modified(r.id, Object::Dictionary(d));
r.id
} else {
build_font_descriptor(editor, &font_dict, ff_id)
}
} else {
build_font_descriptor(editor, &font_dict, ff_id)
}
},
None => build_font_descriptor(editor, &font_dict, ff_id),
};
font_dict.insert(
"FontDescriptor".to_string(),
Object::Reference(ObjectRef::new(desc_id, 0)),
);
editor.insert_modified(font_id, Object::Dictionary(font_dict));
}
result.add_action(
ConversionAction::new(
ActionType::EmbeddedFont,
format!("Embedded system font '{}' as FontFile2 stream", font_name),
)
.with_fixed_error(ErrorCode::FontNotEmbedded),
);
Ok(())
}
#[cfg(not(feature = "rendering"))]
fn embed_font(
&self,
_editor: &mut DocumentEditor,
error: &ComplianceError,
result: &mut ConversionResult,
) -> Result<()> {
let font_name = error.location.as_deref().unwrap_or("Unknown");
result.add_error(ConversionError::new(
ErrorCode::FontNotEmbedded,
format!(
"Font '{}' cannot be embedded without the `rendering` feature \
(rebuild with --features rendering)",
font_name
),
));
Ok(())
}
fn add_output_intent(
&self,
editor: &mut DocumentEditor,
result: &mut ConversionResult,
) -> Result<()> {
let mut catalog = load_catalog_for_edit(editor)?;
if catalog.contains_key("OutputIntents") {
return Ok(());
}
let icc_bytes: Vec<u8> = self
.config
.icc_profile
.clone()
.unwrap_or_else(|| Self::get_srgb_icc_profile().to_vec());
let mut icc_dict = std::collections::HashMap::new();
icc_dict.insert("N".to_string(), Object::Integer(icc_channel_count(&icc_bytes) as i64));
icc_dict.insert("Length".to_string(), Object::Integer(icc_bytes.len() as i64));
let icc_stream = Object::Stream {
dict: icc_dict,
data: bytes::Bytes::from(icc_bytes),
};
let icc_id = editor.alloc_id();
editor.insert_modified(icc_id, icc_stream);
let mut oi = std::collections::HashMap::new();
oi.insert("Type".to_string(), Object::Name("OutputIntent".to_string()));
oi.insert("S".to_string(), Object::Name("GTS_PDFA1".to_string()));
oi.insert(
"OutputConditionIdentifier".to_string(),
Object::text_string("sRGB IEC61966-2.1"),
);
oi.insert("RegistryName".to_string(), Object::text_string("http://www.color.org"));
oi.insert("Info".to_string(), Object::text_string("sRGB IEC61966-2.1"));
oi.insert("DestOutputProfile".to_string(), Object::Reference(ObjectRef::new(icc_id, 0)));
catalog.insert("OutputIntents".to_string(), Object::Array(vec![Object::Dictionary(oi)]));
let cat_id = catalog_object_id(editor)?;
editor.insert_modified(cat_id, Object::Dictionary(catalog));
result.add_action(
ConversionAction::new(
ActionType::AddedOutputIntent,
"Added sRGB OutputIntent (GTS_PDFA1) with embedded ICC profile",
)
.with_fixed_error(ErrorCode::MissingOutputIntent),
);
Ok(())
}
fn remove_javascript(
&self,
editor: &mut DocumentEditor,
result: &mut ConversionResult,
) -> Result<()> {
let mut touched = false;
if let Some((mut names, names_id)) = load_names_for_edit(editor)? {
if names.remove("JavaScript").is_some() {
store_names(editor, names, names_id)?;
touched = true;
}
}
let mut catalog = load_catalog_for_edit(editor)?;
let mut catalog_changed = false;
if action_is_javascript(catalog.get("OpenAction"), editor) {
catalog.remove("OpenAction");
catalog_changed = true;
}
if let Some(aa_obj) = catalog.get("AA").cloned() {
if let Some(cleaned) = strip_js_from_aa(&aa_obj, editor) {
if cleaned.is_empty() {
catalog.remove("AA");
} else {
catalog.insert("AA".to_string(), Object::Dictionary(cleaned));
}
catalog_changed = true;
}
}
if catalog_changed {
let cat_id = catalog_object_id(editor)?;
editor.insert_modified(cat_id, Object::Dictionary(catalog));
touched = true;
}
let page_refs = editor.source().all_page_refs().unwrap_or_default();
for page_ref in page_refs {
let page_obj = match editor.source().load_object(page_ref) {
Ok(o) => o,
Err(_) => continue,
};
if let Object::Dictionary(mut page_dict) = page_obj {
if let Some(aa) = page_dict.get("AA").cloned() {
if let Some(cleaned) = strip_js_from_aa(&aa, editor) {
if cleaned.is_empty() {
page_dict.remove("AA");
} else {
page_dict.insert("AA".to_string(), Object::Dictionary(cleaned));
}
editor.insert_modified(page_ref.id, Object::Dictionary(page_dict));
touched = true;
}
}
}
}
if touched {
result.add_action(
ConversionAction::new(
ActionType::RemovedJavaScript,
"Removed JavaScript from /Names, /OpenAction, and /AA entries",
)
.with_fixed_error(ErrorCode::JavaScriptNotAllowed),
);
}
Ok(())
}
fn remove_encryption(
&self,
editor: &mut DocumentEditor,
result: &mut ConversionResult,
) -> Result<()> {
if editor.source_mut().get_page_content_data(0).is_err() {
result.add_error(ConversionError::new(
ErrorCode::EncryptionNotAllowed,
"Document content is inaccessible; cannot strip encryption safely",
));
return Ok(());
}
result.add_action(
ConversionAction::new(
ActionType::RemovedEncryption,
"Encryption marker will be absent from the saved output",
)
.with_fixed_error(ErrorCode::EncryptionNotAllowed),
);
Ok(())
}
#[cfg(feature = "rendering")]
fn flatten_transparency(
&self,
editor: &mut DocumentEditor,
result: &mut ConversionResult,
) -> Result<()> {
let flat_bytes = crate::rendering::flatten_to_images(editor.source(), 144)?;
editor.replace_source_bytes(flat_bytes)?;
result.add_action(
ConversionAction::new(
ActionType::FlattenedTransparency,
"Re-rendered all pages at 144 dpi to eliminate transparency",
)
.with_fixed_error(ErrorCode::TransparencyNotAllowed),
);
Ok(())
}
#[cfg(not(feature = "rendering"))]
fn flatten_transparency(
&self,
_editor: &mut DocumentEditor,
result: &mut ConversionResult,
) -> Result<()> {
result.add_error(ConversionError::new(
ErrorCode::TransparencyNotAllowed,
"Transparency flattening requires the `rendering` feature; use PDF/A-2b or -3b \
(which allow transparency) or rebuild with --features rendering",
));
Ok(())
}
fn remove_embedded_files(
&self,
editor: &mut DocumentEditor,
result: &mut ConversionResult,
) -> Result<()> {
let removed = if let Some((mut names, names_id)) = load_names_for_edit(editor)? {
if names.remove("EmbeddedFiles").is_some() {
store_names(editor, names, names_id)?;
true
} else {
false
}
} else {
false
};
if removed {
result.add_action(
ConversionAction::new(
ActionType::RemovedEmbeddedFiles,
"Removed /EmbeddedFiles from /Names",
)
.with_fixed_error(ErrorCode::EmbeddedFileNotAllowed),
);
}
Ok(())
}
fn add_structure(
&self,
editor: &mut DocumentEditor,
result: &mut ConversionResult,
) -> Result<()> {
let mut catalog = load_catalog_for_edit(editor)?;
let has_struct = catalog.contains_key("StructTreeRoot");
let has_markinfo = catalog
.get("MarkInfo")
.and_then(|o| o.as_dict())
.map(|d| matches!(d.get("Marked"), Some(Object::Boolean(true))))
.unwrap_or(false);
if has_struct && has_markinfo {
return Ok(());
}
if !has_markinfo {
let mut mark = std::collections::HashMap::new();
mark.insert("Marked".to_string(), Object::Boolean(true));
catalog.insert("MarkInfo".to_string(), Object::Dictionary(mark));
}
if !has_struct {
let mut root_dict = std::collections::HashMap::new();
root_dict.insert("Type".to_string(), Object::Name("StructTreeRoot".to_string()));
root_dict.insert("K".to_string(), Object::Array(vec![]));
let root_id = editor.alloc_id();
editor.insert_modified(root_id, Object::Dictionary(root_dict));
catalog.insert(
"StructTreeRoot".to_string(),
Object::Reference(ObjectRef::new(root_id, 0)),
);
}
let cat_id = catalog_object_id(editor)?;
editor.insert_modified(cat_id, Object::Dictionary(catalog));
result.add_action(
ConversionAction::new(
ActionType::AddedStructure,
"Added /MarkInfo /Marked true and minimal /StructTreeRoot for PDF/A-*a compliance",
)
.with_fixed_error(ErrorCode::MissingDocumentStructure),
);
Ok(())
}
fn add_language(
&self,
editor: &mut DocumentEditor,
result: &mut ConversionResult,
) -> Result<()> {
let mut catalog = load_catalog_for_edit(editor)?;
if !catalog.contains_key("Lang") {
catalog.insert("Lang".to_string(), Object::text_string("en"));
let cat_id = catalog_object_id(editor)?;
editor.insert_modified(cat_id, Object::Dictionary(catalog));
}
result.add_action(
ConversionAction::new(ActionType::AddedLanguage, "Set catalog /Lang to 'en'")
.with_fixed_error(ErrorCode::MissingLanguage),
);
Ok(())
}
#[cfg(feature = "rendering")]
fn fix_annotation_appearance(
&self,
editor: &mut DocumentEditor,
_error: &ComplianceError,
result: &mut ConversionResult,
) -> Result<()> {
use crate::rendering::{render_page_region, RenderOptions};
let page_count = editor.source_mut().page_count()?;
let mut fixed = 0usize;
for page_idx in 0..page_count {
let page_ref = match editor.source().get_page_ref(page_idx) {
Ok(r) => r,
Err(_) => continue,
};
let page_obj = match editor.source().load_object(page_ref) {
Ok(o) => o,
Err(_) => continue,
};
let annots_arr = match page_obj.as_dict().and_then(|d| d.get("Annots")).cloned() {
Some(a) => match editor.source().resolve_references(&a, 1) {
Ok(o) => o,
Err(_) => continue,
},
None => continue,
};
let annots = match annots_arr.as_array() {
Some(a) => a.clone(),
None => continue,
};
for annot_ref_obj in annots {
let annot_ref = match annot_ref_obj.as_reference() {
Some(r) => r,
None => continue,
};
let annot_obj = match editor.source().load_object(annot_ref) {
Ok(o) => o,
Err(_) => continue,
};
let annot_dict = match annot_obj.as_dict() {
Some(d) => d.clone(),
None => continue,
};
if let Some(ap) = annot_dict.get("AP") {
if let Ok(ap_resolved) = editor.source().resolve_references(ap, 1) {
if ap_resolved.as_dict().and_then(|d| d.get("N")).is_some() {
continue;
}
}
}
let rect = match annot_dict.get("Rect").and_then(|r| r.as_array()) {
Some(arr) if arr.len() == 4 => {
let nums: Vec<f32> = arr
.iter()
.filter_map(|o| {
o.as_real()
.map(|r| r as f32)
.or_else(|| o.as_integer().map(|i| i as f32))
})
.collect();
if nums.len() == 4 {
(nums[0], nums[1], nums[2] - nums[0], nums[3] - nums[1])
} else {
continue;
}
},
_ => continue,
};
let (x, y, w, h) = rect;
if w <= 0.0 || h <= 0.0 {
continue;
}
let opts = RenderOptions::with_dpi(144);
let rendered =
match render_page_region(editor.source(), page_idx, (x, y, w, h), &opts) {
Ok(r) => r,
Err(_) => continue,
};
let img = match image::load_from_memory(&rendered.data) {
Ok(i) => i.to_rgb8(),
Err(_) => continue,
};
let img_w = img.width();
let img_h = img.height();
let raw_rgb: Vec<u8> = img.into_raw();
let mut img_dict = std::collections::HashMap::new();
img_dict.insert("Type".to_string(), Object::Name("XObject".to_string()));
img_dict.insert("Subtype".to_string(), Object::Name("Image".to_string()));
img_dict.insert("Width".to_string(), Object::Integer(img_w as i64));
img_dict.insert("Height".to_string(), Object::Integer(img_h as i64));
img_dict.insert("ColorSpace".to_string(), Object::Name("DeviceRGB".to_string()));
img_dict.insert("BitsPerComponent".to_string(), Object::Integer(8));
img_dict.insert("Length".to_string(), Object::Integer(raw_rgb.len() as i64));
let img_id = editor.alloc_id();
editor.insert_modified(
img_id,
Object::Stream {
dict: img_dict,
data: bytes::Bytes::from(raw_rgb),
},
);
let content = format!("q {} 0 0 {} 0 0 cm /Im Do Q", img_w, img_h);
let mut res_dict = std::collections::HashMap::new();
let mut xobj_dict = std::collections::HashMap::new();
xobj_dict.insert("Im".to_string(), Object::Reference(ObjectRef::new(img_id, 0)));
res_dict.insert("XObject".to_string(), Object::Dictionary(xobj_dict));
let mut form_dict = std::collections::HashMap::new();
form_dict.insert("Type".to_string(), Object::Name("XObject".to_string()));
form_dict.insert("Subtype".to_string(), Object::Name("Form".to_string()));
form_dict.insert(
"BBox".to_string(),
Object::Array(vec![
Object::Integer(0),
Object::Integer(0),
Object::Integer(img_w as i64),
Object::Integer(img_h as i64),
]),
);
form_dict.insert("Resources".to_string(), Object::Dictionary(res_dict));
form_dict.insert("Length".to_string(), Object::Integer(content.len() as i64));
let form_id = editor.alloc_id();
editor.insert_modified(
form_id,
Object::Stream {
dict: form_dict,
data: bytes::Bytes::from(content.into_bytes()),
},
);
let mut ap_dict = std::collections::HashMap::new();
ap_dict.insert("N".to_string(), Object::Reference(ObjectRef::new(form_id, 0)));
let mut updated_annot = annot_dict;
updated_annot.insert("AP".to_string(), Object::Dictionary(ap_dict));
editor.insert_modified(annot_ref.id, Object::Dictionary(updated_annot));
fixed += 1;
}
}
if fixed > 0 {
result.add_action(
ConversionAction::new(
ActionType::FixedAnnotation,
format!("Generated raster /AP N streams for {} annotation(s)", fixed),
)
.with_fixed_error(ErrorCode::MissingAppearanceStream),
);
}
Ok(())
}
#[cfg(not(feature = "rendering"))]
fn fix_annotation_appearance(
&self,
_editor: &mut DocumentEditor,
error: &ComplianceError,
result: &mut ConversionResult,
) -> Result<()> {
let location = error.location.as_deref().unwrap_or("annotation");
result.add_error(ConversionError::new(
ErrorCode::MissingAppearanceStream,
format!(
"Cannot generate appearance stream for {} — rebuild with \
--features rendering to enable annotation appearance synthesis",
location
),
));
Ok(())
}
fn generate_xmp_metadata(&self) -> String {
format!(
r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:pdf="http://ns.adobe.com/pdf/1.3/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
<pdfaid:part>{}</pdfaid:part>
<pdfaid:conformance>{}</pdfaid:conformance>
<dc:format>application/pdf</dc:format>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#,
self.level.xmp_part(),
self.level.xmp_conformance()
)
}
pub fn get_srgb_icc_profile() -> &'static [u8] {
include_bytes!("srgb_profile_placeholder.bin")
}
}
fn catalog_object_id(editor: &DocumentEditor) -> Result<u32> {
use crate::error::Error;
editor
.source()
.trailer()
.as_dict()
.and_then(|d| d.get("Root"))
.and_then(|r| r.as_reference())
.map(|r| r.id)
.ok_or_else(|| Error::InvalidPdf("Trailer missing /Root reference".to_string()))
}
fn load_catalog_for_edit(
editor: &mut DocumentEditor,
) -> Result<std::collections::HashMap<String, Object>> {
use crate::error::Error;
let catalog_id = catalog_object_id(editor)?;
if let Some(staged) = editor.get_modified(catalog_id) {
if let Some(d) = staged.as_dict() {
return Ok(d.clone());
}
}
editor
.source()
.load_object(ObjectRef::new(catalog_id, 0))?
.as_dict()
.cloned()
.ok_or_else(|| Error::InvalidPdf("Catalog is not a dictionary".to_string()))
}
fn load_names_for_edit(
editor: &mut DocumentEditor,
) -> Result<Option<(std::collections::HashMap<String, Object>, u32)>> {
let catalog = load_catalog_for_edit(editor)?;
let names_obj = match catalog.get("Names") {
Some(o) => o.clone(),
None => return Ok(None),
};
match names_obj {
Object::Reference(r) => {
let id = r.id;
if let Some(staged) = editor.get_modified(id) {
if let Some(d) = staged.as_dict() {
return Ok(Some((d.clone(), id)));
}
}
match editor.source().load_object(r) {
Ok(Object::Dictionary(d)) => Ok(Some((d, id))),
_ => Ok(None),
}
},
Object::Dictionary(d) => Ok(Some((d, 0))),
_ => Ok(None),
}
}
fn store_names(
editor: &mut DocumentEditor,
names: std::collections::HashMap<String, Object>,
names_id: u32,
) -> Result<()> {
if names_id == 0 {
let mut catalog = load_catalog_for_edit(editor)?;
if names.is_empty() {
catalog.remove("Names");
} else {
catalog.insert("Names".to_string(), Object::Dictionary(names));
}
let cat_id = catalog_object_id(editor)?;
editor.insert_modified(cat_id, Object::Dictionary(catalog));
} else if names.is_empty() {
let mut catalog = load_catalog_for_edit(editor)?;
catalog.remove("Names");
let cat_id = catalog_object_id(editor)?;
editor.insert_modified(cat_id, Object::Dictionary(catalog));
} else {
editor.insert_modified(names_id, Object::Dictionary(names));
}
Ok(())
}
fn action_is_javascript(obj: Option<&Object>, editor: &DocumentEditor) -> bool {
let Some(action) = obj else { return false };
let resolved = match action {
Object::Reference(r) => editor.source().load_object(*r).unwrap_or(Object::Null),
other => other.clone(),
};
matches!(
resolved
.as_dict()
.and_then(|d| d.get("S"))
.and_then(|o| o.as_name()),
Some("JavaScript")
)
}
fn strip_js_from_aa(
aa: &Object,
editor: &DocumentEditor,
) -> Option<std::collections::HashMap<String, Object>> {
let aa_dict = match aa {
Object::Reference(r) => editor.source().load_object(*r).ok()?.as_dict()?.clone(),
Object::Dictionary(d) => d.clone(),
_ => return None,
};
let before = aa_dict.len();
let mut out = aa_dict;
out.retain(|_, action_obj| {
let resolved = match action_obj {
Object::Reference(r) => editor.source().load_object(*r).unwrap_or(Object::Null),
other => other.clone(),
};
!matches!(
resolved
.as_dict()
.and_then(|d| d.get("S"))
.and_then(|o| o.as_name()),
Some("JavaScript")
)
});
if out.len() != before {
Some(out)
} else {
None
}
}
#[cfg(feature = "rendering")]
fn collect_font_objects_by_name(
doc: &mut PdfDocument,
target: &str,
) -> Result<Vec<(u32, std::collections::HashMap<String, Object>)>> {
let page_count = doc.page_count()?;
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
let mut found: Vec<(u32, std::collections::HashMap<String, Object>)> = Vec::new();
for idx in 0..page_count {
let page_ref = match doc.get_page_ref(idx) {
Ok(r) => r,
Err(_) => continue,
};
let page_obj = match doc.load_object(page_ref) {
Ok(o) => o,
Err(_) => continue,
};
let resources = match page_obj.as_dict().and_then(|d| d.get("Resources")).cloned() {
Some(r) => match doc.resolve_references(&r, 2) {
Ok(o) => o,
Err(_) => continue,
},
None => continue,
};
let font_map = match resources.as_dict().and_then(|d| d.get("Font")).cloned() {
Some(f) => match doc.resolve_references(&f, 1) {
Ok(o) => o,
Err(_) => continue,
},
None => continue,
};
let fonts = match font_map.as_dict() {
Some(d) => d.clone(),
None => continue,
};
for font_obj in fonts.values() {
let font_ref = match font_obj.as_reference() {
Some(r) => r,
None => continue,
};
if !seen.insert(font_ref.id) {
continue;
}
let resolved = match doc.load_object(font_ref) {
Ok(o) => o,
Err(_) => continue,
};
let fd = match resolved.as_dict() {
Some(d) => d.clone(),
None => continue,
};
let base_font = fd.get("BaseFont").and_then(|o| o.as_name()).unwrap_or("");
if base_font == target {
found.push((font_ref.id, fd));
}
}
}
Ok(found)
}
#[cfg(feature = "rendering")]
fn std14_alias(ps_name: &str) -> Option<(&'static str, fontdb::Weight, fontdb::Style)> {
match ps_name {
"Helvetica" => Some(("Nimbus Sans", fontdb::Weight::NORMAL, fontdb::Style::Normal)),
"Helvetica-Bold" => Some(("Nimbus Sans", fontdb::Weight::BOLD, fontdb::Style::Normal)),
"Helvetica-Oblique" => Some(("Nimbus Sans", fontdb::Weight::NORMAL, fontdb::Style::Italic)),
"Helvetica-BoldOblique" => {
Some(("Nimbus Sans", fontdb::Weight::BOLD, fontdb::Style::Italic))
},
"Times-Roman" => Some(("Nimbus Roman", fontdb::Weight::NORMAL, fontdb::Style::Normal)),
"Times-Bold" => Some(("Nimbus Roman", fontdb::Weight::BOLD, fontdb::Style::Normal)),
"Times-Italic" => Some(("Nimbus Roman", fontdb::Weight::NORMAL, fontdb::Style::Italic)),
"Times-BoldItalic" => Some(("Nimbus Roman", fontdb::Weight::BOLD, fontdb::Style::Italic)),
"Courier" => Some(("Nimbus Mono PS", fontdb::Weight::NORMAL, fontdb::Style::Normal)),
"Courier-Bold" => Some(("Nimbus Mono PS", fontdb::Weight::BOLD, fontdb::Style::Normal)),
"Courier-Oblique" => {
Some(("Nimbus Mono PS", fontdb::Weight::NORMAL, fontdb::Style::Italic))
},
"Courier-BoldOblique" => {
Some(("Nimbus Mono PS", fontdb::Weight::BOLD, fontdb::Style::Italic))
},
_ => None,
}
}
fn icc_channel_count(icc: &[u8]) -> i32 {
if icc.len() < 20 {
return 3;
}
match &icc[16..20] {
b"XYZ " | b"Lab " | b"RGB " | b"Luv " | b"YCbr" | b"Yxy " => 3,
b"CMYK" => 4,
b"GRAY" => 1,
b"HSV " | b"HLS " => 3,
b"CMY " => 3,
b"2CLR" => 2,
b"3CLR" => 3,
b"4CLR" => 4,
b"5CLR" => 5,
b"6CLR" => 6,
b"7CLR" => 7,
b"8CLR" => 8,
_ => 3,
}
}
#[cfg(feature = "rendering")]
static CONVERTER_FONTDB: std::sync::OnceLock<std::sync::Arc<fontdb::Database>> =
std::sync::OnceLock::new();
#[cfg(feature = "rendering")]
fn converter_fontdb() -> std::sync::Arc<fontdb::Database> {
CONVERTER_FONTDB
.get_or_init(|| {
let mut db = fontdb::Database::new();
db.load_system_fonts();
std::sync::Arc::new(db)
})
.clone()
}
#[cfg(feature = "rendering")]
fn load_system_font_bytes(font_name: &str) -> Option<Vec<u8>> {
let db = converter_fontdb();
let clean = {
let s = font_name.trim_start_matches(|c: char| c.is_ascii_uppercase());
s.strip_prefix('+').unwrap_or(font_name)
};
let weight = if clean.contains("Bold") {
fontdb::Weight::BOLD
} else {
fontdb::Weight::NORMAL
};
let style = if clean.contains("Italic") || clean.contains("Oblique") {
fontdb::Style::Italic
} else {
fontdb::Style::Normal
};
let mut candidates: Vec<(&str, fontdb::Weight, fontdb::Style)> = Vec::new();
if let Some(alias) = std14_alias(clean) {
candidates.push(alias);
let lib_family = match alias.0 {
"Nimbus Sans" => Some("Liberation Sans"),
"Nimbus Roman" => Some("Liberation Serif"),
"Nimbus Mono PS" => Some("Liberation Mono"),
_ => None,
};
if let Some(lf) = lib_family {
candidates.push((lf, alias.1, alias.2));
}
}
candidates.push((clean, weight, style));
let base_family = clean.split('-').next().unwrap_or(clean);
if base_family != clean {
candidates.push((base_family, weight, style));
}
for (family, w, s) in candidates {
let query = fontdb::Query {
families: &[fontdb::Family::Name(family)],
weight: w,
style: s,
stretch: fontdb::Stretch::Normal,
};
if let Some(id) = db.query(&query) {
let mut result: Option<Vec<u8>> = None;
db.with_face_data(id, |data, _index| {
result = Some(data.to_vec());
});
if result.is_some() {
return result;
}
}
}
None
}
#[cfg(feature = "rendering")]
fn build_font_descriptor(
editor: &mut DocumentEditor,
font_dict: &std::collections::HashMap<String, Object>,
ff_id: u32,
) -> u32 {
let base_font = font_dict
.get("BaseFont")
.and_then(|o| o.as_name())
.unwrap_or("Unknown")
.to_string();
let mut d = std::collections::HashMap::new();
d.insert("Type".to_string(), Object::Name("FontDescriptor".to_string()));
d.insert("FontName".to_string(), Object::Name(base_font));
d.insert("Flags".to_string(), Object::Integer(32)); d.insert("ItalicAngle".to_string(), Object::Integer(0));
d.insert("Ascent".to_string(), Object::Integer(800));
d.insert("Descent".to_string(), Object::Integer(-200));
d.insert("CapHeight".to_string(), Object::Integer(700));
d.insert("StemV".to_string(), Object::Integer(80));
d.insert(
"FontBBox".to_string(),
Object::Array(vec![
Object::Integer(-100),
Object::Integer(-200),
Object::Integer(1000),
Object::Integer(800),
]),
);
d.insert("FontFile2".to_string(), Object::Reference(ObjectRef::new(ff_id, 0)));
let id = editor.alloc_id();
editor.insert_modified(id, Object::Dictionary(d));
id
}
fn inject_pdfaid(xml: &str, part: &str, conformance: &str) -> String {
let block = format!(
" <rdf:Description rdf:about=\"\" \
xmlns:pdfaid=\"http://www.aiim.org/pdfa/ns/id/\">\n\
<pdfaid:part>{part}</pdfaid:part>\n\
<pdfaid:conformance>{conformance}</pdfaid:conformance>\n\
</rdf:Description>\n"
);
if let Some(idx) = xml.rfind("</rdf:RDF>") {
let mut out = String::with_capacity(xml.len() + block.len());
out.push_str(&xml[..idx]);
out.push_str(&block);
out.push_str(&xml[idx..]);
out
} else {
xml.to_string()
}
}
pub fn convert_to_pdf_a(document: &mut PdfDocument, level: PdfALevel) -> Result<ConversionResult> {
PdfAConverter::new(level).convert(document)
}
const KNOWN_UNEMBEDDABLE_FONTS: &[&str] = &["Symbol", "ZapfDingbats"];
fn downgrade_known_unembeddable_fonts(validation: &mut crate::compliance::types::ValidationResult) {
use crate::compliance::types::{ComplianceWarning, ErrorCode, WarningCode};
let (downgraded, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut validation.errors)
.into_iter()
.partition(|e| {
e.code == ErrorCode::FontNotEmbedded
&& e.location
.as_deref()
.is_some_and(is_known_unembeddable_font)
});
validation.errors = kept;
for err in downgraded {
let mut warning = ComplianceWarning::new(
WarningCode::KnownUnembeddableFont,
format!(
"{} (downgraded from error per #451 — no open-source equivalent available)",
err.message
),
);
if let Some(loc) = err.location {
warning = warning.with_location(loc);
}
validation.warnings.push(warning);
}
if validation.errors.is_empty() {
validation.is_compliant = true;
}
}
fn is_known_unembeddable_font(name: &str) -> bool {
KNOWN_UNEMBEDDABLE_FONTS
.iter()
.any(|&base| name == base || name.split('+').next_back() == Some(base))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_conversion_config_default() {
let config = ConversionConfig::default();
assert!(config.embed_fonts);
assert!(config.remove_javascript);
assert!(config.remove_encryption);
assert!(config.flatten_transparency);
}
#[test]
fn downgrade_symbol_font_to_warning() {
use crate::compliance::types::{ComplianceError, ErrorCode, ValidationResult, WarningCode};
let mut v = ValidationResult::new(PdfALevel::A1b);
v.errors.push(
ComplianceError::new(ErrorCode::FontNotEmbedded, "font 'Symbol' not embedded")
.with_location("Symbol"),
);
v.is_compliant = false;
downgrade_known_unembeddable_fonts(&mut v);
assert!(v.errors.is_empty(), "Symbol error should have been moved");
assert_eq!(v.warnings.len(), 1, "exactly one warning should be emitted");
assert_eq!(v.warnings[0].code, WarningCode::KnownUnembeddableFont);
assert!(
v.is_compliant,
"validation should flip to compliant once the only remaining error was downgraded"
);
}
#[test]
fn downgrade_does_not_mask_other_font_errors() {
use crate::compliance::types::{ComplianceError, ErrorCode, ValidationResult};
let mut v = ValidationResult::new(PdfALevel::A1b);
v.errors.push(
ComplianceError::new(ErrorCode::FontNotEmbedded, "font 'Helvetica' not embedded")
.with_location("Helvetica"),
);
v.is_compliant = false;
downgrade_known_unembeddable_fonts(&mut v);
assert_eq!(v.errors.len(), 1, "non-Symbol error should stay");
assert!(v.warnings.is_empty(), "no warning should be emitted");
assert!(!v.is_compliant, "still non-compliant");
}
#[test]
fn downgrade_handles_zapf_dingbats() {
use crate::compliance::types::{ComplianceError, ErrorCode, ValidationResult};
let mut v = ValidationResult::new(PdfALevel::A1b);
v.errors.push(
ComplianceError::new(ErrorCode::FontNotEmbedded, "font 'ZapfDingbats' not embedded")
.with_location("ZapfDingbats"),
);
v.is_compliant = false;
downgrade_known_unembeddable_fonts(&mut v);
assert!(v.errors.is_empty());
assert_eq!(v.warnings.len(), 1);
assert!(v.is_compliant);
}
#[test]
fn downgrade_accepts_subset_prefix() {
use crate::compliance::types::{ComplianceError, ErrorCode, ValidationResult};
let mut v = ValidationResult::new(PdfALevel::A1b);
v.errors.push(
ComplianceError::new(ErrorCode::FontNotEmbedded, "font 'ABCDEF+Symbol' not embedded")
.with_location("ABCDEF+Symbol"),
);
v.is_compliant = false;
downgrade_known_unembeddable_fonts(&mut v);
assert!(v.errors.is_empty(), "subset-prefixed Symbol must downgrade");
assert_eq!(v.warnings.len(), 1);
assert!(v.is_compliant);
}
#[test]
fn downgrade_rejects_substring_match() {
use crate::compliance::types::{ComplianceError, ErrorCode, ValidationResult};
let mut v = ValidationResult::new(PdfALevel::A1b);
v.errors.push(
ComplianceError::new(ErrorCode::FontNotEmbedded, "font 'MySymbolFont' not embedded")
.with_location("MySymbolFont"),
);
v.errors.push(
ComplianceError::new(ErrorCode::FontNotEmbedded, "font 'ZapfDingbatsITC' not embedded")
.with_location("ZapfDingbatsITC"),
);
v.is_compliant = false;
downgrade_known_unembeddable_fonts(&mut v);
assert_eq!(v.errors.len(), 2, "neither bogus name should be downgraded");
assert!(v.warnings.is_empty());
assert!(!v.is_compliant);
}
#[test]
fn downgrade_propagates_location_to_warning() {
use crate::compliance::types::{ComplianceError, ErrorCode, ValidationResult};
let mut v = ValidationResult::new(PdfALevel::A1b);
v.errors.push(
ComplianceError::new(ErrorCode::FontNotEmbedded, "font 'Symbol' not embedded")
.with_location("ABCDEF+Symbol"),
);
downgrade_known_unembeddable_fonts(&mut v);
assert_eq!(v.warnings.len(), 1);
assert_eq!(v.warnings[0].location.as_deref(), Some("ABCDEF+Symbol"));
}
#[test]
fn test_conversion_config_builder() {
let config = ConversionConfig::new()
.embed_fonts(false)
.remove_javascript(false)
.flatten_transparency(false);
assert!(!config.embed_fonts);
assert!(!config.remove_javascript);
assert!(!config.flatten_transparency);
}
#[test]
fn test_converter_creation() {
let converter = PdfAConverter::new(PdfALevel::A2b);
assert_eq!(converter.level(), PdfALevel::A2b);
}
#[test]
fn test_conversion_result() {
let mut result = ConversionResult::new(PdfALevel::A2b);
assert!(!result.success);
assert_eq!(result.level, PdfALevel::A2b);
assert!(result.actions.is_empty());
assert!(result.errors.is_empty());
result.add_action(ConversionAction::new(ActionType::AddedXmpMetadata, "Test action"));
assert_eq!(result.actions.len(), 1);
result.add_error(ConversionError::new(ErrorCode::FontNotEmbedded, "Test error"));
assert_eq!(result.errors.len(), 1);
}
#[test]
fn test_xmp_generation() {
let converter = PdfAConverter::new(PdfALevel::A2b);
let xmp = converter.generate_xmp_metadata();
assert!(xmp.contains("<pdfaid:part>2</pdfaid:part>"));
assert!(xmp.contains("<pdfaid:conformance>B</pdfaid:conformance>"));
}
#[test]
fn test_action_type() {
let action = ConversionAction::new(ActionType::AddedXmpMetadata, "Added metadata")
.with_fixed_error(ErrorCode::MissingXmpMetadata);
assert_eq!(action.action_type, ActionType::AddedXmpMetadata);
assert_eq!(action.fixed_error, Some(ErrorCode::MissingXmpMetadata));
}
#[test]
fn test_conversion_config_remove_encryption() {
let config = ConversionConfig::default();
assert!(config.remove_encryption);
assert!(config.remove_embedded_files);
assert!(!config.add_structure);
assert!(config.icc_profile.is_none());
}
#[test]
fn test_conversion_config_add_structure() {
let config = ConversionConfig::new().add_structure(true);
assert!(config.add_structure);
}
#[test]
fn test_conversion_config_with_icc_profile() {
let profile = vec![1, 2, 3, 4];
let config = ConversionConfig::new().with_icc_profile(profile.clone());
assert_eq!(config.icc_profile.unwrap(), profile);
}
#[test]
fn test_converter_with_config() {
let config = ConversionConfig::new().embed_fonts(false);
let converter = PdfAConverter::new(PdfALevel::A1b).with_config(config);
assert_eq!(converter.level(), PdfALevel::A1b);
}
#[test]
fn test_converter_levels() {
for level in [
PdfALevel::A1a,
PdfALevel::A1b,
PdfALevel::A2a,
PdfALevel::A2b,
PdfALevel::A2u,
PdfALevel::A3a,
PdfALevel::A3b,
PdfALevel::A3u,
] {
let converter = PdfAConverter::new(level);
assert_eq!(converter.level(), level);
}
}
#[test]
fn test_xmp_generation_a1b() {
let converter = PdfAConverter::new(PdfALevel::A1b);
let xmp = converter.generate_xmp_metadata();
assert!(xmp.contains("<pdfaid:part>1</pdfaid:part>"));
assert!(xmp.contains("<pdfaid:conformance>B</pdfaid:conformance>"));
assert!(xmp.contains("xmpmeta"));
assert!(xmp.contains("xpacket"));
}
#[test]
fn test_xmp_generation_a3a() {
let converter = PdfAConverter::new(PdfALevel::A3a);
let xmp = converter.generate_xmp_metadata();
assert!(xmp.contains("<pdfaid:part>3</pdfaid:part>"));
assert!(xmp.contains("<pdfaid:conformance>A</pdfaid:conformance>"));
}
#[test]
fn test_xmp_generation_a2u() {
let converter = PdfAConverter::new(PdfALevel::A2u);
let xmp = converter.generate_xmp_metadata();
assert!(xmp.contains("<pdfaid:part>2</pdfaid:part>"));
assert!(xmp.contains("<pdfaid:conformance>U</pdfaid:conformance>"));
}
#[test]
fn test_conversion_result_new() {
let result = ConversionResult::new(PdfALevel::A2b);
assert!(!result.success);
assert_eq!(result.level, PdfALevel::A2b);
assert!(result.actions.is_empty());
assert!(result.errors.is_empty());
}
#[test]
fn test_conversion_result_add_action() {
let mut result = ConversionResult::new(PdfALevel::A1b);
result.add_action(ConversionAction::new(ActionType::RemovedJavaScript, "Removed JS"));
result
.add_action(ConversionAction::new(ActionType::RemovedEncryption, "Removed encryption"));
assert_eq!(result.actions.len(), 2);
}
#[test]
fn test_conversion_result_add_error() {
let mut result = ConversionResult::new(PdfALevel::A1b);
result.add_error(ConversionError::new(ErrorCode::FontNotEmbedded, "Cannot embed font"));
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].error_code, ErrorCode::FontNotEmbedded);
assert_eq!(result.errors[0].reason, "Cannot embed font");
}
#[test]
fn test_conversion_action_debug_clone() {
let action = ConversionAction::new(ActionType::AddedLanguage, "Added en");
let cloned = action.clone();
assert_eq!(cloned.action_type, ActionType::AddedLanguage);
let debug = format!("{:?}", action);
assert!(debug.contains("AddedLanguage"));
}
#[test]
fn test_conversion_error_debug_clone() {
let error = ConversionError::new(ErrorCode::EncryptionNotAllowed, "Encrypted");
let cloned = error.clone();
assert_eq!(cloned.error_code, ErrorCode::EncryptionNotAllowed);
let debug = format!("{:?}", error);
assert!(debug.contains("EncryptionNotAllowed"));
}
#[test]
fn test_all_action_types() {
let types = vec![
ActionType::AddedXmpMetadata,
ActionType::AddedPdfaIdentification,
ActionType::EmbeddedFont,
ActionType::AddedOutputIntent,
ActionType::RemovedJavaScript,
ActionType::RemovedEncryption,
ActionType::FlattenedTransparency,
ActionType::RemovedEmbeddedFiles,
ActionType::AddedStructure,
ActionType::FixedAnnotation,
ActionType::AddedLanguage,
];
for t in types {
let copy = t;
assert_eq!(t, copy);
let debug = format!("{:?}", t);
assert!(!debug.is_empty());
}
}
#[test]
fn test_converter_debug_clone() {
let converter = PdfAConverter::new(PdfALevel::A2b);
let cloned = converter.clone();
assert_eq!(cloned.level(), PdfALevel::A2b);
let debug = format!("{:?}", converter);
assert!(debug.contains("PdfAConverter"));
}
#[test]
fn test_srgb_icc_profile() {
let profile = PdfAConverter::get_srgb_icc_profile();
assert!(!profile.is_empty());
}
#[test]
fn test_conversion_config_debug_clone() {
let config = ConversionConfig::new().embed_fonts(false);
let cloned = config.clone();
assert!(!cloned.embed_fonts);
let debug = format!("{:?}", config);
assert!(debug.contains("ConversionConfig"));
}
#[test]
fn test_conversion_result_debug_clone() {
let result = ConversionResult::new(PdfALevel::A1a);
let cloned = result.clone();
assert_eq!(cloned.level, PdfALevel::A1a);
let debug = format!("{:?}", result);
assert!(debug.contains("ConversionResult"));
}
}