diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index d5a6d80b04..54a8a9508f 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -521,6 +521,41 @@ fn content_colour(data: &[u8], used: &mut DeviceColour) -> usize { read } +/// Records any device colour space named directly or used as the base or alternate space of a +/// colour-space object (`Name` or `Array` such as `[/Indexed base …]`, `[/Separation _ alt …]`, +/// or `[/DeviceN _ alt …]`). +fn mark_cs_object(doc: &Document, cs: &Object, used: &mut DeviceColour, depth: u8) { + if depth > MAX_DIRECT_DEPTH { + return; + } + match cs { + Object::Ref(r) => mark_cs_object(doc, &doc.get(*r), used, depth + 1), + Object::Name(n) => used.mark(n, false), + Object::Array(a) => { + let mut kind = a.first().map(|o| doc.resolve(o)); + for _ in 0..MAX_DIRECT_DEPTH { + let Some(&Object::Ref(r)) = kind.as_deref() else { break }; + kind = Some(doc.get(r)); + } + match kind.as_deref().and_then(Object::as_name) { + Some(n) if a.len() == 1 => used.mark(n, false), + Some(b"I" | b"Indexed" | b"Pattern") => { + if let Some(base) = a.get(1) { + mark_cs_object(doc, base, used, depth + 1); + } + } + Some(b"Separation" | b"DeviceN") => { + if let Some(alt) = a.get(2) { + mark_cs_object(doc, alt, used, depth + 1); + } + } + _ => {} + } + } + _ => {} + } +} + /// Whether any content or object uses a device colour space. fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> DeviceColour { let mut used = DeviceColour::default(); @@ -536,15 +571,15 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) { used.default_rgb |= spaces.get(b"DefaultRGB").is_some(); used.default_cmyk |= spaces.get(b"DefaultCMYK").is_some(); + for (k, entry) in spaces.iter() { + if !matches!(k.as_slice(), b"DefaultGray" | b"DefaultRGB" | b"DefaultCMYK") { + mark_cs_object(doc, entry, &mut used, 0); + } + } } } - if let Some(cs) = d.get(b"ColorSpace").map(|c| doc.resolve(c)) { - match &*cs { - Object::Name(n) if n == b"DeviceGray" => used.gray = true, - Object::Name(n) if n == b"DeviceRGB" => used.rgb = true, - Object::Name(n) if n == b"DeviceCMYK" => used.cmyk = true, - _ => {} - } + if let Some(cs) = d.get(b"ColorSpace") { + mark_cs_object(doc, cs, &mut used, 0); } // Content streams only: a font program or an ICC profile is data, whatever bytes it holds. // Parsing is the expensive part, so once content has used all three spaces no more is read. @@ -947,8 +982,20 @@ fn check_objects( let Some(d) = dict_of(o) else { continue }; check_one_object(doc, level, d, o, drawing.contains(r), page_of(*r), issue); } + for (i, p) in pdfcraft_model::pages(doc).into_iter().enumerate() { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + for entry in a { + if let Object::Dict(d) = entry { + check_one_object(doc, level, d, entry, false, Some(i), issue); + } + } + } + } } +/// Checks a single object against the PDF/A rules for `level`. fn check_one_object( doc: &Document, level: Level, @@ -961,13 +1008,14 @@ fn check_one_object( let ty = dict.name(b"Type"); let subtype = dict.name(b"Subtype"); check_actions(doc, dict, issue); - if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page, issue) { + let is_annot = matches!(object, Object::Dict(_)) && is_annotation(doc, dict, ty, subtype, page.is_some()); + if is_annot && check_annotation(doc, dict, subtype, page, issue) { // A forbidden annotation stops every later check for its object, as the scan did before // it was split into helpers: the rest of its own checks and the form field, font, stream // and embedded file below all came after that `continue`. return; } - if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { + if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && !is_annot { issue("6.5.2", "A form field has additional actions", None, None, true); } // A Type 0 font's descendant is checked through it. @@ -992,8 +1040,11 @@ fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static st } } -fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool { - ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some()) +/// Whether `dict` is an annotation dictionary (`/Type /Annot`, or a dictionary with the required +/// `/Subtype` and `/Rect` array entries that either sits in a page's `/Annots` list or carries `/P`). +fn is_annotation(doc: &Document, dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>, on_page: bool) -> bool { + ty == Some(b"Annot") + || (subtype.is_some() && dict.get(b"Rect").is_some_and(|r| doc.resolve(r).as_array().is_some()) && (on_page || dict.get(b"P").is_some())) } fn check_annotation( @@ -1044,6 +1095,7 @@ fn check_annotation_appearance( } } +/// Checks a stream's compression filters, external file keys, and image/PostScript subtype rules. fn check_stream( doc: &Document, stream: &pdfcraft_cos::Stream, @@ -1053,12 +1105,12 @@ fn check_stream( let filters: Vec> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) { Some(filter) => match &*filter { Object::Name(name) => vec![name.clone()], - Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(), + Object::Array(filters) => filters.iter().filter_map(|filter| doc.resolve(filter).as_name().map(<[u8]>::to_vec)).collect(), _ => Vec::new(), }, None => Vec::new(), }; - if filters.iter().any(|filter| filter == b"LZWDecode") { + if filters.iter().any(|filter| filter == b"LZWDecode" || filter == b"LZW") { issue("6.1.7.2", "A stream uses LZW compression", None, None, false); } if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() { @@ -1223,6 +1275,86 @@ fn strip_actions(doc: &Document, d: &mut Dict) -> bool { changed } +/// Collects every indirect annotation reference listed in any page's `/Annots` array. +fn page_annot_refs(doc: &Document) -> std::collections::HashSet { + let mut refs = std::collections::HashSet::new(); + for p in pdfcraft_model::pages(doc) { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + refs.extend(a.iter().filter_map(Object::as_ref)); + } + } + refs +} + +/// Removes annotation `r` from every page's `/Annots` list and replaces its indirect object with `Null`. +fn remove_annot_from_pages(doc: &mut Document, r: ObjRef) -> Result<(), pdfcraft_cos::CosError> { + for p in pdfcraft_model::pages(doc) { + let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + if annots.iter().any(|a| a.as_ref() == Some(r)) { + let kept: Vec = annots.into_iter().filter(|a| a.as_ref() != Some(r)).collect(); + doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?; + } + } + doc.set(r, Object::Null); + Ok(()) +} + +fn fix_annot_or_field(doc: &Document, nd: &mut Dict, is_annot: bool, sub: Option<&[u8]>, actions: &mut usize, flags: &mut usize) -> bool { + let mut changed = strip_actions(doc, nd); + *actions += changed as usize; + if is_annot && sub != Some(b"Popup") { + let f = nd.int(b"F").unwrap_or(0); + let want = (f | 4) & !(1 | 2 | 32); + if want != f { + nd.set(b"F".to_vec(), Object::Int(want)); + *flags += 1; + changed = true; + } + } + changed +} + +fn convert_direct_page_annots( + doc: &mut Document, + page: &pdfcraft_model::Page, + actions: &mut usize, + flags: &mut usize, + removed: &mut usize, +) -> Result<(), pdfcraft_cos::CosError> { + let Some(annots) = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { + return Ok(()); + }; + if !annots.iter().any(|a| a.as_dict().is_some()) { + return Ok(()); + } + let mut kept = Vec::with_capacity(annots.len()); + let mut changed = false; + for entry in annots { + let Object::Dict(mut nd) = entry else { + kept.push(entry); + continue; + }; + let ty = nd.name(b"Type").map(<[u8]>::to_vec); + let sub = nd.name(b"Subtype").map(<[u8]>::to_vec); + let is_annot = is_annotation(doc, &nd, ty.as_deref(), sub.as_deref(), true); + if is_annot && sub.as_deref().is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { + *removed += 1; + changed = true; + continue; + } + if is_annot || nd.get(b"FT").is_some() { + changed |= fix_annot_or_field(doc, &mut nd, is_annot, sub.as_deref(), actions, flags); + } + kept.push(Object::Dict(nd)); + } + if changed { + doc.update_dict(page.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?; + } + Ok(()) +} + /// Make `doc` conform to `level` as far as possible, then re-check it. pub fn convert(doc: &mut Document, level: Level) -> Result { let mut fixed = Vec::new(); @@ -1293,45 +1425,26 @@ pub fn convert(doc: &mut Document, level: Level) -> Result::to_vec); let sub = d.name(b"Subtype").map(<[u8]>::to_vec); - let is_annot = ty.as_deref() == Some(b"Annot"); + let is_annot = matches!(&*o, Object::Dict(_)) && is_annotation(doc, &d, ty.as_deref(), sub.as_deref(), page_annots.contains(&r)); if is_annot && sub.as_deref().is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { - // Take it off its page. - for p in pdfcraft_model::pages(doc) { - let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); - if annots.iter().any(|a| a.as_ref() == Some(r)) { - let kept: Vec = annots.into_iter().filter(|a| a.as_ref() != Some(r)).collect(); - doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?; - } - } - // The object stays in the file unless it is replaced: verify scans every object, so a - // forbidden annotation left behind would still be reported (clause 6.3.1). - doc.set(r, Object::Null); + remove_annot_from_pages(doc, r)?; removed += 1; continue; } if matches!(&*o, Object::Dict(_)) && (is_annot || d.get(b"FT").is_some()) { let mut nd = d.clone(); - let mut changed = strip_actions(doc, &mut nd); - actions += changed as usize; - if is_annot && sub.as_deref() != Some(b"Popup") { - let f = nd.int(b"F").unwrap_or(0); - let want = (f | 4) & !(1 | 2 | 32); - if want != f { - nd.set(b"F".to_vec(), Object::Int(want)); - flags += 1; - changed = true; - } - } - if changed { + if fix_annot_or_field(doc, &mut nd, is_annot, sub.as_deref(), &mut actions, &mut flags) { doc.set_existing(r, Object::Dict(nd))?; } continue; diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index e0107d9f21..fca88769e0 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -495,6 +495,89 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { } } +#[test] +fn typeless_annotations_indexed_cmyk_and_indirect_filter_elements_are_handled() { + // Annotations in /Annots that omit optional /Type /Annot and /P (while keeping required /Subtype + // and /Rect), whether indirect or direct dictionaries, are still verified and fixed by convert. + // A malformed /Annots dictionary without /Type /Annot and without /Rect is not treated as an + // annotation or destructively removed by convert. + let mut doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 8 0 R << /Subtype /Movie /Rect [60 10 70 30] >> << /Subtype /Sound >> << /Subtype /Sound /Rect null >> << /Subtype /Sound /Rect 9 0 R >>] >>", + "<< /Subtype /Text /Rect [10 10 30 30] /P 3 0 R /AP << /N 7 0 R >> >>", + "<< /Subtype /Sound /Rect [50 10 60 30] >>", + "<< /Subtype /Widget /FT /Tx /T (f) /Rect [70 10 90 30] /F 4 /AA << /E << /S /URI /URI (https://example.com) >> >> /AP << /N 7 0 R >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream", + "<< /Subtype /Movie >>", + "null", + ], + "", + ); + let issues = verify(&doc, Level::A2b); + let msgs = messages(&issues); + assert!(msgs.contains(&"A Text annotation isn't set to print, or is hidden"), "{msgs:#?}"); + assert!(msgs.contains(&"Sound annotations aren't allowed"), "{msgs:#?}"); + assert!(msgs.contains(&"Movie annotations aren't allowed"), "{msgs:#?}"); + assert!(msgs.contains(&"A Widget annotation has additional actions"), "{msgs:#?}"); + assert!(!msgs.contains(&"A form field has additional actions"), "{msgs:#?}"); + let r = convert(&mut doc, Level::A2b).unwrap(); + assert!(r.remaining.is_empty(), "{:#?}", r.remaining); + // The two /Rect-bearing forbidden annotations (5 0 R and the inline /Movie with /Rect) were + // removed, while the /Rect-less or null-/Rect dictionaries (8 0 R and the inline /Sound + // entries) without /Type /Annot are left untouched rather than destructively stripped. + assert_eq!(&*doc.get(ObjRef::new(5, 0)), &Object::Null); + assert!(doc.get(ObjRef::new(8, 0)).as_dict().is_some()); + let page_annots = doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.get(b"Annots")).and_then(Object::as_array).cloned().unwrap(); + assert_eq!(page_annots.len(), 6, "{page_annots:#?}"); + assert!(page_annots.iter().any(|entry| entry.as_ref() == Some(ObjRef::new(8, 0)))); + assert_eq!(page_annots.iter().filter(|entry| entry.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Sound"))).count(), 3); + + // Indexed and Separation colour spaces over DeviceCMYK on image XObjects or /ColorSpace resources count as CMYK. + let indexed_img = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>", + "<< /Length 7 >>\nstream\n/Im0 Do\nendstream", + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace [/Indexed /DeviceCMYK 0 <00000000>] /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream", + ], + "", + ); + assert!(uses_cmyk(&indexed_img)); + + let res_cmyk = drawing("/CS0 cs 0 sc 0 0 10 10 re f", "/ColorSpace << /CS0 [/Indexed /DeviceCMYK 0 <00000000>] >>", &[]); + assert!(uses_cmyk(&res_cmyk)); + let pattern_cmyk = drawing("0 0 10 10 re f", "/ColorSpace << /CS0 [/Pattern /DeviceCMYK] >>", &[]); + assert!(uses_cmyk(&pattern_cmyk)); + let chained_cs = drawing("0 0 10 10 re f", "/ColorSpace << /CS0 5 0 R >>", &["6 0 R", "[7 0 R 8 0 R]", "/Pattern", "/DeviceCMYK"]); + assert!(uses_cmyk(&chained_cs)); + + // An indirect filter name inside a /Filter array or the /LZW abbreviation is still checked for LZWDecode. + let indirect_lzw = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + "<< /Filter [5 0 R] /Length 0 >>\nstream\n\nendstream", + "/LZWDecode", + ], + "", + ); + assert!(messages(&verify(&indirect_lzw, Level::A2b)).contains(&"A stream uses LZW compression")); + let abbrev_lzw = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + "<< /Filter /LZW /Length 0 >>\nstream\n\nendstream", + ], + "", + ); + assert!(messages(&verify(&abbrev_lzw, Level::A2b)).contains(&"A stream uses LZW compression")); +} + /// PDF/A-2 6.1.13 holds a file to the implementation limits of ISO 32000-1 Annex C (Table C.1): /// an integer past 32 bits, a string over 32767 bytes or a name over 127 bytes (decoded: a name /// written with `#xx` escapes counts its bytes once read), in an object or in content; and a page