use pdfrum_object::{
Array, ByteSpan, Dict, Name, ObjRef, Object, PdfString, Resolve, Stream, StringSyntax, names,
};
use super::policy::{Compromise, Conversion, Policy, RasterCause, Refusal};
use super::xmp_write::{self, InfoFields};
use crate::{Document, PdfaLevel};
const FORBIDDEN_ACTIONS: &[&str] = &[
"JavaScript",
"Launch",
"Sound",
"Movie",
"ResetForm",
"ImportData",
"Hide",
"SetOCGState",
"Rendition",
"Trans",
"GoTo3DView",
];
const FORBIDDEN_ANNOTATIONS: &[&str] = &[
"FileAttachment",
"Sound",
"Movie",
"Screen",
"3D",
"RichMedia",
];
const PRINT_FLAG: i64 = 1 << 2;
const ILLEGAL_FLAGS: i64 = (1 << 0) | (1 << 1) | (1 << 5) | (1 << 8);
#[derive(Debug, Default)]
struct Plan {
replace: Vec<(ObjRef, Object)>,
remove: Vec<ObjRef>,
drop_catalog_keys: Vec<Name>,
compromises: Vec<Compromise>,
}
pub(crate) fn convert(
doc: &Document,
level: PdfaLevel,
policy: Policy,
) -> crate::Result<(Conversion, Option<Vec<u8>>)> {
let mut refusals = Vec::new();
let mut plan = Plan::default();
let catalog = doc.catalog();
let Some(root) = doc.inner.trailer().reference(names::ROOT) else {
return Ok((
Conversion {
level,
compromises: Vec::new(),
refusals: vec![Refusal::ForbiddenFeature {
holder: ObjRef::new(0, 0),
kind: "no /Root in the trailer, so there is no catalog to convert".to_owned(),
}],
},
None,
));
};
survey_catalog(doc, &catalog, root, level, policy, &mut plan, &mut refusals);
survey_pages(doc, level, policy, &mut plan, &mut refusals);
survey_stray_metadata(doc, root, &mut plan);
survey_unrepairable(doc, level, policy, &mut refusals);
if !refusals.is_empty() {
return Ok((
Conversion {
level,
compromises: Vec::new(),
refusals,
},
None,
));
}
let bytes = apply(doc, level, root, &catalog, &plan)?;
Ok((
Conversion {
level,
compromises: plan.compromises,
refusals: Vec::new(),
},
Some(bytes),
))
}
impl Plan {
fn merged_replacements(&self) -> Vec<(ObjRef, Object)> {
let mut order: Vec<ObjRef> = Vec::new();
let mut merged: std::collections::BTreeMap<u32, (ObjRef, Object)> =
std::collections::BTreeMap::new();
for (reference, object) in &self.replace {
match merged.get_mut(&reference.num) {
None => {
order.push(*reference);
merged.insert(reference.num, (*reference, object.clone()));
}
Some((_, existing)) => match (&mut *existing, object) {
(Object::Dict(into), Object::Dict(from)) => {
let dropped: Vec<Name> = into
.iter()
.map(|(key, _)| key.clone())
.filter(|key| !from.contains_key(key))
.collect();
for key in dropped {
into.remove(&key);
}
for (key, value) in from.iter() {
if into.contains_key(key) {
into.insert(key.clone(), value.clone());
}
}
}
_ => *existing = object.clone(),
},
}
}
order
.into_iter()
.filter_map(|reference| merged.remove(&reference.num))
.collect()
}
}
fn survey_unrepairable(
doc: &Document,
level: PdfaLevel,
policy: Policy,
refusals: &mut Vec<Refusal>,
) {
if policy.unembeddable_font.accepts() && policy.unrepresentable_content.accepts() {
return;
}
let report = doc.check_pdfa(level);
for violation in &report.violations {
match violation.clause {
crate::PdfaClause::FontNotEmbedded if !policy.unembeddable_font.accepts() => {
let font = match violation.subject {
crate::PdfaSubject::Object(reference) => reference,
_ => ObjRef::new(0, 0),
};
refusals.push(Refusal::UnembeddableFont {
font,
base_name: violation.detail.clone(),
});
}
crate::PdfaClause::Transparency if !policy.unrepresentable_content.accepts() => {
let page = match violation.subject {
crate::PdfaSubject::Page(index)
| crate::PdfaSubject::Resource { page: index, .. } => index,
_ => 0,
};
refusals.push(Refusal::UnrepresentableContent {
page,
cause: RasterCause::Transparency,
});
}
_ => {}
}
}
}
fn survey_catalog(
doc: &Document,
catalog: &Dict,
root: ObjRef,
level: PdfaLevel,
policy: Policy,
plan: &mut Plan,
refusals: &mut Vec<Refusal>,
) {
let resolve = &doc.inner;
if let Some(action) = catalog.dict(&Name::from("OpenAction"), resolve)
&& let Some(kind) = forbidden_action_kind(&action, resolve)
{
if policy.forbidden_feature.accepts() {
plan.drop_catalog_keys.push(Name::from("OpenAction"));
plan.compromises
.push(Compromise::ActionRemoved { holder: root, kind });
} else {
refusals.push(Refusal::ForbiddenFeature { holder: root, kind });
}
}
let names_ref = catalog.reference(names::NAMES);
if let Some(names_dict) = catalog.dict(names::NAMES, resolve) {
let mut edited = names_dict.clone();
let mut removed: Vec<&str> = Vec::new();
if edited.contains_key(&Name::from("JavaScript")) {
if policy.forbidden_feature.accepts() {
edited.remove(&Name::from("JavaScript"));
removed.push("JavaScript");
plan.compromises.push(Compromise::ActionRemoved {
holder: names_ref.unwrap_or(root),
kind: "JavaScript".to_owned(),
});
} else {
refusals.push(Refusal::ForbiddenFeature {
holder: names_ref.unwrap_or(root),
kind: "a JavaScript name tree".to_owned(),
});
}
}
if level == PdfaLevel::A1b && edited.contains_key(names::EMBEDDED_FILES) {
if policy.forbidden_feature.accepts() {
edited.remove(names::EMBEDDED_FILES);
removed.push("EmbeddedFiles");
plan.compromises.push(Compromise::EmbeddedFileRemoved {
file: names_ref,
name: "the /EmbeddedFiles name tree".to_owned(),
});
} else {
refusals.push(Refusal::ForbiddenFeature {
holder: names_ref.unwrap_or(root),
kind: "an embedded file".to_owned(),
});
}
}
if !removed.is_empty() {
match names_ref {
Some(reference) => plan.replace.push((reference, Object::Dict(edited))),
None => plan.replace.push((
root,
Object::Dict(with_key(catalog, names::NAMES, Object::Dict(edited))),
)),
}
}
}
if let Some(acroform) = catalog.dict(&Name::from("AcroForm"), resolve)
&& let Some(fields) = acroform.array(&Name::from("Fields"), resolve)
{
let mut seen = std::collections::BTreeSet::new();
for position in 0..fields.len() {
if let Some(Object::Ref(reference)) = fields.raw_at(position) {
survey_field(doc, *reference, policy, plan, refusals, &mut seen, 0);
}
}
}
if level == PdfaLevel::A1b && catalog.contains_key(&Name::from("OCProperties")) {
if policy.forbidden_feature.accepts() {
plan.drop_catalog_keys.push(Name::from("OCProperties"));
plan.compromises
.push(Compromise::OptionalContentRemoved { catalog: root });
} else {
refusals.push(Refusal::ForbiddenFeature {
holder: root,
kind: "optional content".to_owned(),
});
}
}
}
fn survey_stray_metadata(doc: &Document, root: ObjRef, plan: &mut Plan) {
let resolve = &doc.inner;
let last = doc.inner.xref().last_object_number();
for num in 1..=last {
if num == root.num {
continue;
}
let reference = ObjRef::new(num, 0);
let Ok(object) = resolve.fetch(reference) else {
continue;
};
let Some(dict) = object.as_dict() else {
continue;
};
let type_name = dict.name(names::TYPE);
let could_be_extgstate = type_name.is_none_or(|name| name.as_bytes() == b"ExtGState");
let has_transfer = could_be_extgstate
&& (dict.contains_key(&Name::from("TR")) || dict.contains_key(&Name::from("TR2")));
if !dict.contains_key(names::METADATA) && !has_transfer {
continue;
}
let strip = |target: &mut Dict| {
target.remove(names::METADATA);
target.remove(&Name::from("TR"));
target.remove(&Name::from("TR2"));
};
let replacement = if let Object::Stream(stream) = object.as_ref() {
let mut stream = stream.clone();
strip(&mut stream.dict);
Object::Stream(stream)
} else {
let mut edited = dict.clone();
strip(&mut edited);
Object::Dict(edited)
};
plan.replace.push((reference, replacement));
if dict.contains_key(names::METADATA) {
plan.compromises
.push(Compromise::ObjectMetadataRemoved { object: reference });
}
}
}
const MAX_FIELD_DEPTH: u32 = 32;
fn survey_field(
doc: &Document,
reference: ObjRef,
policy: Policy,
plan: &mut Plan,
refusals: &mut Vec<Refusal>,
seen: &mut std::collections::BTreeSet<u32>,
depth: u32,
) {
if depth >= MAX_FIELD_DEPTH || !seen.insert(reference.num) {
return;
}
let resolve = &doc.inner;
let Ok(object) = resolve.fetch(reference) else {
return;
};
let Some(dict) = object.as_dict() else {
return;
};
let mut edited = dict.clone();
let mut changed = false;
for key in [Name::from("A"), names::AA.clone()] {
if !dict.contains_key(&key) {
continue;
}
let kind = format!(
"a /{} entry on a form field",
String::from_utf8_lossy(key.as_bytes())
);
if policy.forbidden_feature.accepts() {
edited.remove(&key);
changed = true;
plan.compromises.push(Compromise::ActionRemoved {
holder: reference,
kind,
});
} else {
refusals.push(Refusal::ForbiddenFeature {
holder: reference,
kind,
});
}
}
if changed {
plan.replace.push((reference, Object::Dict(edited)));
}
if let Some(kids) = dict.array(&Name::from("Kids"), resolve) {
for position in 0..kids.len() {
if let Some(Object::Ref(kid)) = kids.raw_at(position) {
survey_field(doc, *kid, policy, plan, refusals, seen, depth + 1);
}
}
}
}
fn with_key(dict: &Dict, key: &Name, value: Object) -> Dict {
let mut out = dict.clone();
out.insert(key.clone(), value);
out
}
fn survey_pages(
doc: &Document,
_level: PdfaLevel,
policy: Policy,
plan: &mut Plan,
refusals: &mut Vec<Refusal>,
) {
let resolve = &doc.inner;
let mut seen = std::collections::BTreeSet::new();
for index in 0..doc.page_count() {
let Ok(page) = doc.inner.page(index) else {
continue;
};
survey_annotations(
doc,
&page.dict,
page.reference,
index,
policy,
plan,
refusals,
);
sweep_annotation_appearances(doc, &page.dict, plan, &mut seen);
let Some(resources) = page.dict.dict(names::RESOURCES, resolve) else {
continue;
};
sweep_resources(doc, &resources, plan, &mut seen, 0);
}
}
fn sweep_annotation_appearances(
doc: &Document,
page: &Dict,
plan: &mut Plan,
seen: &mut std::collections::BTreeSet<u32>,
) {
let resolve = &doc.inner;
let Some(annots) = page.array(names::ANNOTS, resolve) else {
return;
};
for position in 0..annots.len() {
let Some(annot) = annots.get(position, resolve) else {
continue;
};
let Some(ap) = annot.as_dict().and_then(|d| d.dict(names::AP, resolve)) else {
continue;
};
let normal = Name::from("N");
if let Some(stream) = ap.stream(&normal, resolve) {
if let Some(resources) = stream.dict.dict(names::RESOURCES, resolve) {
sweep_resources(doc, &resources, plan, seen, 1);
}
} else if let Some(states) = ap.dict(&normal, resolve) {
for (_, value) in states.iter() {
let Object::Ref(reference) = value else {
continue;
};
let Ok(object) = resolve.fetch(*reference) else {
continue;
};
if let Some(stream) = object.as_stream()
&& let Some(resources) = stream.dict.dict(names::RESOURCES, resolve)
{
sweep_resources(doc, &resources, plan, seen, 1);
}
}
}
}
}
const MAX_RESOURCE_DEPTH: u32 = 16;
fn sweep_resources(
doc: &Document,
resources: &Dict,
plan: &mut Plan,
seen: &mut std::collections::BTreeSet<u32>,
depth: u32,
) {
if depth >= MAX_RESOURCE_DEPTH {
return;
}
let resolve = &doc.inner;
drop_key_from_resources(
doc,
resources,
names::XOBJECT,
&Name::from("Interpolate"),
plan,
);
for key in ["TR", "TR2"] {
drop_key_from_resources(
doc,
resources,
&Name::from("ExtGState"),
&Name::from(key),
plan,
);
}
let Some(xobjects) = resources.dict(names::XOBJECT, resolve) else {
return;
};
for (_, value) in xobjects.iter() {
let Object::Ref(reference) = value else {
continue;
};
if !seen.insert(reference.num) {
continue;
}
let Ok(object) = resolve.fetch(*reference) else {
continue;
};
let Some(stream) = object.as_stream() else {
continue;
};
if let Some(nested) = stream.dict.dict(names::RESOURCES, resolve) {
sweep_resources(doc, &nested, plan, seen, depth + 1);
}
}
}
fn drop_key_from_resources(
doc: &Document,
resources: &Dict,
category: &Name,
key: &Name,
plan: &mut Plan,
) {
let resolve = &doc.inner;
let Some(group) = resources.dict(category, resolve) else {
return;
};
for (_, value) in group.iter() {
let Object::Ref(reference) = value else {
continue;
};
let Ok(object) = resolve.fetch(*reference) else {
continue;
};
match object.as_ref() {
Object::Stream(stream) if stream.dict.contains_key(key) => {
let mut edited = stream.clone();
edited.dict.remove(key);
plan.replace.push((*reference, Object::Stream(edited)));
}
Object::Dict(dict) if dict.contains_key(key) => {
let mut edited = dict.clone();
edited.remove(key);
plan.replace.push((*reference, Object::Dict(edited)));
}
_ => {}
}
}
}
fn survey_annotations(
doc: &Document,
page: &Dict,
page_ref: Option<ObjRef>,
index: u32,
policy: Policy,
plan: &mut Plan,
refusals: &mut Vec<Refusal>,
) {
let resolve = &doc.inner;
let Some(annots) = page.array(names::ANNOTS, resolve) else {
return;
};
let mut kept = Array::default();
let mut dropped_any = false;
for position in 0..annots.len() {
let Some(entry) = annots.raw_at(position) else {
continue;
};
let (reference, dict) = match entry {
Object::Ref(reference) => {
let Ok(object) = resolve.fetch(*reference) else {
continue;
};
let Some(dict) = object.as_dict().cloned() else {
kept.push(entry.clone());
continue;
};
(Some(*reference), dict)
}
Object::Dict(dict) => (None, dict.clone()),
other => {
kept.push(other.clone());
continue;
}
};
let subtype = dict
.name(names::SUBTYPE)
.map(|n| String::from_utf8_lossy(n.as_bytes()).into_owned())
.unwrap_or_default();
let holder = reference.or(page_ref).unwrap_or(ObjRef::new(0, 0));
if FORBIDDEN_ANNOTATIONS.contains(&subtype.as_str()) {
if policy.forbidden_feature.accepts() {
if let Some(reference) = reference {
plan.remove.push(reference);
}
plan.compromises.push(Compromise::AnnotationRemoved {
annotation: holder,
page: index,
subtype,
});
dropped_any = true;
} else {
refusals.push(Refusal::ForbiddenFeature {
holder,
kind: format!("a /{subtype} annotation"),
});
kept.push(entry.clone());
}
continue;
}
let repaired = repair_annotation(
&dict, &subtype, resolve, policy, index, holder, plan, refusals,
);
match (reference, repaired) {
(Some(reference), Some(edited)) => {
plan.replace.push((reference, Object::Dict(edited)));
kept.push(Object::Ref(reference));
}
(Some(reference), None) => kept.push(Object::Ref(reference)),
(None, Some(edited)) => {
kept.push(Object::Dict(edited));
dropped_any = true;
}
(None, None) => kept.push(entry.clone()),
}
}
if !dropped_any {
return;
}
match page.reference(names::ANNOTS) {
Some(annots_ref) => plan.replace.push((annots_ref, Object::Array(kept))),
None => {
if let Some(page_ref) = page_ref {
plan.replace.push((
page_ref,
Object::Dict(with_key(page, names::ANNOTS, Object::Array(kept))),
));
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn repair_annotation<R: Resolve>(
dict: &Dict,
subtype: &str,
resolve: &R,
policy: Policy,
index: u32,
holder: ObjRef,
plan: &mut Plan,
refusals: &mut Vec<Refusal>,
) -> Option<Dict> {
let mut edited = dict.clone();
let mut changed = false;
if subtype != "Popup" {
let flags = dict.int(&Name::from("F"), resolve).unwrap_or(0);
let wanted = (flags | PRINT_FLAG) & !ILLEGAL_FLAGS;
let hides = flags & ILLEGAL_FLAGS != 0;
if hides && !policy.forbidden_feature.accepts() {
refusals.push(Refusal::ForbiddenFeature {
holder,
kind: "an annotation hidden by its /F flags, which PDF/A requires be \
visible and printable"
.to_owned(),
});
} else if wanted != flags || !dict.contains_key(&Name::from("F")) {
edited.insert(Name::from("F"), Object::Int(wanted));
changed = true;
if hides {
plan.compromises.push(Compromise::AnnotationFlagsChanged {
annotation: holder,
page: index,
});
}
}
}
for key in [Name::from("A"), names::AA.clone()] {
if !dict.contains_key(&key) {
continue;
}
let forbidden = dict
.dict(&key, resolve)
.and_then(|action| forbidden_action_kind(&action, resolve));
let is_widget = subtype == "Widget";
let Some(kind) = forbidden.or_else(|| {
is_widget.then(|| {
format!(
"a /{} entry on a widget",
String::from_utf8_lossy(key.as_bytes())
)
})
}) else {
continue;
};
if policy.forbidden_feature.accepts() {
edited.remove(&key);
changed = true;
plan.compromises
.push(Compromise::ActionRemoved { holder, kind });
} else {
refusals.push(Refusal::ForbiddenFeature { holder, kind });
}
}
if let Some(ap) = dict.dict(names::AP, resolve) {
let mut trimmed = ap.clone();
let mut ap_changed = false;
if ap.contains_key(&Name::from("R")) || ap.contains_key(&Name::from("D")) {
trimmed.remove(&Name::from("R"));
trimmed.remove(&Name::from("D"));
ap_changed = true;
}
let normal = Name::from("N");
let is_button = field_type(dict, resolve).as_deref() == Some("Btn");
if is_button
&& let Some(stream_ref) = ap.reference(&normal)
&& ap.stream(&normal, resolve).is_some()
{
let state = dict
.name(&Name::from("AS"))
.map_or_else(|| Name::from("Off"), std::clone::Clone::clone);
let mut states = Dict::default();
states.insert(state.clone(), Object::Ref(stream_ref));
trimmed.insert(normal, Object::Dict(states));
if !dict.contains_key(&Name::from("AS")) {
edited.insert(Name::from("AS"), Object::Name(state));
}
ap_changed = true;
}
if ap_changed {
edited.insert(names::AP.clone(), Object::Dict(trimmed));
changed = true;
}
}
changed.then_some(edited)
}
const MAX_ACTION_DEPTH: u32 = 32;
fn field_type<R: Resolve>(dict: &Dict, resolve: &R) -> Option<String> {
let mut current = dict.clone();
for _ in 0..MAX_FIELD_DEPTH {
if let Some(ft) = current.name(&Name::from("FT")) {
return Some(String::from_utf8_lossy(ft.as_bytes()).into_owned());
}
current = current.dict(&Name::from("Parent"), resolve)?;
}
None
}
fn forbidden_action_kind<R: Resolve>(action: &Dict, resolve: &R) -> Option<String> {
forbidden_action_at(action, resolve, 0)
}
fn forbidden_action_at<R: Resolve>(action: &Dict, resolve: &R, depth: u32) -> Option<String> {
if depth >= MAX_ACTION_DEPTH {
return None;
}
if let Some(kind) = action.name(&Name::from("S")) {
let kind = String::from_utf8_lossy(kind.as_bytes()).into_owned();
if FORBIDDEN_ACTIONS.contains(&kind.as_str()) {
return Some(kind);
}
}
let next = Name::from("Next");
if let Some(array) = action.array(&next, resolve) {
return (0..array.len())
.filter_map(|i| array.get(i, resolve))
.filter_map(|entry| entry.as_dict().cloned())
.find_map(|entry| forbidden_action_at(&entry, resolve, depth + 1));
}
action
.dict(&next, resolve)
.and_then(|entry| forbidden_action_at(&entry, resolve, depth + 1))
}
fn apply(
doc: &Document,
level: PdfaLevel,
root: ObjRef,
catalog: &Dict,
plan: &Plan,
) -> crate::Result<Vec<u8>> {
let mut edit = pdfrum_edit::EditDoc::new(&doc.inner);
let mut catalog = catalog.clone();
for (reference, object) in &plan.merged_replacements() {
if *reference == root
&& let Object::Dict(dict) = object
{
catalog = dict.clone();
continue;
}
edit.replace(*reference, object.clone());
}
for reference in &plan.remove {
edit.remove(*reference);
}
for key in &plan.drop_catalog_keys {
catalog.remove(key);
}
let mut metadata_dict = Dict::default();
metadata_dict.insert(names::TYPE.clone(), Object::Name(names::METADATA.clone()));
metadata_dict.insert(names::SUBTYPE.clone(), Object::Name(Name::from("XML")));
let metadata = edit.add(Object::Stream(Box::new(Stream::new(
metadata_dict,
ByteSpan::from(xmp_write::packet(level, &info_fields(doc))),
))));
catalog.insert(names::METADATA.clone(), Object::Ref(metadata));
if let Some(intents) = output_intent(&mut edit, intent_profile(doc)) {
catalog.insert(Name::from("OutputIntents"), Object::Array(intents));
}
edit.replace(root, Object::Dict(catalog));
let options = pdfrum_edit::SaveOptions {
mode: pdfrum_edit::SaveMode::Full,
remove_security: true,
..pdfrum_edit::SaveOptions::default()
};
let mut out = Vec::new();
pdfrum_edit::save(&edit, &options, &mut out)?;
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IntentProfile {
Srgb,
Cmyk,
}
impl IntentProfile {
fn parts(self) -> Option<(Vec<u8>, i64, &'static [u8])> {
match self {
Self::Srgb => Some((pdfrum_page::srgb_profile_bytes()?, 3, b"sRGB IEC61966-2.1")),
Self::Cmyk => Some((pdfrum_page::cmyk_profile_bytes()?, 4, b"CGATS TR 001")),
}
}
}
fn intent_profile(doc: &Document) -> IntentProfile {
let resolve = &doc.inner;
let mut cmyk = false;
let mut other = false;
let last = doc.inner.xref().last_object_number();
for num in 1..=last {
let Ok(object) = resolve.fetch(ObjRef::new(num, 0)) else {
continue;
};
note_device_spaces(object.as_ref(), &mut cmyk, &mut other);
}
if cmyk && !other {
IntentProfile::Cmyk
} else {
IntentProfile::Srgb
}
}
const MAX_COLOR_SPACE_DEPTH: u32 = 8;
fn note_device_spaces(object: &Object, cmyk: &mut bool, other: &mut bool) {
let Some(dict) = object.as_dict() else {
return;
};
for key in [names::COLOR_SPACE, &Name::from("CS")] {
if let Some(space) = dict.raw(key) {
note_one_space(space, cmyk, other, 0);
}
}
if let Some(Object::Dict(resources)) = dict.raw(names::RESOURCES)
&& let Some(Object::Dict(spaces)) = resources.raw(names::COLOR_SPACE)
{
for (_, space) in spaces.iter() {
note_one_space(space, cmyk, other, 0);
}
}
}
fn note_one_space(space: &Object, cmyk: &mut bool, other: &mut bool, depth: u32) {
match space {
Object::Name(name) => match name.as_bytes() {
b"DeviceCMYK" => *cmyk = true,
b"DeviceRGB" | b"DeviceGray" => *other = true,
_ => {}
},
Object::Array(array) if depth < MAX_COLOR_SPACE_DEPTH => {
for i in 0..array.len() {
if let Some(element) = array.raw_at(i) {
note_one_space(element, cmyk, other, depth + 1);
}
}
}
_ => {}
}
}
fn output_intent(edit: &mut pdfrum_edit::EditDoc<'_>, profile: IntentProfile) -> Option<Array> {
let (profile_bytes, components, condition) = profile.parts()?;
let mut profile_dict = Dict::default();
profile_dict.insert(Name::from("N"), Object::Int(components));
let profile = edit.add(Object::Stream(Box::new(Stream::new(
profile_dict,
ByteSpan::from(profile_bytes),
))));
let mut intent = Dict::default();
intent.insert(
names::TYPE.clone(),
Object::Name(Name::from("OutputIntent")),
);
intent.insert(Name::from("S"), Object::Name(Name::from("GTS_PDFA1")));
let condition = PdfString::new(condition, StringSyntax::Literal);
intent.insert(
Name::from("OutputConditionIdentifier"),
Object::Str(condition.clone()),
);
intent.insert(Name::from("Info"), Object::Str(condition));
intent.insert(Name::from("DestOutputProfile"), Object::Ref(profile));
let intent_ref = edit.add(Object::Dict(intent));
let mut array = Array::default();
array.push(Object::Ref(intent_ref));
Some(array)
}
fn info_fields(doc: &Document) -> InfoFields {
let metadata = doc.metadata();
InfoFields {
title: metadata.title.clone(),
author: metadata.author.clone(),
creator: metadata.creator.clone(),
producer: metadata.producer.clone(),
keywords: metadata.keywords.clone(),
create_date: metadata.creation_date.as_deref().map(pdf_date_to_iso8601),
modify_date: metadata
.modification_date
.as_deref()
.map(pdf_date_to_iso8601),
}
}
fn pdf_date_to_iso8601(pdf: &str) -> String {
let body = pdf.trim_start_matches("D:");
let digits: Vec<char> = body.chars().take_while(char::is_ascii_digit).collect();
if digits.len() < 4 {
return pdf.to_owned();
}
let at = |range: std::ops::Range<usize>| -> Option<String> {
(digits.len() >= range.end).then(|| digits[range].iter().collect())
};
let mut out: String = digits[0..4].iter().collect();
let Some(month) = at(4..6) else { return out };
out.push('-');
out.push_str(&month);
let Some(day) = at(6..8) else { return out };
out.push('-');
out.push_str(&day);
let (Some(hour), Some(minute)) = (at(8..10), at(10..12)) else {
return out;
};
out.push('T');
out.push_str(&hour);
out.push(':');
out.push_str(&minute);
out.push(':');
out.push_str(&at(12..14).unwrap_or_else(|| "00".to_owned()));
let rest: String = body.chars().skip(digits.len()).collect();
let sign = rest.chars().next();
let zone: Vec<char> = rest.chars().filter(char::is_ascii_digit).collect();
if matches!(sign, Some('+' | '-')) && zone.len() >= 4 {
out.push(sign.unwrap_or('Z'));
out.extend(&zone[0..2]);
out.push(':');
out.extend(&zone[2..4]);
} else {
out.push('Z');
}
out
}
#[cfg(test)]
mod tests {
use super::{FORBIDDEN_ACTIONS, ILLEGAL_FLAGS, PRINT_FLAG, pdf_date_to_iso8601};
#[test]
fn a_pdf_date_becomes_iso8601() {
assert_eq!(
pdf_date_to_iso8601("D:20260907120000Z"),
"2026-09-07T12:00:00Z"
);
assert_eq!(
pdf_date_to_iso8601("D:20260907120000+01'00'"),
"2026-09-07T12:00:00+01:00"
);
assert_eq!(pdf_date_to_iso8601("D:2026"), "2026");
assert_eq!(pdf_date_to_iso8601("D:202609"), "2026-09");
assert_eq!(pdf_date_to_iso8601("D:20260907"), "2026-09-07");
}
#[test]
fn an_unreadable_date_is_returned_for_the_writer_to_drop() {
assert_eq!(pdf_date_to_iso8601("garbage"), "garbage");
let packet = super::xmp_write::packet(
crate::PdfaLevel::A2b,
&super::InfoFields {
create_date: Some(pdf_date_to_iso8601("garbage")),
..super::InfoFields::default()
},
);
assert!(!packet.windows(10).any(|w| w == b"CreateDate"));
}
#[test]
fn the_flag_repair_sets_print_and_clears_the_hiding_bits() {
assert_eq!((6 | PRINT_FLAG) & !ILLEGAL_FLAGS, PRINT_FLAG);
assert_eq!(PRINT_FLAG & !ILLEGAL_FLAGS, 4);
assert_eq!((PRINT_FLAG | 16) | PRINT_FLAG & !ILLEGAL_FLAGS, 20);
}
fn spaces_in(space: &pdfrum_object::Object) -> (bool, bool) {
let (mut cmyk, mut other) = (false, false);
super::note_one_space(space, &mut cmyk, &mut other, 0);
(cmyk, other)
}
#[test]
fn a_device_space_is_recognised_by_name() {
use pdfrum_object::{Name, Object};
assert_eq!(
spaces_in(&Object::Name(Name::from("DeviceCMYK"))),
(true, false)
);
assert_eq!(
spaces_in(&Object::Name(Name::from("DeviceRGB"))),
(false, true)
);
assert_eq!(
spaces_in(&Object::Name(Name::from("DeviceGray"))),
(false, true)
);
assert_eq!(
spaces_in(&Object::Name(Name::from("Pattern"))),
(false, false)
);
}
#[test]
fn a_device_space_under_a_wrapper_is_found() {
use pdfrum_object::{Array, Name, Object};
let mut separation = Array::default();
separation.push(Object::Name(Name::from("Separation")));
separation.push(Object::Name(Name::from("Spot")));
separation.push(Object::Name(Name::from("DeviceCMYK")));
assert_eq!(spaces_in(&Object::Array(separation)), (true, false));
}
#[test]
fn a_document_painting_in_both_is_not_a_cmyk_document() {
use pdfrum_object::{Array, Name, Object};
let mut both = Array::default();
both.push(Object::Name(Name::from("DeviceCMYK")));
both.push(Object::Name(Name::from("DeviceRGB")));
assert_eq!(spaces_in(&Object::Array(both)), (true, true));
}
#[test]
fn javascript_is_in_the_forbidden_action_list() {
assert!(FORBIDDEN_ACTIONS.contains(&"JavaScript"));
assert!(FORBIDDEN_ACTIONS.contains(&"Launch"));
assert!(!FORBIDDEN_ACTIONS.contains(&"GoTo"));
assert!(!FORBIDDEN_ACTIONS.contains(&"URI"));
}
}