use std::collections::HashMap;
use zpdf_core::{ObjectId, PdfDict, PdfName, PdfObject};
use zpdf_parser::PdfFile;
use crate::metadata::encode_text_string;
use crate::rewrite::{renumber, PdfaConvertConfig, PdfaProfile};
use crate::{flate_compress, invalid_data};
const SRGB_ICC: &[u8] = include_bytes!("srgb.icc");
pub(crate) enum ExtraObj {
Object(PdfObject),
Stream(PdfDict, Vec<u8>),
}
pub(crate) struct PdfaEdits {
pub extras: Vec<(u32, ExtraObj)>,
pub transforms: HashMap<ObjectId, PdfObject>,
pub header: &'static str,
}
impl Default for PdfaEdits {
fn default() -> Self {
Self {
extras: Vec::new(),
transforms: HashMap::new(),
header: "%PDF-1.7",
}
}
}
pub(crate) fn prepare(
source: &PdfFile,
order: &[ObjectId],
map: &HashMap<ObjectId, u32>,
cfg: &PdfaConvertConfig,
) -> Result<PdfaEdits, zpdf_core::Error> {
let mut edits = PdfaEdits {
extras: Vec::new(),
transforms: HashMap::new(),
header: match cfg.profile {
PdfaProfile::A1b => "%PDF-1.4",
PdfaProfile::A2b => "%PDF-1.7",
},
};
let root = source
.trailer
.get_ref("Root")
.map_err(|_| invalid_data("trailer missing /Root"))?;
let mut next_extra = (order.len() as u32) + 1;
let icc_bytes = cfg.icc.as_deref().unwrap_or(SRGB_ICC);
let icc_num = next_extra;
next_extra += 1;
let mut icc_dict = PdfDict::new();
icc_dict.insert(PdfName::new("N"), PdfObject::Integer(3));
icc_dict.insert(
PdfName::new("Filter"),
PdfObject::Name(PdfName::new("FlateDecode")),
);
edits.extras.push((
icc_num,
ExtraObj::Stream(icc_dict, flate_compress(icc_bytes)),
));
let intent_num = next_extra;
next_extra += 1;
let mut intent = PdfDict::new();
intent.insert(
PdfName::new("Type"),
PdfObject::Name(PdfName::new("OutputIntent")),
);
intent.insert(
PdfName::new("S"),
PdfObject::Name(PdfName::new("GTS_PDFA1")),
);
intent.insert(
PdfName::new("OutputConditionIdentifier"),
PdfObject::String(encode_text_string("sRGB")),
);
intent.insert(
PdfName::new("Info"),
PdfObject::String(encode_text_string("sRGB IEC61966-2.1")),
);
intent.insert(
PdfName::new("DestOutputProfile"),
PdfObject::Ref(ObjectId(icc_num, 0)),
);
edits
.extras
.push((intent_num, ExtraObj::Object(PdfObject::Dict(intent))));
let xmp = build_pdfa_xmp(cfg.profile);
let xmp_num = next_extra;
next_extra += 1;
let mut xmp_dict = PdfDict::new();
xmp_dict.insert(
PdfName::new("Type"),
PdfObject::Name(PdfName::new("Metadata")),
);
xmap_sub(&mut xmp_dict);
xmp_dict.insert(
PdfName::new("Filter"),
PdfObject::Name(PdfName::new("FlateDecode")),
);
edits
.extras
.push((xmp_num, ExtraObj::Stream(xmp_dict, flate_compress(&xmp))));
let catalog_obj = source.resolve(root)?;
let mut catalog_old = catalog_obj
.as_dict()
.map_err(|_| invalid_data("catalog is not a dict"))?
.clone();
if let Some(PdfObject::Dict(action)) = catalog_old
.get("OpenAction")
.map(|o| deref(source, o))
.as_ref()
{
let s = action.get_name("S").unwrap_or("");
if s == "JavaScript" || s == "Launch" {
catalog_old.0.remove(&PdfName::new("OpenAction"));
}
}
let names_ref_to_transform = match catalog_old.get("Names") {
Some(PdfObject::Ref(r)) => Some(*r),
Some(PdfObject::Dict(_)) => {
strip_names_inline(&mut catalog_old, cfg.profile);
None
}
_ => None,
};
let already_pdfa1 = matches!(catalog_old.get("OutputIntents"), Some(PdfObject::Array(a)) if a.iter().any(|o| {
matches!(deref(source, o).as_dict().ok().and_then(|d| d.get_name("S").ok()), Some("GTS_PDFA1"))
}));
let mut cat = match renumber(&PdfObject::Dict(catalog_old), map) {
PdfObject::Dict(d) => d,
_ => unreachable!("renumber of a dict yields a dict"),
};
let mut intents = match cat.get("OutputIntents") {
Some(PdfObject::Array(a)) => a.clone(),
_ => Vec::new(),
};
if !already_pdfa1 {
intents.push(PdfObject::Ref(ObjectId(intent_num, 0)));
}
cat.insert(PdfName::new("OutputIntents"), PdfObject::Array(intents));
cat.insert(
PdfName::new("Metadata"),
PdfObject::Ref(ObjectId(xmp_num, 0)),
);
edits.transforms.insert(root, PdfObject::Dict(cat));
if let Some(names_id) = names_ref_to_transform {
if let Ok(PdfObject::Dict(mut names)) = source.resolve(names_id) {
names.0.remove(&PdfName::new("JavaScript"));
if cfg.profile == PdfaProfile::A1b {
names.0.remove(&PdfName::new("EmbeddedFiles"));
}
let ren = renumber(&PdfObject::Dict(names), map);
edits.transforms.insert(names_id, ren);
}
}
if cfg.profile == PdfaProfile::A1b {
strip_transparency_groups(source, map, root, &mut edits.transforms);
}
strip_forbidden_annotations(source, map, root, cfg.profile, &mut edits.transforms);
if let Some(fallback) = &cfg.fallback_font {
embed_fallback_fonts(source, map, root, fallback, &mut next_extra, &mut edits)?;
}
Ok(edits)
}
const FORBIDDEN_ANNOT_SUBTYPES_BOTH: &[&str] = &["3D", "Sound", "Movie"];
const FORBIDDEN_ANNOT_SUBTYPES_A1B: &[&str] = &["FileAttachment"];
fn strip_forbidden_annotations(
source: &PdfFile,
map: &HashMap<ObjectId, u32>,
root: ObjectId,
profile: PdfaProfile,
transforms: &mut HashMap<ObjectId, PdfObject>,
) {
let Ok(catalog) = source.resolve(root).and_then(|o| o.as_dict().cloned()) else {
return;
};
let Ok(pages_root) = catalog.get_ref("Pages") else {
return;
};
let a1b_extra = profile == PdfaProfile::A1b;
let mut stack = vec![pages_root];
let mut visited = std::collections::HashSet::new();
while let Some(node) = stack.pop() {
if !visited.insert(node) {
continue;
}
let Ok(dict) = source.resolve(node).and_then(|o| o.as_dict().cloned()) else {
continue;
};
if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(source, o)).as_ref() {
for kid in kids {
if let PdfObject::Ref(r) = kid {
stack.push(*r);
}
}
}
let annots_obj = dict.get("Annots").map(|o| deref(source, o));
let Some(PdfObject::Array(annots)) = annots_obj.as_ref() else {
continue;
};
let mut kept: Vec<PdfObject> = Vec::with_capacity(annots.len());
let mut removed = false;
for a in annots {
let resolved = deref(source, a);
let PdfObject::Dict(ad) = &resolved else {
kept.push(a.clone());
continue;
};
let subtype = ad.get_name("Subtype").unwrap_or("");
let forbidden = FORBIDDEN_ANNOT_SUBTYPES_BOTH.contains(&subtype)
|| (a1b_extra && FORBIDDEN_ANNOT_SUBTYPES_A1B.contains(&subtype));
if forbidden {
removed = true;
continue;
}
if let Some(PdfObject::Dict(action)) = ad.get("A").map(|o| deref(source, o)).as_ref() {
let s = action.get_name("S").unwrap_or("");
if s == "JavaScript" || s == "Launch" {
removed = true;
continue;
}
}
kept.push(a.clone());
}
if removed {
let mut d = dict.clone();
if kept.is_empty() {
d.0.remove(&PdfName::new("Annots"));
} else {
d.insert(PdfName::new("Annots"), PdfObject::Array(kept));
}
let ren = renumber(&PdfObject::Dict(d), map);
transforms.insert(node, ren);
}
}
}
fn xmap_sub(dict: &mut PdfDict) {
dict.insert(
PdfName::new("Subtype"),
PdfObject::Name(PdfName::new("XML")),
);
}
fn strip_names_inline(catalog: &mut PdfDict, profile: PdfaProfile) {
if let Some(PdfObject::Dict(names)) = catalog.get("Names").cloned().as_ref() {
let mut n = names.clone();
n.0.remove(&PdfName::new("JavaScript"));
if profile == PdfaProfile::A1b {
n.0.remove(&PdfName::new("EmbeddedFiles"));
}
catalog.insert(PdfName::new("Names"), PdfObject::Dict(n));
}
}
fn strip_transparency_groups(
source: &PdfFile,
map: &HashMap<ObjectId, u32>,
root: ObjectId,
transforms: &mut HashMap<ObjectId, PdfObject>,
) {
let Ok(catalog) = source.resolve(root).and_then(|o| o.as_dict().cloned()) else {
return;
};
let Ok(pages_root) = catalog.get_ref("Pages") else {
return;
};
let mut stack = vec![pages_root];
let mut visited = std::collections::HashSet::new();
while let Some(node) = stack.pop() {
if !visited.insert(node) {
continue;
}
let Ok(dict) = source.resolve(node).and_then(|o| o.as_dict().cloned()) else {
continue;
};
if let Some(PdfObject::Dict(group)) = dict.get("Group").map(|o| deref(source, o)).as_ref() {
if group.get_name("S").ok() == Some("Transparency") {
let mut d = dict.clone();
d.0.remove(&PdfName::new("Group"));
let ren = renumber(&PdfObject::Dict(d), map);
transforms.insert(node, ren);
}
}
if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(source, o)).as_ref() {
for kid in kids {
if let PdfObject::Ref(r) = kid {
stack.push(*r);
}
}
}
}
}
fn embed_fallback_fonts(
source: &PdfFile,
map: &HashMap<ObjectId, u32>,
root: ObjectId,
fallback: &[u8],
next_extra: &mut u32,
edits: &mut PdfaEdits,
) -> Result<(), zpdf_core::Error> {
let Ok(catalog) = source.resolve(root).and_then(|o| o.as_dict().cloned()) else {
return Ok(());
};
let Ok(pages_root) = catalog.get_ref("Pages") else {
return Ok(());
};
let compressed = flate_compress(fallback);
let mut seen: std::collections::HashSet<ObjectId> = std::collections::HashSet::new();
let mut stack = vec![pages_root];
let mut visited = std::collections::HashSet::new();
while let Some(node) = stack.pop() {
if !visited.insert(node) {
continue;
}
let Ok(dict) = source.resolve(node).and_then(|o| o.as_dict().cloned()) else {
continue;
};
if let Some(PdfObject::Dict(res)) = dict.get("Resources").map(|o| deref(source, o)).as_ref()
{
if let Some(PdfObject::Dict(fonts)) = res.get("Font").map(|o| deref(source, o)).as_ref()
{
for v in fonts.0.values() {
let PdfObject::Ref(font_ref) = v else {
continue;
};
let Ok(font) = source.resolve(*font_ref).and_then(|o| o.as_dict().cloned())
else {
continue;
};
let subtype = font.get_name("Subtype").unwrap_or("");
if subtype == "Type3" {
continue;
}
if subtype == "Type0" {
embed_type0_fallback(
source,
map,
&font,
fallback,
&compressed,
next_extra,
&mut seen,
edits,
);
continue;
}
let Some(PdfObject::Ref(desc_ref)) = font.get("FontDescriptor") else {
continue;
};
if !seen.insert(*desc_ref) {
continue;
}
let Ok(desc) = source.resolve(*desc_ref).and_then(|o| o.as_dict().cloned())
else {
continue;
};
let already_embedded = desc.get("FontFile").is_some()
|| desc.get("FontFile2").is_some()
|| desc.get("FontFile3").is_some();
if already_embedded {
continue;
}
let file_num = *next_extra;
*next_extra += 1;
let mut file_dict = PdfDict::new();
file_dict.insert(
PdfName::new("Filter"),
PdfObject::Name(PdfName::new("FlateDecode")),
);
file_dict.insert(
PdfName::new("Length1"),
PdfObject::Integer(fallback.len() as i64),
);
edits
.extras
.push((file_num, ExtraObj::Stream(file_dict, compressed.clone())));
let mut new_desc = desc.clone();
new_desc.insert(
PdfName::new("FontFile2"),
PdfObject::Ref(ObjectId(file_num, 0)),
);
let ren = renumber(&PdfObject::Dict(new_desc), map);
edits.transforms.insert(*desc_ref, ren);
}
}
}
if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(source, o)).as_ref() {
for kid in kids {
if let PdfObject::Ref(r) = kid {
stack.push(*r);
}
}
}
}
Ok(())
}
fn deref(file: &PdfFile, obj: &PdfObject) -> PdfObject {
match obj {
PdfObject::Ref(r) => file.resolve(*r).unwrap_or(PdfObject::Null),
other => other.clone(),
}
}
#[allow(clippy::too_many_arguments)]
fn embed_type0_fallback(
source: &PdfFile,
map: &HashMap<ObjectId, u32>,
font: &PdfDict,
fallback: &[u8],
compressed: &[u8],
next_extra: &mut u32,
seen: &mut std::collections::HashSet<ObjectId>,
edits: &mut PdfaEdits,
) {
let descs = font.get("DescendantFonts").map(|o| deref(source, o));
let Some(PdfObject::Array(descs)) = descs.as_ref() else {
return;
};
let Some(PdfObject::Ref(cid_ref)) = descs.first() else {
return;
};
let Ok(cid) = source.resolve(*cid_ref).and_then(|o| o.as_dict().cloned()) else {
return;
};
let Some(PdfObject::Ref(desc_ref)) = cid.get("FontDescriptor") else {
return;
};
if !seen.insert(*desc_ref) {
return;
}
let Ok(desc) = source.resolve(*desc_ref).and_then(|o| o.as_dict().cloned()) else {
return;
};
let already_embedded = desc.get("FontFile").is_some()
|| desc.get("FontFile2").is_some()
|| desc.get("FontFile3").is_some();
if already_embedded {
return;
}
let file_num = *next_extra;
*next_extra += 1;
let mut file_dict = PdfDict::new();
file_dict.insert(
PdfName::new("Filter"),
PdfObject::Name(PdfName::new("FlateDecode")),
);
file_dict.insert(
PdfName::new("Length1"),
PdfObject::Integer(fallback.len() as i64),
);
edits
.extras
.push((file_num, ExtraObj::Stream(file_dict, compressed.to_vec())));
let mut new_desc = desc.clone();
new_desc.insert(
PdfName::new("FontFile2"),
PdfObject::Ref(ObjectId(file_num, 0)),
);
let ren = renumber(&PdfObject::Dict(new_desc), map);
edits.transforms.insert(*desc_ref, ren);
let mut new_cid = cid.clone();
new_cid.insert(
PdfName::new("CIDToGIDMap"),
PdfObject::Name(PdfName::new("Identity")),
);
if new_cid.get("DW").is_none() && new_cid.get("W").is_none() {
new_cid.insert(PdfName::new("DW"), PdfObject::Integer(1000));
}
let ren = renumber(&PdfObject::Dict(new_cid), map);
edits.transforms.insert(*cid_ref, ren);
}
fn build_pdfa_xmp(profile: PdfaProfile) -> Vec<u8> {
let (part, conf) = match profile {
PdfaProfile::A1b => ("1", "B"),
PdfaProfile::A2b => ("2", "B"),
};
let xmp = format!(
"<?xpacket begin=\"\u{FEFF}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n\
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
<rdf:Description rdf:about=\"\"\n\
xmlns:pdfaid=\"http://www.aiim.org/pdfa/ns/id/\">\n\
<pdfaid:part>{part}</pdfaid:part>\n\
<pdfaid:conformance>{conf}</pdfaid:conformance>\n\
</rdf:Description>\n\
</rdf:RDF>\n\
</x:xmpmeta>\n\
<?xpacket end=\"w\"?>\n"
);
xmp.into_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xmp_declares_part_and_conformance() {
let xmp = build_pdfa_xmp(PdfaProfile::A1b);
let s = String::from_utf8(xmp).unwrap();
assert!(s.contains("<pdfaid:part>1</pdfaid:part>"));
assert!(s.contains("<pdfaid:conformance>B</pdfaid:conformance>"));
assert!(s.contains("http://www.aiim.org/pdfa/ns/id/"));
let xmp2 = build_pdfa_xmp(PdfaProfile::A2b);
assert!(String::from_utf8(xmp2)
.unwrap()
.contains("<pdfaid:part>2</pdfaid:part>"));
}
#[test]
fn srgb_icc_fixture_is_present() {
assert!(
SRGB_ICC.len() > 100,
"embedded sRGB ICC fixture looks empty"
);
assert_eq!(&SRGB_ICC[36..40], b"acsp", "ICC signature missing");
}
}