mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 23:32:08 +00:00
# Conflicts: # crates/preflight/src/lib.rs # crates/preflight/src/tests.rs # parity/acrobat-features.toml
2163 lines
128 KiB
Rust
2163 lines
128 KiB
Rust
use super::*;
|
||
|
||
fn build(objs: &[&str], trailer_extra: &str) -> Document {
|
||
build_with_header(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n", objs, trailer_extra)
|
||
}
|
||
|
||
fn build_with_header(header: &[u8], objs: &[&str], trailer_extra: &str) -> Document {
|
||
let mut out = header.to_vec();
|
||
let mut offsets = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offsets.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let xref = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offsets {
|
||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
Document::open(std::sync::Arc::new(out)).unwrap()
|
||
}
|
||
|
||
/// A page with embedded-font-free text, a note without the print flag, a link with a
|
||
/// JavaScript action, an interpolated image, and a document JavaScript.
|
||
fn messy() -> Document {
|
||
build(
|
||
&[
|
||
"<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript << /Names [(x) 9 0 R] >> >> /AA << /WC 9 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 << /Font << /F1 5 0 R >> /XObject << /Im 8 0 R >> >> /Annots [6 0 R 7 0 R] >>",
|
||
"<< /Length 44 >>\nstream\n1 0 0 rg BT /F1 12 Tf 10 10 Td (Hi) Tj ET /Im Do\nendstream",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||
"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /P 3 0 R /AP << /N 10 0 R >> >>",
|
||
"<< /Type /Annot /Subtype /Link /Rect [40 10 80 30] /P 3 0 R /F 4 /A 9 0 R >>",
|
||
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Length 1 >>\nstream\n\u{0}\nendstream",
|
||
"<< /Type /Action /S /JavaScript /JS (app.alert(1)) >>",
|
||
"<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream",
|
||
],
|
||
"/Info << /Title (Report) /Producer (Test) /CreationDate (D:20240105093000Z) >>",
|
||
)
|
||
}
|
||
|
||
fn clauses(issues: &[Issue]) -> Vec<&'static str> {
|
||
let mut c: Vec<&str> = issues.iter().map(|i| i.clause).collect();
|
||
c.dedup();
|
||
c
|
||
}
|
||
|
||
#[test]
|
||
fn verify_reports_what_breaks_pdfa() {
|
||
let issues = verify(&messy(), Level::A2b);
|
||
let msgs: Vec<&str> = issues.iter().map(|i| i.message.as_str()).collect();
|
||
for want in [
|
||
"The document has no XMP metadata",
|
||
"Device colour is used, but there is no PDF/A output intent",
|
||
"The document has additional actions (/AA)",
|
||
"The document has document-level JavaScript",
|
||
"A JavaScript action isn't allowed",
|
||
"A Text annotation isn't set to print, or is hidden",
|
||
"The font Helvetica isn't embedded",
|
||
"An image asks to be interpolated",
|
||
] {
|
||
assert!(msgs.contains(&want), "missing {want:?} in {msgs:#?}");
|
||
}
|
||
assert!(issues.iter().find(|i| i.message.starts_with("The font")).is_some_and(|i| !i.fixable));
|
||
assert!(clauses(&issues).contains(&"6.6.2.1"));
|
||
// Each problem is also worded separately from what the document calls things, so an interface
|
||
// can say it in another language; in English the two come to the same sentence.
|
||
for i in &issues {
|
||
assert_eq!(i.said(&in_english), i.message);
|
||
assert!(WORDINGS.contains(&i.pattern), "{:?} is not in WORDINGS", i.pattern);
|
||
}
|
||
let font = issues.iter().find(|i| i.pattern == "The font {} isn't embedded").unwrap();
|
||
assert_eq!(font.said(&|w: &str| format!("«{w}»")), "«The font {} isn't embedded»".replace("{}", "Helvetica"));
|
||
}
|
||
|
||
/// Every problem the rules can report is listed in `WORDINGS`, so an interface showing them in
|
||
/// another language has something to translate. The wording is `verify`'s second argument, which is
|
||
/// what this reads off the file; the sentences `convert` reports as fixed are not wordings.
|
||
#[test]
|
||
fn wordings_are_complete() {
|
||
let source = include_str!("lib.rs");
|
||
let mut said = Vec::new();
|
||
let mut rest = source;
|
||
// `issue(` and `located_issue(`, written on one line or, when rustfmt breaks it, on several.
|
||
while let Some(at) = rest.find("issue(") {
|
||
rest = &rest[at + 6..];
|
||
let Some(clause) = rest.trim_start().strip_prefix('"') else { continue };
|
||
// Step over the clause, then take the wording that follows it.
|
||
let Some(end) = clause.find('"') else { break };
|
||
rest = &clause[end + 1..];
|
||
let Some(text) = rest.strip_prefix(',').map(str::trim_start).and_then(|r| r.strip_prefix('"')) else { continue };
|
||
let Some(end) = text.find('"') else { break };
|
||
said.push(&text[..end]);
|
||
rest = text;
|
||
}
|
||
assert!(said.len() >= 24, "the wordings are not being read off the file: {said:#?}");
|
||
let missing: Vec<&&str> = said.iter().filter(|word| !WORDINGS.contains(word)).collect();
|
||
assert!(missing.is_empty(), "add these to WORDINGS, and a translation of each to every interface: {missing:#?}");
|
||
let unsaid: Vec<&&str> = WORDINGS.iter().filter(|word| !said.contains(word)).collect();
|
||
assert!(unsaid.is_empty(), "WORDINGS lists problems the rules no longer report: {unsaid:#?}");
|
||
}
|
||
|
||
#[test]
|
||
fn convert_fixes_all_but_fonts() {
|
||
let mut doc = messy();
|
||
let r = convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(r.fixed.iter().any(|f| f.contains("output intent")), "{:?}", r.fixed);
|
||
let left: Vec<&str> = r.remaining.iter().map(|i| i.message.as_str()).collect();
|
||
assert_eq!(left, ["The font Helvetica isn't embedded"], "{:#?}", r.remaining);
|
||
let d = declared(&doc);
|
||
assert_eq!(d.pdfa, Some((2, "B".into())));
|
||
assert_eq!(d.output_intents, ["sRGB IEC61966-2.1"]);
|
||
// The metadata carries the document information.
|
||
let x = metadata(&doc).unwrap();
|
||
assert_eq!(xmp::value(&x, "dc:title").as_deref(), Some("Report"));
|
||
assert_eq!(xmp::value(&x, "xmp:CreateDate").as_deref(), Some("2024-01-05T09:30:00Z"));
|
||
// It survives a save.
|
||
let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
|
||
let back = Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||
assert_eq!(verify(&back, Level::A2b).len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn embedded_files_differ_between_parts() {
|
||
let doc = build(
|
||
&[
|
||
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(a.txt) 4 0 R] >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||
"<< /Type /Page /Parent 2 0 R >>",
|
||
"<< /Type /Filespec /F (a.txt) /EF << /F 5 0 R >> >>",
|
||
"<< /Type /EmbeddedFile /Length 2 >>\nstream\nhi\nendstream",
|
||
],
|
||
"",
|
||
);
|
||
assert!(verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.8" && !i.fixable));
|
||
let mut d3 = doc.clone();
|
||
assert!(verify(&d3, Level::A3b).iter().any(|i| i.message.contains("AFRelationship")));
|
||
let r = convert(&mut d3, Level::A3b).unwrap();
|
||
assert!(r.remaining.is_empty(), "{:#?}", r.remaining);
|
||
}
|
||
|
||
/// A page that paints a DeviceCMYK image (as `doc_create` makes from a CMYK JPEG), with
|
||
/// `intent` as the catalog's extra entries and `extra` as more objects (from object 6 on).
|
||
fn cmyk_page(intent: &str, content: &str, extra: &[&str]) -> Document {
|
||
let catalog = format!("<< /Type /Catalog /Pages 2 0 R {intent} >>");
|
||
let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
|
||
let mut objs = vec![
|
||
catalog.as_str(),
|
||
"<< /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 >> >> >>",
|
||
stream.as_str(),
|
||
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\nendstream",
|
||
];
|
||
objs.extend_from_slice(extra);
|
||
build(&objs, "")
|
||
}
|
||
|
||
fn messages(issues: &[Issue]) -> Vec<&str> {
|
||
issues.iter().map(|i| i.message.as_str()).collect()
|
||
}
|
||
|
||
/// Issue #667: converting a CMYK-only document doesn't add an sRGB output intent (it would
|
||
/// describe a colour space the document doesn't use); the CMYK output intent it needs is
|
||
/// reported as not fixable instead.
|
||
#[test]
|
||
fn convert_does_not_give_cmyk_documents_an_srgb_output_intent() {
|
||
let mut doc = cmyk_page("", "q 200 0 0 200 0 0 cm /Im0 Do Q", &[]);
|
||
let r = convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(!r.fixed.iter().any(|f| f.contains("output intent")), "{:?}", r.fixed);
|
||
assert!(declared(&doc).output_intents.is_empty());
|
||
let left = messages(&r.remaining);
|
||
assert!(left.contains(&"Device colour is used, but there is no PDF/A output intent"), "{left:#?}");
|
||
assert!(left.contains(&"DeviceCMYK is used: it needs a CMYK output intent (not added automatically)"), "{left:#?}");
|
||
assert!(r.remaining.iter().all(|i| !i.fixable));
|
||
}
|
||
|
||
/// Issue #667: verify checks the output intent's colour space against the device colour the
|
||
/// pages use, not only that an output intent exists.
|
||
#[test]
|
||
fn verify_reports_an_output_intent_that_does_not_match_device_colour() {
|
||
let intent = "/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (X) /DestOutputProfile 6 0 R >>]";
|
||
let profile = |n: u8| format!("<< /N {n} /Length 0 >>\nstream\n\nendstream");
|
||
let (rgb, cmyk) = (profile(3), profile(4));
|
||
let cmyk_msg = "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile";
|
||
let rgb_msg = "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile";
|
||
|
||
// CMYK content under an sRGB output intent (what convert used to write).
|
||
let issues = verify(&cmyk_page(intent, "/Im0 Do", &[&rgb]), Level::A2b);
|
||
assert!(messages(&issues).contains(&cmyk_msg), "{issues:#?}");
|
||
assert!(issues.iter().find(|i| i.message == cmyk_msg).is_some_and(|i| i.clause == "6.2.4.3" && !i.fixable));
|
||
|
||
// CMYK content under a CMYK output intent is fine; RGB and gray content beside it is not
|
||
// and is, respectively.
|
||
assert!(!messages(&verify(&cmyk_page(intent, "/Im0 Do 0 g", &[&cmyk]), Level::A2b)).iter().any(|m| m.contains("output intent")));
|
||
let issues = verify(&cmyk_page(intent, "/Im0 Do 1 0 0 rg", &[&cmyk]), Level::A2b);
|
||
assert!(messages(&issues).contains(&rgb_msg) && !messages(&issues).contains(&cmyk_msg), "{issues:#?}");
|
||
|
||
// DefaultCMYK in the page's resources draws DeviceCMYK in that space, which PDF/A accepts
|
||
// under any output intent.
|
||
let catalog = format!("<< /Type /Catalog /Pages 2 0 R {intent} >>");
|
||
let doc = build(
|
||
&[
|
||
catalog.as_str(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /ColorSpace << /DefaultCMYK [/ICCBased 7 0 R] >> /XObject << /Im0 5 0 R >> >> >>",
|
||
"<< /Length 7 >>\nstream\n/Im0 Do\nendstream",
|
||
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\nendstream",
|
||
rgb.as_str(),
|
||
cmyk.as_str(),
|
||
],
|
||
"",
|
||
);
|
||
assert!(!messages(&verify(&doc, Level::A2b)).contains(&cmyk_msg), "DefaultCMYK covers DeviceCMYK");
|
||
|
||
// Without /N, the ICC header's colour space decides.
|
||
let mut header = vec![b' '; 128];
|
||
header[16..20].copy_from_slice(b"CMYK");
|
||
let header = String::from_utf8(header).unwrap();
|
||
let headed = format!("<< /Length 128 >>\nstream\n{header}\nendstream");
|
||
assert!(!messages(&verify(&cmyk_page(intent, "/Im0 Do", &[&headed]), Level::A2b)).contains(&cmyk_msg));
|
||
}
|
||
|
||
/// Issue #1334: a colour operator is found whichever of the six PDF whitespace characters
|
||
/// separates the tokens, not only after a space or a line feed.
|
||
#[test]
|
||
fn device_colour_is_found_after_any_whitespace() {
|
||
let page = |content: &[u8]| {
|
||
let content = String::from_utf8(content.to_vec()).unwrap();
|
||
let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
|
||
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 << >> >>",
|
||
stream.as_str(),
|
||
],
|
||
"",
|
||
)
|
||
};
|
||
let cmyk_msg = "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)";
|
||
for separator in [b" ".as_slice(), b"\n", b"\r", b"\t", b"\x0C", b"\0"] {
|
||
let cmyk: &[&[u8]] = &[b"0", b"0", b"0", b"1", b"k", b"10", b"10", b"50", b"50", b"re", b"f"];
|
||
let mut doc = page(&cmyk.join(separator));
|
||
assert!(messages(&verify(&doc, Level::A2b)).contains(&cmyk_msg), "separator {separator:?}");
|
||
let r = convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(declared(&doc).output_intents.is_empty(), "separator {separator:?}");
|
||
assert!(messages(&r.remaining).contains(&cmyk_msg), "separator {separator:?}");
|
||
|
||
// Gray and RGB still get the sRGB output intent.
|
||
for colour in [[b"0".as_slice(), b"g"].as_slice(), &[b"1", b"0", b"0", b"rg"]] {
|
||
let mut doc = page(&colour.join(separator));
|
||
let r = convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(r.fixed.iter().any(|f| f.contains("output intent")), "separator {separator:?}: {:?}", r.fixed);
|
||
assert_eq!(declared(&doc).output_intents, ["sRGB IEC61966-2.1"], "separator {separator:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A page that draws `content` with `resources`, followed by `extra` objects (from object 5 on).
|
||
fn drawing(content: &str, resources: &str, extra: &[&str]) -> Document {
|
||
let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
|
||
let page = format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << {resources} >> >>");
|
||
let mut objs = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||
page.as_str(),
|
||
stream.as_str(),
|
||
];
|
||
objs.extend_from_slice(extra);
|
||
build(&objs, "")
|
||
}
|
||
|
||
fn uses_cmyk(doc: &Document) -> bool {
|
||
messages(&verify(doc, Level::A2b)).contains(&"DeviceCMYK is used: it needs a CMYK output intent (not added automatically)")
|
||
}
|
||
|
||
/// Issue #2259: a `k` is DeviceCMYK only when it is an operator. Inside a string, a comment, an inline image's
|
||
/// data, a font program, an ICC profile or a mesh shading's data it is just a byte, and the
|
||
/// document still gets its sRGB output intent.
|
||
#[test]
|
||
fn device_colour_is_read_from_operators_not_bytes() {
|
||
let rgb = "1 0 0 rg 0 0 10 10 re f";
|
||
let cases: &[(&str, String, &str, Vec<&str>)] = &[
|
||
(
|
||
"a string",
|
||
format!("{rgb} BT /F1 12 Tf 10 10 Td (press k now) Tj ET"),
|
||
"/Font << /F1 5 0 R >>",
|
||
vec![
|
||
"<< /Type /Font /Subtype /TrueType /BaseFont /Sans /FontDescriptor 6 0 R >>",
|
||
"<< /Type /FontDescriptor /FontName /Sans /FontFile2 7 0 R >>",
|
||
// A font program whose bytes read " k ", as Chromium and krilla write them.
|
||
"<< /Length1 3 /Length 3 >>\nstream\n k \nendstream",
|
||
],
|
||
),
|
||
("a comment", format!("{rgb} % 0 0 0 1 k"), "", vec![]),
|
||
// A 3×1 gray image whose three bytes read " k ".
|
||
("inline image data", format!("{rgb} q 10 0 0 10 0 0 cm BI /W 3 /H 1 /CS /G /BPC 8 ID k EI Q"), "", vec![]),
|
||
("an ICC profile", rgb.to_owned(), "/ColorSpace << /CS0 [/ICCBased 5 0 R] >>", vec!["<< /N 3 /Length 3 >>\nstream\n k \nendstream"]),
|
||
(
|
||
"a mesh shading",
|
||
format!("{rgb} /Sh0 sh"),
|
||
"/Shading << /Sh0 5 0 R >>",
|
||
vec![
|
||
"<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 1 0 1 0 1 0 1 0 1] /BBox [0 0 10 10] /Length 3 >>\nstream\n k \nendstream",
|
||
],
|
||
),
|
||
];
|
||
for (what, content, resources, extra) in cases {
|
||
let mut doc = drawing(content, resources, extra);
|
||
assert!(!uses_cmyk(&doc), "a k in {what} was read as DeviceCMYK");
|
||
let r = convert(&mut doc, Level::A2b).unwrap();
|
||
assert_eq!(declared(&doc).output_intents, ["sRGB IEC61966-2.1"], "{what}: {:?}", r.fixed);
|
||
}
|
||
}
|
||
|
||
/// Issue #2259: DeviceCMYK is found however the content sets it: `k`/`K` right before a delimiter, `cs`/`CS`
|
||
/// with the space's name, or an inline image in `/CMYK` or indexed over it.
|
||
#[test]
|
||
fn device_colour_is_found_however_content_sets_it() {
|
||
for content in [
|
||
"0 0 0 1 k/GS0 gs 10 10 50 50 re f",
|
||
"0 0 0 1 K[]0 d 10 10 m 50 50 l S",
|
||
"/DeviceCMYK cs 0 0 0 1 sc 10 10 50 50 re f",
|
||
"/DeviceCMYK CS 0 0 0 1 SC 10 10 m 50 50 l S",
|
||
"q 10 0 0 10 0 0 cm BI /W 1 /H 1 /CS /CMYK /BPC 8 ID \u{0}\u{0}\u{0}\u{0} EI Q",
|
||
"q 10 0 0 10 0 0 cm BI /W 1 /H 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8 ID \u{0}\u{0}\u{0}\u{0} EI Q",
|
||
"q 10 0 0 10 0 0 cm BI /W 1 /H 1 /CS [/I /DeviceCMYK 0 <00000000>] /BPC 8 ID \u{0} EI Q",
|
||
] {
|
||
assert!(uses_cmyk(&drawing(content, "", &[])), "{content:?}");
|
||
}
|
||
// A resource named like a device space's abbreviation is a resource, not that space.
|
||
let named =
|
||
drawing("/CMYK cs 1 sc 10 10 50 50 re f", "/ColorSpace << /CMYK [/ICCBased 5 0 R] >>", &["<< /N 1 /Length 0 >>\nstream\n\nendstream"]);
|
||
assert!(!uses_cmyk(&named));
|
||
}
|
||
|
||
/// Issue #2259: content is not only the page's own stream: form XObjects (with `/Type /XObject`, as almost
|
||
/// every writer gives them), appearance streams, tiling patterns and Type 3 glyphs draw too. A
|
||
/// CMYK logo placed as a form must not get the document an sRGB output intent.
|
||
#[test]
|
||
fn device_colour_in_forms_patterns_and_glyphs_is_found() {
|
||
let cmyk = "0.75 0 1 0 k 0 0 10 10 re f";
|
||
let stream = |dict: &str| format!("<< {dict} /Length {} >>\nstream\n{cmyk}\nendstream", cmyk.len());
|
||
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]");
|
||
let appearance = stream("/BBox [0 0 10 10]");
|
||
let pattern = stream("/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>");
|
||
let glyph = format!("<< /Length {} >>\nstream\n1 0 d0 {cmyk}\nendstream", cmyk.len() + 7);
|
||
let cases: [(&str, Document); 4] = [
|
||
("a form XObject", drawing("/Fm0 Do", "/XObject << /Fm0 5 0 R >>", &[&form])),
|
||
(
|
||
"an appearance stream without /Subtype",
|
||
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] >>",
|
||
"<< /Type /Annot /Subtype /Square /Rect [0 0 10 10] /F 4 /P 3 0 R /AP << /N 5 0 R >> >>",
|
||
&appearance,
|
||
],
|
||
"",
|
||
),
|
||
),
|
||
("a tiling pattern", drawing("/Pattern cs /P0 scn 0 0 100 100 re f", "/Pattern << /P0 5 0 R >>", &[&pattern])),
|
||
(
|
||
"a Type 3 glyph in an inline font",
|
||
drawing(
|
||
"BT /T3 1 Tf (a) Tj ET",
|
||
"/Font << /T3 << /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [1 0 0 1 0 0] /CharProcs << /a 5 0 R >> /Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] >> >>",
|
||
&[&glyph],
|
||
),
|
||
),
|
||
];
|
||
for (what, mut doc) in cases {
|
||
assert!(uses_cmyk(&doc), "DeviceCMYK in {what} wasn't found");
|
||
let r = convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(declared(&doc).output_intents.is_empty(), "{what}: an sRGB output intent was added to a CMYK document: {:?}", r.fixed);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn standards_identification_uses_namespace_not_prefix() {
|
||
fn with_xmp(xmp: &str) -> Document {
|
||
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
|
||
build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "")
|
||
}
|
||
let attribute = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:a="http://www.aiim.org/pdfa/ns/id/" xmlns:u="http://www.aiim.org/pdfua/ns/id/" a:part="2" a:conformance="b" u:part="1"/></rdf:RDF>"#;
|
||
let element = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description><part xmlns="http://www.aiim.org/pdfa/ns/id/">2</part><x:conformance xmlns:x="http://www.aiim.org/pdfa/ns/id/" >B</x:conformance></rdf:Description></rdf:RDF>"#;
|
||
for xml in [attribute, element] {
|
||
let doc = with_xmp(xml);
|
||
assert_eq!(declared(&doc).pdfa, Some((2, "B".into())));
|
||
assert!(!verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4"));
|
||
}
|
||
assert_eq!(declared(&with_xmp(attribute)).pdfua, Some(1));
|
||
let wrong = r#"<root xmlns:pdfaid="urn:wrong" pdfaid:part="2" pdfaid:conformance="B"/>"#;
|
||
let doc = with_xmp(wrong);
|
||
assert_eq!(declared(&doc).pdfa, None);
|
||
assert!(verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4"));
|
||
let nested = format!("{}{}", "<n>".repeat(1100), "</n>".repeat(1100));
|
||
assert_eq!(declared(&with_xmp(&nested)).pdfa, None);
|
||
}
|
||
|
||
#[test]
|
||
fn oversized_xmp_is_refused_while_decoding() {
|
||
let xmp = " ".repeat(xmp::MAX_XMP_BYTES + 1);
|
||
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
|
||
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
|
||
assert!(metadata(&doc).is_none());
|
||
assert_eq!(declared(&doc).pdfa, None);
|
||
|
||
use std::io::Write as _;
|
||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||
z.write_all(xmp.as_bytes()).unwrap();
|
||
let compressed = z.finish().unwrap();
|
||
let hex: String = compressed.iter().map(|byte| format!("{byte:02X}")).collect();
|
||
let data = format!("{hex}>");
|
||
let stream =
|
||
format!("<< /Type /Metadata /Subtype /XML /Filter [/ASCIIHexDecode /FlateDecode] /Length {} >>\nstream\n{data}\nendstream", data.len());
|
||
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
|
||
let cat = catalog(&doc);
|
||
let raw = doc.resolve(cat.get(b"Metadata").unwrap());
|
||
let Object::Stream(stream) = &*raw else { panic!("fixture has no metadata stream") };
|
||
assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress");
|
||
assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding");
|
||
}
|
||
|
||
/// Content is read one operator at a time and no further than it needs: once gray, RGB and CMYK
|
||
/// are all set, the rest of the stream is not read.
|
||
#[test]
|
||
fn content_colour_reads_operators_one_at_a_time_and_stops_when_all_are_found() {
|
||
let mut data = b"0 g 0 0 0 rg 0 0 0 0 k\n".to_vec();
|
||
data.extend(b"n\n".repeat(100_000));
|
||
let mut used = DeviceColour::default();
|
||
let read = content_colour(&data, &mut used);
|
||
assert!(used.gray && used.rgb && used.cmyk);
|
||
assert_eq!(read, 3);
|
||
}
|
||
|
||
/// A forbidden action chained by `/Next` behind an allowed one, written directly inside its
|
||
/// parent, is reported and removed like any other.
|
||
#[test]
|
||
fn forbidden_actions_chained_behind_allowed_ones_are_found_and_removed() {
|
||
let js = "<< /S /JavaScript /JS (app.alert(1)) >>";
|
||
let page = "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>";
|
||
let pages = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>";
|
||
let link = |a: &str| format!("<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R /F 4 /A {a} >>");
|
||
let open_action = format!("<< /Type /Catalog /Pages 2 0 R /OpenAction << /S /GoTo /D [3 0 R /Fit] /Next {js} >> >>");
|
||
let goto = format!("<< /Type /Action /S /GoTo /D [3 0 R /Fit] /Next [{js}] >>");
|
||
let cases = [
|
||
build(&[&open_action, pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] >>")], ""),
|
||
build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("5 0 R"), &goto], ""),
|
||
build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link(&format!("<< /S /GoTo /D [3 0 R /Fit] /Next {js} >>"))], ""),
|
||
// `/Next` refers to an array that holds the action directly.
|
||
build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] /Next 5 0 R >>"), &format!("[{js}]")], ""),
|
||
];
|
||
for (i, mut doc) in cases.into_iter().enumerate() {
|
||
let found = messages(&verify(&doc, Level::A2b)).contains(&"A JavaScript action isn't allowed");
|
||
assert!(found, "case {i}: {:#?}", verify(&doc, Level::A2b));
|
||
let r = convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(!r.remaining.iter().any(|x| x.clause == "6.5.1"), "case {i}: {:#?}", r.remaining);
|
||
let plain = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() };
|
||
let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap();
|
||
assert!(!bytes.windows(11).any(|w| w == b"/JavaScript"), "case {i}: {}", String::from_utf8_lossy(&bytes));
|
||
}
|
||
}
|
||
|
||
/// Each annotation's page is found from one pass over the pages' `/Annots`, not by searching
|
||
/// every page's list again for each annotation.
|
||
#[test]
|
||
fn annotation_pages_are_found_in_one_pass() {
|
||
let n = 2_000;
|
||
let annots: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 4)).collect();
|
||
let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", annots.join(" "));
|
||
let mut objs =
|
||
vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), page];
|
||
objs.extend((0..n).map(|_| "<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R >>".to_string()));
|
||
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||
let doc = build(&refs, "");
|
||
ANNOT_ENTRIES_READ.with(|c| c.set(0));
|
||
let issues = verify(&doc, Level::A2b);
|
||
assert!(issues.iter().any(|i| i.clause == "6.3.2" && i.page == Some(0)), "{issues:#?}");
|
||
let read = ANNOT_ENTRIES_READ.with(std::cell::Cell::get);
|
||
assert!(read <= 2 * n, "{read} entries read for {n} annotations");
|
||
}
|
||
|
||
#[test]
|
||
fn an_output_intent_profile_header_is_read_within_a_size_cap() {
|
||
let intent = "/OutputIntents [<< /S /GTS_PDFA1 /DestOutputProfile 2 0 R >>]";
|
||
for (size, n) in [(128, Some(4)), (MAX_ICC_PROFILE + 1, None)] {
|
||
let mut doc = build(&[&format!("<< /Type /Catalog {intent} >>"), "null"], "");
|
||
let mut profile = vec![0; size];
|
||
profile[16..20].copy_from_slice(b"CMYK");
|
||
doc.set(pdfcraft_cos::ObjRef::new(2, 0), Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &profile)));
|
||
assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile");
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
/// boundary under 3 or over 14400 units, read after inheritance, so a page that overrides its
|
||
/// parent's box is judged by its own.
|
||
#[test]
|
||
fn implementation_limits_are_reported() {
|
||
let limits =
|
||
|doc: &Document| -> Vec<String> { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() };
|
||
let doc = |extra: &str, content: &str, parent_box: &str, page_box: &str| {
|
||
let objects = [
|
||
format!("<< /Type /Catalog /Pages 2 0 R {extra} >>"),
|
||
format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox {parent_box} >>"),
|
||
format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R {page_box} >>"),
|
||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||
];
|
||
let refs: Vec<&str> = objects.iter().map(String::as_str).collect();
|
||
build(&refs, "")
|
||
};
|
||
let ok = "[0 0 200 200]";
|
||
assert!(limits(&doc("", "", ok, "")).is_empty(), "{:?}", limits(&doc("", "", ok, "")));
|
||
assert_eq!(limits(&doc("/Big 2147483648", "", ok, "")), ["An integer is outside the 32-bit range"]);
|
||
assert_eq!(limits(&doc("/Small -2147483649", "", ok, "")), ["An integer is outside the 32-bit range"]);
|
||
assert!(limits(&doc("/Edge -2147483648 /Top 2147483647", "", ok, "")).is_empty());
|
||
assert_eq!(limits(&doc("", "2147483648 0 Td", ok, "")), ["An integer is outside the 32-bit range"]);
|
||
let long = "x".repeat(32768);
|
||
assert_eq!(limits(&doc(&format!("/S ({long})"), "", ok, "")), ["A string is longer than 32767 bytes"]);
|
||
assert_eq!(limits(&doc("", &format!("BT ({long}) Tj ET"), ok, "")), ["A string is longer than 32767 bytes"]);
|
||
assert!(limits(&doc(&format!("/S ({})", "x".repeat(32767)), "", ok, "")).is_empty());
|
||
let name = "n".repeat(128);
|
||
assert_eq!(limits(&doc(&format!("/V /{name}"), "", ok, "")), ["A name is longer than 127 bytes"]);
|
||
assert_eq!(limits(&doc(&format!("/{name} 1"), "", ok, "")), ["A name is longer than 127 bytes"]);
|
||
assert_eq!(limits(&doc("", &format!("/{name} Do"), ok, "")), ["A name is longer than 127 bytes"]);
|
||
// 200 bytes as written, 66 once the escapes are read.
|
||
let escaped = format!("A{}", "#41".repeat(65) + &"B".repeat(4));
|
||
assert!(limits(&doc(&format!("/V /{escaped}"), "", ok, "")).is_empty());
|
||
assert_eq!(limits(&doc("", "", "[0 0 2 200]", "")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]);
|
||
assert_eq!(limits(&doc("", "", ok, "/CropBox [0 0 14401 200]")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]);
|
||
assert!(limits(&doc("", "", "[0 0 2 2]", "/MediaBox [0 0 3 14400]")).is_empty(), "the page's own box overrides its parent's");
|
||
}
|
||
|
||
/// 6.1.13 also reads the trailer, a page's `/Contents` streams joined (operands may end one
|
||
/// stream and their operator start the next, ISO 32000-1 7.8.2), values nested as deep as the
|
||
/// parser allows, and a page box whose size overflows to infinity.
|
||
#[test]
|
||
fn implementation_limits_reach_the_trailer_split_content_deep_values_and_huge_boxes() {
|
||
let limits =
|
||
|doc: &Document| -> Vec<String> { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() };
|
||
let stream = |data: &str| {
|
||
format!(
|
||
"<< /Length {} >>
|
||
stream
|
||
{data}
|
||
endstream",
|
||
data.len()
|
||
)
|
||
};
|
||
let page = |contents: &str, catalog_extra: &str, media: &str, trailer: &str| {
|
||
let (first, second) = contents.split_once('|').unwrap_or((contents, ""));
|
||
build(
|
||
&[
|
||
&format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"),
|
||
&format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox {media} >>"),
|
||
"<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] >>",
|
||
&stream(first),
|
||
&stream(second),
|
||
],
|
||
trailer,
|
||
)
|
||
};
|
||
let ok = "[0 0 200 200]";
|
||
assert!(limits(&page("|", "", ok, "")).is_empty());
|
||
assert_eq!(limits(&page("|", "", ok, "/Custom 2147483648")), ["An integer is outside the 32-bit range"]);
|
||
let long = "x".repeat(32768);
|
||
assert_eq!(limits(&page(&format!("BT ({long})|Tj ET"), "", ok, "")), ["A string is longer than 32767 bytes"]);
|
||
let deep = format!("/Deep {}2147483648{}", "[".repeat(40), "]".repeat(40));
|
||
assert_eq!(limits(&page("|", &deep, ok, "")), ["An integer is outside the 32-bit range"]);
|
||
// Corners near ±1e308 are reals past the limit too, and the width overflows to infinity.
|
||
assert_eq!(
|
||
limits(&page("|", "", "[-1e308 0 1e308 200]", "")),
|
||
[
|
||
"A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸",
|
||
"Page 1 has a boundary smaller than 3 or larger than 14400 units"
|
||
]
|
||
);
|
||
}
|
||
|
||
/// 6.1.13's two remaining limits of ISO 32000-1 Annex C: a real number beyond ±3.403 × 10³⁸ (or
|
||
/// nonzero and nearer zero than ±1.175 × 10⁻³⁸), and
|
||
/// a CID above 65535 that an embedded CMap of a Type 0 font maps to (a `begincidrange` reaching
|
||
/// past it, a `begincidchar` naming one, or a CMap it uses through `/UseCMap`).
|
||
#[test]
|
||
fn implementation_limits_cover_reals_and_cids() {
|
||
let limits =
|
||
|doc: &Document| -> Vec<String> { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() };
|
||
let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len());
|
||
let doc = |catalog_extra: &str, cmap: &str, used: &str| {
|
||
let first = stream("/Type /CMap /CMapName /Test-A /UseCMap 6 0 R", cmap);
|
||
let second = stream("/Type /CMap /CMapName /Test-B", used);
|
||
build(
|
||
&[
|
||
&format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Resources << /Font << /F 4 0 R >> >> >>",
|
||
"<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding 5 0 R /DescendantFonts [] >>",
|
||
&first,
|
||
&second,
|
||
],
|
||
"",
|
||
)
|
||
};
|
||
let range = |low: &str, high: &str, cid: u32| format!("1 begincidrange <{low}> <{high}> {cid} endcidrange");
|
||
let near = format!("34{}.0", "0".repeat(37));
|
||
let far = format!("4{}.0", "0".repeat(38));
|
||
assert!(limits(&doc(&format!("/R {near} /S -{near}"), &range("0000", "00ff", 65280), "")).is_empty(), "both at their limits");
|
||
assert_eq!(limits(&doc(&format!("/R {far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]);
|
||
assert_eq!(limits(&doc(&format!("/R -{far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]);
|
||
// A nonzero real nearer zero than ±1.175 × 10⁻³⁸ is not allowed either; zero and the
|
||
// smallest allowed magnitude are.
|
||
let tiny = format!("0.{}1", "0".repeat(39));
|
||
let smallest = format!("0.{}1175", "0".repeat(37));
|
||
assert!(limits(&doc(&format!("/Z 0.0 /M -0.0 /S {smallest} /T -{smallest}"), "", "")).is_empty(), "zero and the smallest real");
|
||
assert_eq!(limits(&doc(&format!("/R {tiny}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]);
|
||
assert_eq!(limits(&doc(&format!("/R -{tiny}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]);
|
||
let cid = ["A CMap maps a character code to a CID above 65535"];
|
||
assert_eq!(limits(&doc("", &range("3f00", "3fff", 65536), "")), cid);
|
||
assert_eq!(limits(&doc("", &range("0000", "0100", 65280), "")), cid, "the range reaches 65536");
|
||
assert_eq!(limits(&doc("", "1 begincidchar <01> 70000 endcidchar", "")), cid);
|
||
assert_eq!(limits(&doc("", &range("0000", "00ff", 0), &range("3f00", "3fff", 65536))), cid, "through /UseCMap");
|
||
}
|
||
|
||
/// PDF/A-2 6.2.4.4: Separation spaces of one colorant name agree on their alternate space and tint
|
||
/// transform, also inside a DeviceN space's `/Colorants`; and a DeviceN space names each spot
|
||
/// colorant it uses in its `/Colorants` (ISO 32000-1 8.6.6.5, Table 71). Process colorants (the
|
||
/// CMYK four, and an NChannel space's `/Process` components) and `None` need no entry.
|
||
#[test]
|
||
fn separation_and_devicen_spaces_are_checked() {
|
||
let spots = |spaces: &str| -> Vec<String> {
|
||
let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>");
|
||
let doc = build(
|
||
&[
|
||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||
&page,
|
||
"<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>",
|
||
"<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 1 0] /N 1 >>",
|
||
"<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>",
|
||
],
|
||
"",
|
||
);
|
||
verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect()
|
||
};
|
||
let red = |alt: &str, f: u32| format!("[/Separation /Red /{alt} {f} 0 R]");
|
||
// The same name, the same alternate and an identical tint transform (another object): fine.
|
||
assert!(spots(&format!("/A {} /B {}", red("DeviceRGB", 4), red("DeviceRGB", 6))).is_empty());
|
||
assert_eq!(
|
||
spots(&format!("/A {} /B {}", red("DeviceRGB", 4), red("DeviceRGB", 5))),
|
||
["Separation colour spaces named Red differ in their alternate space or tint transform"]
|
||
);
|
||
assert_eq!(
|
||
spots(&format!("/A {} /B {}", red("DeviceRGB", 4), red("DeviceGray", 4))),
|
||
["Separation colour spaces named Red differ in their alternate space or tint transform"]
|
||
);
|
||
// Inside two DeviceN spaces' Colorants.
|
||
let nchannel = |f: u32| format!("[/DeviceN [/Red] /DeviceRGB 4 0 R << /Subtype /NChannel /Colorants << /Red {} >> >>]", red("DeviceRGB", f));
|
||
assert_eq!(
|
||
spots(&format!("/A {} /B {}", nchannel(4), nchannel(5))),
|
||
["Separation colour spaces named Red differ in their alternate space or tint transform"]
|
||
);
|
||
// Spot colorants need a Colorants entry; process colorants and None don't.
|
||
assert_eq!(
|
||
spots("/A [/DeviceN [/Spot1 /Cyan /None] /DeviceCMYK 4 0 R]"),
|
||
["The spot colour Spot1 of a DeviceN colour space isn't in its Colorants dictionary"]
|
||
);
|
||
assert_eq!(
|
||
spots("/A [/DeviceN [/Spot1] /DeviceRGB 4 0 R << /Colorants << >> >>]"),
|
||
["The spot colour Spot1 of a DeviceN colour space isn't in its Colorants dictionary"]
|
||
);
|
||
assert!(spots(&format!("/A [/DeviceN [/Red /Black] /DeviceRGB 4 0 R << /Colorants << /Red {} >> >>]", red("DeviceRGB", 4))).is_empty());
|
||
assert!(
|
||
spots("/A [/DeviceN [/PrCyan /Black] /DeviceCMYK 4 0 R << /Subtype /NChannel /Process << /ColorSpace /DeviceCMYK /Components [/PrCyan /M /Y /Black] >> >>]")
|
||
.is_empty()
|
||
);
|
||
}
|
||
|
||
/// From the review of the 6.2.4.4 checks: nested parts compare by content, `All` and `None` get
|
||
/// no exemption, names may be given by reference, `/Process` exempts only in an NChannel space, a
|
||
/// null `/Colorants` entry defines nothing, and a colour space nested deep is still found.
|
||
#[test]
|
||
fn separation_and_devicen_checks_follow_references_depth_and_nchannel() {
|
||
let spots = |spaces: &str| -> Vec<String> {
|
||
let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>");
|
||
let icc = "<< /N 3 /Length 4 >>\nstream\nabcd\nendstream";
|
||
let doc = build(
|
||
&[
|
||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||
&page,
|
||
"<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>",
|
||
"<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 1 0] /N 1 >>",
|
||
icc,
|
||
icc,
|
||
"/Red",
|
||
"null",
|
||
],
|
||
"",
|
||
);
|
||
verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect()
|
||
};
|
||
let differ = |n: &str| vec![format!("Separation colour spaces named {n} differ in their alternate space or tint transform")];
|
||
let undefined = |n: &str| vec![format!("The spot colour {n} of a DeviceN colour space isn't in its Colorants dictionary")];
|
||
// Identical ICC streams under different numbers are the same alternate space.
|
||
assert!(spots("/A [/Separation /Red [/ICCBased 6 0 R] 4 0 R] /B [/Separation /Red [/ICCBased 7 0 R] 4 0 R]").is_empty());
|
||
assert_eq!(spots("/A [/Separation /All /DeviceRGB 4 0 R] /B [/Separation /All /DeviceRGB 5 0 R]"), differ("All"));
|
||
assert_eq!(spots("/A [/Separation 8 0 R /DeviceRGB 4 0 R] /B [/Separation /Red /DeviceRGB 5 0 R]"), differ("Red"));
|
||
assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Process << /Components [/Spot] >> >>]"), undefined("Spot"));
|
||
assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot null >> >>]"), undefined("Spot"));
|
||
assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot 9 0 R >> >>]"), undefined("Spot"));
|
||
let deep = format!("/A {}[/DeviceN [/Spot] /DeviceRGB 4 0 R]{}", "[".repeat(40), "]".repeat(40));
|
||
assert_eq!(spots(&deep), undefined("Spot"));
|
||
}
|
||
|
||
/// From the second review of the 6.2.4.4 checks: a `/Colorants` entry is a Separation space of
|
||
/// its own colorant, tint-transform streams compare by decoded data, and a structure shared by
|
||
/// many definitions is compared once rather than once per path through it.
|
||
#[test]
|
||
fn separation_checks_match_colorant_entries_decode_streams_and_share_comparisons() {
|
||
// Two copies of a chain of arrays, each level naming the next twice: comparing them path by
|
||
// path takes about 2^19 steps.
|
||
const LEVELS: usize = 18;
|
||
let chain = |first: usize| -> Vec<String> {
|
||
(0..LEVELS).map(|k| if k + 1 < LEVELS { format!("[{n} 0 R {n} 0 R]", n = first + k + 1) } else { "[1 2 3]".into() }).collect()
|
||
};
|
||
let (a, b) = (5, 5 + LEVELS);
|
||
let spots = |spaces: &str| -> Vec<String> {
|
||
let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>");
|
||
let mut objects = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(),
|
||
page,
|
||
"<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>".into(),
|
||
];
|
||
objects.extend(chain(a));
|
||
objects.extend(chain(b));
|
||
objects.push("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length 15 >>\nstream\n{ dup dup pop }\nendstream".into());
|
||
objects.push("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Filter /ASCIIHexDecode /Length 31 >>\nstream\n7B206475702064757020706F70207D>\nendstream".into());
|
||
objects.push("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length 15 >>\nstream\n{ dup pop dup }\nendstream".into());
|
||
let objects: Vec<&str> = objects.iter().map(String::as_str).collect();
|
||
let doc = build(&objects, "");
|
||
verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect()
|
||
};
|
||
let differ = vec!["Separation colour spaces named Red differ in their alternate space or tint transform".to_string()];
|
||
let undefined = vec!["The spot colour Spot of a DeviceN colour space isn't in its Colorants dictionary".to_string()];
|
||
// A Colorants entry that isn't a Separation space of the same colorant defines nothing.
|
||
assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot /DeviceRGB >> >>]"), undefined);
|
||
assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot [/Separation /Other /DeviceRGB 4 0 R] >> >>]"), undefined);
|
||
assert!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot [/Separation /Spot /DeviceRGB 4 0 R] >> >>]").is_empty());
|
||
// The same PostScript function, once plain and once hex-encoded, is the same tint transform.
|
||
let (plain, hex, other) = (5 + 2 * LEVELS, 6 + 2 * LEVELS, 7 + 2 * LEVELS);
|
||
assert!(spots(&format!("/A [/Separation /Red /DeviceRGB {plain} 0 R] /B [/Separation /Red /DeviceRGB {hex} 0 R]")).is_empty());
|
||
assert_eq!(spots(&format!("/A [/Separation /Red /DeviceRGB {plain} 0 R] /B [/Separation /Red /DeviceRGB {other} 0 R]")), differ);
|
||
// Equal copies compared again and again still leave room to find the one that differs.
|
||
let sep = |f: usize| format!("[/Separation /Red /DeviceRGB {f} 0 R]");
|
||
assert_eq!(spots(&format!("/A {} /B {} /C {} /D {} /E {}", sep(a), sep(b), sep(b), sep(b), sep(4))), differ);
|
||
}
|
||
|
||
const XMP_NS: &str = concat!(
|
||
r#"xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" "#,
|
||
r#"xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" "#,
|
||
r#"xmlns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#" xmlns:xmpDM="http://ns.adobe.com/xmp/1.0/DynamicMedia/" "#,
|
||
r#"xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" xmlns:xmpTPg="http://ns.adobe.com/xap/1.0/t/pg/" "#,
|
||
r#"xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" "#,
|
||
r#"xmlns:tiff="http://ns.adobe.com/tiff/1.0/" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:aux="http://ns.adobe.com/exif/1.0/aux/""#
|
||
);
|
||
|
||
/// The mistyped predefined properties of a packet whose one description holds `body` (its
|
||
/// attributes, then `>` and its elements).
|
||
fn mistyped(body: &str) -> Vec<String> {
|
||
let xmp = format!(
|
||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF {XMP_NS}><rdf:Description rdf:about="" {body}</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||
);
|
||
let parsed = xmp::parse(&xmp).expect("parses");
|
||
xmp_types::mistyped(&parsed).into_keys().map(|(name, _)| name).collect()
|
||
}
|
||
|
||
/// [`mistyped`] for the property `p` written as an element holding `v`.
|
||
fn element(p: &str, v: &str) -> Vec<String> {
|
||
mistyped(&format!("><{p}>{v}</{p}>"))
|
||
}
|
||
|
||
/// PDF/A-2 6.6.2.3.1, simple values: their lexical forms (ISO 16684-1 8.2.1), as elements and attributes.
|
||
#[test]
|
||
fn xmp_simple_values_have_their_lexical_forms() {
|
||
// Simple types, as elements and as attributes.
|
||
assert!(element("crs:Contrast", "-25").is_empty());
|
||
assert_eq!(element("crs:Contrast", "25.0"), ["crs:Contrast"]);
|
||
assert_eq!(element("crs:Contrast", "4/1"), ["crs:Contrast"]);
|
||
assert!(element("crs:Exposure", "+.5").is_empty() && element("crs:Exposure", "5").is_empty());
|
||
assert_eq!(element("crs:Exposure", "5."), ["crs:Exposure"]);
|
||
assert_eq!(element("crs:Exposure", "1e3"), ["crs:Exposure"]);
|
||
assert!(element("pdf:Trapped", "True").is_empty());
|
||
assert_eq!(element("pdf:Trapped", "true"), ["pdf:Trapped"]);
|
||
assert_eq!(mistyped(r#"crs:AutoContrast="Yes">"#), ["crs:AutoContrast"]);
|
||
assert!(mistyped(r#"crs:AutoContrast="False">"#).is_empty());
|
||
for good in
|
||
["2016", "2016-02", "2016-02-01", "2016-02-01T13:19Z", "2016-02-01T13:19:21+01:00", "2016-02-01T13:19:21.5-08:00", "2016-02-01T13:19:21"]
|
||
{
|
||
assert!(element("xmp:CreateDate", good).is_empty(), "{good}");
|
||
}
|
||
for bad in [
|
||
"16-02-01",
|
||
"2016-13-01",
|
||
"2016-02-32",
|
||
"2016-02T13:19",
|
||
"2016-02-01T25:00",
|
||
"2016-02-01T13:19:21+1:00",
|
||
"Feb 1 2016",
|
||
"2016-02-01T13:19Z01:00",
|
||
] {
|
||
assert_eq!(element("xmp:CreateDate", bad), ["xmp:CreateDate"], "{bad}");
|
||
}
|
||
assert!(element("xmpDM:videoPixelAspectRatio", "1001/30000").is_empty());
|
||
assert_eq!(element("xmpDM:videoPixelAspectRatio", "1.5"), ["xmpDM:videoPixelAspectRatio"]);
|
||
// The whole text of a value counts, however a comment splits it.
|
||
assert_eq!(element("crs:Contrast", "5<!-- c -->.0"), ["crs:Contrast"]);
|
||
assert!(element("pdf:Trapped", "Tr<!-- c -->ue").is_empty());
|
||
// rdf:resource makes a URI, which only a text type takes.
|
||
assert_eq!(mistyped(r#"><crs:Contrast rdf:resource="5"/>"#), ["crs:Contrast"]);
|
||
// A GUID is a simple value but not a URI (ISO 16684-1 8.2.2.3).
|
||
assert!(element("xmpMM:DocumentID", "uuid:1234").is_empty());
|
||
assert_eq!(mistyped(r#"><xmpMM:DocumentID rdf:resource="uuid:1234"/>"#), ["xmpMM:DocumentID"]);
|
||
assert_eq!(
|
||
mistyped(r#"><xmpMM:DerivedFrom rdf:parseType="Resource"><stRef:instanceID rdf:resource="uuid:1"/></xmpMM:DerivedFrom>"#),
|
||
["xmpMM:DerivedFrom"]
|
||
);
|
||
}
|
||
|
||
/// PDF/A-2 6.6.2.3.1, arrays: the kind, every item, and the languages of a language alternative.
|
||
#[test]
|
||
fn xmp_arrays_have_their_kind_and_item_types() {
|
||
// Text can't be an array or a structure; a simple value can be given as rdf:resource.
|
||
assert_eq!(element("crs:RawFileName", "<rdf:Seq><rdf:li>a</rdf:li></rdf:Seq>"), ["crs:RawFileName"]);
|
||
assert_eq!(mistyped(r#"><crs:RawFileName rdf:parseType="Resource"><crs:Exposure>1</crs:Exposure></crs:RawFileName>"#), ["crs:RawFileName"]);
|
||
assert!(mistyped(r#"><dc:source rdf:resource="http://example.com/a"/>"#).is_empty());
|
||
// Arrays: the kind and every item.
|
||
assert!(element("dc:creator", "<rdf:Seq><rdf:li>A</rdf:li><rdf:li>B</rdf:li></rdf:Seq>").is_empty());
|
||
assert_eq!(element("dc:creator", "<rdf:Bag><rdf:li>A</rdf:li></rdf:Bag>"), ["dc:creator"]);
|
||
assert_eq!(element("dc:creator", "A"), ["dc:creator"]);
|
||
assert_eq!(element("dc:date", "<rdf:Seq><rdf:li>2016-02-01</rdf:li><rdf:li>yesterday</rdf:li></rdf:Seq>"), ["dc:date"]);
|
||
assert_eq!(element("dc:date", "<rdf:Seq><rdf:_1>yesterday</rdf:_1></rdf:Seq>"), ["dc:date"]);
|
||
// Only white space around an array's items.
|
||
assert_eq!(element("dc:creator", "stray<rdf:Seq><rdf:li>A</rdf:li></rdf:Seq>"), ["dc:creator"]);
|
||
assert_eq!(element("dc:creator", "<rdf:Seq>stray<rdf:li>A</rdf:li></rdf:Seq>"), ["dc:creator"]);
|
||
// rdf:parseType or rdf:resource on the property makes its value something other than the array.
|
||
assert_eq!(mistyped(r#"><dc:creator rdf:parseType="Resource"><rdf:Seq><rdf:li>A</rdf:li></rdf:Seq></dc:creator>"#), ["dc:creator"]);
|
||
assert_eq!(mistyped(r#"><dc:creator rdf:resource="http://example.com/a"><rdf:Seq><rdf:li>A</rdf:li></rdf:Seq></dc:creator>"#), ["dc:creator"]);
|
||
assert!(element("dc:creator", "\n <rdf:Seq>\n <rdf:li>A</rdf:li>\n </rdf:Seq>\n").is_empty());
|
||
// An rdf:resource element is empty.
|
||
assert_eq!(mistyped(r#"><dc:source rdf:resource="http://example.com/a"><rdf:Seq><rdf:li>x</rdf:li></rdf:Seq></dc:source>"#), ["dc:source"]);
|
||
// Language alternatives: an rdf:Alt whose items all carry xml:lang.
|
||
assert!(element("dc:title", r#"<rdf:Alt><rdf:li xml:lang="x-default">T</rdf:li></rdf:Alt>"#).is_empty());
|
||
assert_eq!(element("dc:title", "<rdf:Alt><rdf:li>T</rdf:li></rdf:Alt>"), ["dc:title"]);
|
||
assert_eq!(element("dc:title", "T"), ["dc:title"]);
|
||
}
|
||
|
||
/// PDF/A-2 6.6.2.3.1, structures in their three RDF/XML forms, nested, and qualified values.
|
||
#[test]
|
||
fn xmp_structures_have_their_field_types() {
|
||
// Structures in their three forms, with each known field checked.
|
||
assert!(mistyped(r#"><xmpMM:DerivedFrom rdf:parseType="Resource"><stRef:documentID>d</stRef:documentID><stRef:lastModifyDate>2016</stRef:lastModifyDate></xmpMM:DerivedFrom>"#).is_empty());
|
||
assert!(mistyped(r#"><xmpMM:DerivedFrom><rdf:Description stRef:documentID="d"/></xmpMM:DerivedFrom>"#).is_empty());
|
||
assert!(mistyped(r#"><xmpMM:DerivedFrom stRef:documentID="d"/>"#).is_empty());
|
||
assert_eq!(element("xmpMM:DerivedFrom", "uuid:1234"), ["xmpMM:DerivedFrom"]);
|
||
assert_eq!(mistyped(r#"><xmpMM:DerivedFrom rdf:parseType="Resource">letter</xmpMM:DerivedFrom>"#), ["xmpMM:DerivedFrom"]);
|
||
assert_eq!(
|
||
mistyped(r#"><xmpMM:DerivedFrom><rdf:Description stRef:documentID="d">letter</rdf:Description></xmpMM:DerivedFrom>"#),
|
||
["xmpMM:DerivedFrom"]
|
||
);
|
||
assert_eq!(mistyped(r#"><xmpMM:DerivedFrom>letter<rdf:Description stRef:documentID="d"/></xmpMM:DerivedFrom>"#), ["xmpMM:DerivedFrom"]);
|
||
// Predefined properties inside a Pantry item keep their types.
|
||
let pantry = |v: &str| {
|
||
mistyped(&format!(
|
||
r#"><xmpMM:Pantry><rdf:Bag><rdf:li rdf:parseType="Resource"><crs:Contrast>{v}</crs:Contrast></rdf:li></rdf:Bag></xmpMM:Pantry>"#
|
||
))
|
||
};
|
||
assert!(pantry("5").is_empty());
|
||
assert_eq!(pantry("high"), ["xmpMM:Pantry"]);
|
||
assert_eq!(
|
||
mistyped(r#"><xmpMM:DerivedFrom rdf:parseType="Resource"><stRef:lastModifyDate>soon</stRef:lastModifyDate></xmpMM:DerivedFrom>"#),
|
||
["xmpMM:DerivedFrom"]
|
||
);
|
||
assert_eq!(mistyped(r#"><xmpMM:DerivedFrom stRef:lastModifyDate="soon"/>"#), ["xmpMM:DerivedFrom"]);
|
||
assert_eq!(mistyped(r#"><xmpTPg:MaxPageSize rdf:parseType="Resource"><stDim:w>8.5 in</stDim:w></xmpTPg:MaxPageSize>"#), ["xmpTPg:MaxPageSize"]);
|
||
// Nested: an array of structures holding a structure.
|
||
let marker = |start: &str| format!(r#"><xmpDM:markers><rdf:Seq><rdf:li rdf:parseType="Resource">{start}</rdf:li></rdf:Seq></xmpDM:markers>"#);
|
||
assert!(
|
||
mistyped(&marker(
|
||
r#"<xmpDM:startTime rdf:parseType="Resource"><xmpDM:value>50</xmpDM:value><xmpDM:scale>1/25</xmpDM:scale></xmpDM:startTime>"#
|
||
))
|
||
.is_empty()
|
||
);
|
||
assert!(mistyped(&marker("<xmpDM:startTime>50f</xmpDM:startTime>")).is_empty());
|
||
assert_eq!(mistyped(&marker(r#"<xmpDM:startTime rdf:parseType="Resource"><xmpDM:value>5.5</xmpDM:value></xmpDM:startTime>"#)), ["xmpDM:markers"]);
|
||
// A qualified value is checked by its rdf:value.
|
||
assert!(mistyped(r#"><crs:Contrast rdf:parseType="Resource"><rdf:value>5</rdf:value><xmp:Label>q</xmp:Label></crs:Contrast>"#).is_empty());
|
||
assert_eq!(mistyped(r#"><crs:Contrast rdf:parseType="Resource"><rdf:value>five</rdf:value></crs:Contrast>"#), ["crs:Contrast"]);
|
||
// rdf:value as an attribute of the description.
|
||
assert!(mistyped(r#"><crs:Contrast><rdf:Description rdf:value="5" xmp:Label="q"/></crs:Contrast>"#).is_empty());
|
||
assert_eq!(mistyped(r#"><crs:Contrast><rdf:Description rdf:value="five" xmp:Label="q"/></crs:Contrast>"#), ["crs:Contrast"]);
|
||
// rdf:value as an attribute of an empty property element.
|
||
assert!(mistyped(r#"><crs:Contrast rdf:value="5" xmp:Label="q"/>"#).is_empty());
|
||
assert_eq!(mistyped(r#"><crs:Contrast rdf:value="five" xmp:Label="q"/>"#), ["crs:Contrast"]);
|
||
// Qualifiers that are predefined properties keep their types.
|
||
assert_eq!(
|
||
mistyped(r#"><crs:Contrast rdf:parseType="Resource"><rdf:value>5</rdf:value><crs:Exposure>high</crs:Exposure></crs:Contrast>"#),
|
||
["crs:Contrast"]
|
||
);
|
||
assert_eq!(mistyped(r#"><crs:Contrast rdf:value="5" crs:Exposure="high"/>"#), ["crs:Contrast"]);
|
||
assert!(mistyped(r#"><crs:Contrast rdf:value="5" crs:Exposure="1.5"/>"#).is_empty());
|
||
// Nesting past any type's depth doesn't have the type.
|
||
let nested = |levels: usize| {
|
||
let open = r#"<rdf:value rdf:parseType="Resource">"#.repeat(levels);
|
||
let close = "</rdf:value>".repeat(levels);
|
||
mistyped(&format!(r#"><crs:Contrast rdf:parseType="Resource">{open}<rdf:value>5</rdf:value>{close}</crs:Contrast>"#))
|
||
};
|
||
assert!(nested(3).is_empty());
|
||
assert_eq!(nested(40), ["crs:Contrast"]);
|
||
}
|
||
|
||
/// PDF/A-2 6.6.2.3.1 through verify: one issue per mistyped property, not fixable.
|
||
#[test]
|
||
fn xmp_value_types_are_reported_by_verify() {
|
||
let metadata = |body: &str| {
|
||
let xmp = format!(r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF {XMP_NS}><rdf:Description rdf:about="" {body}/></rdf:RDF></x:xmpmeta>"#);
|
||
format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len())
|
||
};
|
||
let issues = |doc: &Document| -> Vec<Issue> { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.1").collect() };
|
||
// Every mistyped property, once per metadata stream, by stream and then by name; not fixable.
|
||
let catalog = metadata(r#"crs:Contrast="high" crs:Exposure="bright""#);
|
||
let page = metadata(r#"crs:Contrast="higher""#);
|
||
let doc = build(
|
||
&[
|
||
"<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>",
|
||
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||
&catalog,
|
||
"<< /Type /Page /Parent 2 0 R /Metadata 5 0 R >>",
|
||
&page,
|
||
],
|
||
"",
|
||
);
|
||
let found = issues(&doc);
|
||
let messages: Vec<&str> = found.iter().map(|i| i.message.as_str()).collect();
|
||
assert_eq!(
|
||
messages,
|
||
[
|
||
"The XMP property crs:Contrast in metadata stream 3 0 R should have the value type Integer",
|
||
"The XMP property crs:Exposure in metadata stream 3 0 R should have the value type Real",
|
||
"The XMP property crs:Contrast in metadata stream 5 0 R should have the value type Integer"
|
||
]
|
||
);
|
||
let objects: Vec<Option<ObjRef>> = found.iter().map(|i| i.object).collect();
|
||
assert_eq!(objects, [Some(ObjRef::new(3, 0)), Some(ObjRef::new(3, 0)), Some(ObjRef::new(5, 0))]);
|
||
assert!(found.iter().all(|i| !i.fixable && i.page.is_none()));
|
||
// Said in another language, the property and the stream go where that language puts them, and
|
||
// what the document wrote goes in as it stands.
|
||
let reordered = |w: &str| w.replace("The XMP property {property} in metadata stream {object}", "In {object}, {property}");
|
||
assert_eq!(found[0].said(&in_english), found[0].message);
|
||
assert_eq!(found[0].said(&reordered), "In 3 0 R, crs:Contrast should have the value type Integer");
|
||
// A UTF-16 packet, either byte order, is read.
|
||
for big in [true, false] {
|
||
let xmp = format!(
|
||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF {XMP_NS}><rdf:Description rdf:about="" crs:Contrast="high"/></rdf:RDF></x:xmpmeta>"#
|
||
);
|
||
let mut data = if big { vec![0xFE, 0xFF] } else { vec![0xFF, 0xFE] };
|
||
data.extend(xmp.encode_utf16().flat_map(|u| if big { u.to_be_bytes() } else { u.to_le_bytes() }));
|
||
let hex: String = data.iter().map(|b| format!("{b:02X}")).collect();
|
||
let stream = format!("<< /Type /Metadata /Subtype /XML /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1);
|
||
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
|
||
assert_eq!(issues(&doc).len(), 1, "big-endian: {big}");
|
||
}
|
||
// A metadata stream of a page counts as well as the catalog's.
|
||
let good = metadata(r#"crs:Contrast="5""#);
|
||
let doc = build(
|
||
&[
|
||
"<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>",
|
||
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||
&good,
|
||
"<< /Type /Page /Parent 2 0 R /Metadata 5 0 R >>",
|
||
&page,
|
||
],
|
||
"",
|
||
);
|
||
let found = issues(&doc);
|
||
assert_eq!(found.iter().map(|i| i.object).collect::<Vec<_>>(), [Some(ObjRef::new(5, 0))]);
|
||
}
|
||
|
||
/// PDF/A-2 6.6.2.3.1 for the TIFF, EXIF and Additional EXIF schemas of the September 2005 XMP
|
||
/// Specification (pages 58–70), with the EXIF value types of pages 83–87.
|
||
#[test]
|
||
fn xmp_exif_schemas_have_their_value_types() {
|
||
// TIFF: integers, closed choices of integer, rationals, sequences, language alternatives.
|
||
assert!(element("tiff:ImageWidth", "256").is_empty());
|
||
assert_eq!(element("tiff:ImageWidth", "256 mm"), ["tiff:ImageWidth"]);
|
||
assert_eq!(element("tiff:Orientation", "4/1"), ["tiff:Orientation"]);
|
||
assert!(element("tiff:XResolution", "72/1").is_empty());
|
||
assert_eq!(element("tiff:XResolution", "<rdf:Seq><rdf:li>1/2</rdf:li></rdf:Seq>"), ["tiff:XResolution"]);
|
||
assert!(element("tiff:BitsPerSample", "<rdf:Seq><rdf:li>8</rdf:li><rdf:li>8</rdf:li></rdf:Seq>").is_empty());
|
||
assert_eq!(element("tiff:BitsPerSample", "<rdf:Seq><rdf:li>8</rdf:li><rdf:li>8.0</rdf:li></rdf:Seq>"), ["tiff:BitsPerSample"]);
|
||
assert_eq!(element("tiff:TransferFunction", "<rdf:Bag><rdf:li>0</rdf:li></rdf:Bag>"), ["tiff:TransferFunction"]);
|
||
assert_eq!(element("tiff:ImageDescription", "A picture"), ["tiff:ImageDescription"]);
|
||
// EXIF: GPS coordinates (page 85).
|
||
for good in ["51,30,26N", "0,5.25W", "120,5,0E", "8,0.000001S"] {
|
||
assert!(element("exif:GPSLatitude", good).is_empty(), "{good}");
|
||
}
|
||
for bad in ["51.5N", "51,30,26", "51,30.N", "51,30,26X", "N", "51,30,2.6N", "a,30,26N", "51,30N", "51.5,30,26N"] {
|
||
assert_eq!(element("exif:GPSLatitude", bad), ["exif:GPSLatitude"], "{bad}");
|
||
}
|
||
assert!(element("exif:GPSTimeStamp", "2016-02-01T13:19:21").is_empty());
|
||
assert_eq!(element("exif:ISOSpeedRatings", "100"), ["exif:ISOSpeedRatings"]);
|
||
// EXIF structures: Flash, OECF/SFR, CFAPattern, DeviceSettings (pages 85–87).
|
||
assert!(mistyped(r#"><exif:Flash rdf:parseType="Resource"><exif:Fired>True</exif:Fired><exif:Mode>3</exif:Mode></exif:Flash>"#).is_empty());
|
||
assert_eq!(mistyped(r#"><exif:Flash rdf:parseType="Resource"><exif:Fired>yes</exif:Fired></exif:Flash>"#), ["exif:Flash"]);
|
||
assert_eq!(mistyped(r#"><exif:Flash exif:Return="2.0"/>"#), ["exif:Flash"]);
|
||
assert_eq!(element("exif:Flash", "fired"), ["exif:Flash"]);
|
||
assert!(
|
||
mistyped(r#"><exif:OECF rdf:parseType="Resource"><exif:Values><rdf:Seq><rdf:li>1/2</rdf:li></rdf:Seq></exif:Values></exif:OECF>"#).is_empty()
|
||
);
|
||
assert_eq!(
|
||
mistyped(r#"><exif:OECF rdf:parseType="Resource"><exif:Values><rdf:Seq><rdf:li>0.5</rdf:li></rdf:Seq></exif:Values></exif:OECF>"#),
|
||
["exif:OECF"]
|
||
);
|
||
assert_eq!(
|
||
mistyped(
|
||
r#"><exif:CFAPattern rdf:parseType="Resource"><exif:Values><rdf:Seq><rdf:li>1/2</rdf:li></rdf:Seq></exif:Values></exif:CFAPattern>"#
|
||
),
|
||
["exif:CFAPattern"]
|
||
);
|
||
assert_eq!(
|
||
mistyped(r#"><exif:DeviceSettingDescription rdf:parseType="Resource"><exif:Columns>2.0</exif:Columns></exif:DeviceSettingDescription>"#),
|
||
["exif:DeviceSettingDescription"]
|
||
);
|
||
assert_eq!(
|
||
mistyped(r#"><exif:DeviceSettingDescription rdf:parseType="Resource"><exif:Settings>a</exif:Settings></exif:DeviceSettingDescription>"#),
|
||
["exif:DeviceSettingDescription"]
|
||
);
|
||
// Additional EXIF properties (page 69).
|
||
assert!(element("aux:Lens", "50mm").is_empty());
|
||
assert_eq!(element("aux:Lens", "<rdf:Seq><rdf:li>50mm</rdf:li></rdf:Seq>"), ["aux:Lens"]);
|
||
assert_eq!(element("aux:SerialNumber", "<rdf:Bag><rdf:li>1</rdf:li></rdf:Bag>"), ["aux:SerialNumber"]);
|
||
}
|
||
|
||
/// The 6.6.2.3.3 findings of a packet whose extension schema bag holds `schema`, with the five
|
||
/// description namespaces bound to the prefixes in `prefixes` (`pdfaSchema` and so on by default).
|
||
fn extension(schema: &str, prefixes: [&str; 4]) -> (Vec<String>, Vec<&'static str>) {
|
||
let found = extension_findings(schema, prefixes);
|
||
(found.missing.into_iter().map(|m| m.entry).collect(), found.prefixes.into_iter().map(|p| p.own).collect())
|
||
}
|
||
|
||
/// [`extension`]'s findings in full, as if from the metadata stream `3 0 R`.
|
||
fn extension_findings(schema: &str, prefixes: [&str; 4]) -> xmp_extension::Findings {
|
||
let [s, p, t, f] = prefixes;
|
||
let xmp = format!(
|
||
concat!(
|
||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">"#,
|
||
r#"<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" "#,
|
||
r#"xmlns:{s}="http://www.aiim.org/pdfa/ns/schema#" xmlns:{p}="http://www.aiim.org/pdfa/ns/property#" "#,
|
||
r#"xmlns:{t}="http://www.aiim.org/pdfa/ns/type#" xmlns:{f}="http://www.aiim.org/pdfa/ns/field#">"#,
|
||
r#"<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">{schema}</rdf:li></rdf:Bag></pdfaExtension:schemas>"#,
|
||
r#"</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||
),
|
||
s = s,
|
||
p = p,
|
||
t = t,
|
||
f = f,
|
||
schema = schema
|
||
);
|
||
let parsed = xmp::parse(&xmp).expect("parses");
|
||
let mut found = xmp_extension::Findings::default();
|
||
found.add(ObjRef::new(3, 0), &parsed);
|
||
found
|
||
}
|
||
|
||
const OWN: [&str; 4] = ["pdfaSchema", "pdfaProperty", "pdfaType", "pdfaField"];
|
||
|
||
/// A complete schema description, with `drop` left out of it (`pdfaSchema:schema` and so on).
|
||
fn schema_without(drop: &str) -> String {
|
||
let entry = |name: &str, value: &str| if name == drop { String::new() } else { format!("<{name}>{value}</{name}>") };
|
||
let property = ["pdfaProperty:name", "pdfaProperty:valueType", "pdfaProperty:category", "pdfaProperty:description"]
|
||
.iter()
|
||
.map(|n| entry(n, "x"))
|
||
.collect::<String>();
|
||
let field = ["pdfaField:name", "pdfaField:valueType", "pdfaField:description"].iter().map(|n| entry(n, "x")).collect::<String>();
|
||
let field = entry("pdfaType:field", &format!(r#"<rdf:Seq><rdf:li rdf:parseType="Resource">{field}</rdf:li></rdf:Seq>"#));
|
||
let value_type =
|
||
["pdfaType:type", "pdfaType:namespaceURI", "pdfaType:prefix", "pdfaType:description"].iter().map(|n| entry(n, "x")).collect::<String>();
|
||
[
|
||
entry("pdfaSchema:schema", "Some schema"),
|
||
entry("pdfaSchema:namespaceURI", "http://example.com/ns/"),
|
||
entry("pdfaSchema:prefix", "ex"),
|
||
entry("pdfaSchema:property", &format!(r#"<rdf:Seq><rdf:li rdf:parseType="Resource">{property}</rdf:li></rdf:Seq>"#)),
|
||
entry("pdfaSchema:valueType", &format!(r#"<rdf:Seq><rdf:li rdf:parseType="Resource">{value_type}{field}</rdf:li></rdf:Seq>"#)),
|
||
]
|
||
.concat()
|
||
}
|
||
|
||
/// PDF/A-2 6.6.2.3.3: every extension schema description has its required entries, in each of its
|
||
/// four kinds, and writes each description namespace with its own prefix.
|
||
#[test]
|
||
fn xmp_extension_schema_descriptions_are_complete() {
|
||
assert_eq!(extension(&schema_without(""), OWN), (vec![], vec![]));
|
||
// The property and value type lists of a schema are optional.
|
||
assert_eq!(extension(&schema_without("pdfaSchema:property"), OWN), (vec![], vec![]));
|
||
assert_eq!(extension(&schema_without("pdfaSchema:valueType"), OWN), (vec![], vec![]));
|
||
// Every other entry is required.
|
||
for required in [
|
||
"pdfaSchema:schema",
|
||
"pdfaSchema:namespaceURI",
|
||
"pdfaSchema:prefix",
|
||
"pdfaProperty:name",
|
||
"pdfaProperty:valueType",
|
||
"pdfaProperty:category",
|
||
"pdfaProperty:description",
|
||
"pdfaType:type",
|
||
"pdfaType:namespaceURI",
|
||
"pdfaType:prefix",
|
||
"pdfaType:description",
|
||
"pdfaType:field",
|
||
"pdfaField:name",
|
||
"pdfaField:valueType",
|
||
"pdfaField:description",
|
||
] {
|
||
assert_eq!(extension(&schema_without(required), OWN), (vec![required.to_string()], vec![]), "{required}");
|
||
}
|
||
// An entry may be an attribute of an rdf:Description item.
|
||
let as_attribute = r#"</rdf:li><rdf:li><rdf:Description pdfaSchema:schema="S" pdfaSchema:namespaceURI="http://e/" pdfaSchema:prefix="e"/>"#;
|
||
assert_eq!(extension(&format!("{}{as_attribute}", schema_without("")), OWN), (vec![], vec![]));
|
||
let missing_attribute = r#"</rdf:li><rdf:li><rdf:Description pdfaSchema:schema="S" pdfaSchema:prefix="e"/>"#;
|
||
assert_eq!(extension(&format!("{}{missing_attribute}", schema_without("")), OWN).0, ["pdfaSchema:namespaceURI"]);
|
||
// Each namespace with its own prefix: the right URI under another prefix breaks the rule.
|
||
for (i, own) in OWN.iter().enumerate() {
|
||
let mut prefixes = OWN;
|
||
prefixes[i] = "other";
|
||
let schema = schema_without("").replace(&format!("{own}:"), "other:");
|
||
assert_eq!(extension(&schema, prefixes), (vec![], vec![*own]), "{own}");
|
||
}
|
||
// A second property list is read as well as the first.
|
||
let complete = schema_without("");
|
||
let second = complete.replacen(
|
||
"</pdfaSchema:property>",
|
||
r#"</pdfaSchema:property><pdfaSchema:property><rdf:Seq><rdf:li rdf:parseType="Resource"><pdfaProperty:name>p</pdfaProperty:name></rdf:li></rdf:Seq></pdfaSchema:property>"#,
|
||
1,
|
||
);
|
||
assert_eq!(extension(&second, OWN).0, ["pdfaProperty:valueType", "pdfaProperty:category", "pdfaProperty:description"]);
|
||
// The container is an rdf:Bag (TechNote 0009, 4.2).
|
||
let container = |inner: &str| {
|
||
let xmp = format!(
|
||
r#"<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:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"><pdfaExtension:schemas>{inner}</pdfaExtension:schemas></rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||
);
|
||
let mut found = xmp_extension::Findings::default();
|
||
found.add(ObjRef::new(3, 0), &xmp::parse(&xmp).expect("parses"));
|
||
found.not_bag.into_iter().map(|c| c.found).collect::<Vec<_>>()
|
||
};
|
||
let schema = r#"<rdf:li rdf:parseType="Resource"><pdfaSchema:schema>S</pdfaSchema:schema><pdfaSchema:namespaceURI>http://e/</pdfaSchema:namespaceURI><pdfaSchema:prefix>e</pdfaSchema:prefix></rdf:li>"#;
|
||
assert!(container(&format!("<rdf:Bag>{schema}</rdf:Bag>")).is_empty());
|
||
// What it holds instead is named as the packet writes it.
|
||
assert_eq!(container(&format!("<rdf:Seq>{schema}</rdf:Seq>")), ["rdf:Seq"]);
|
||
assert_eq!(container(r#"<rdf:Description pdfaSchema:schema="S"/>"#), ["rdf:Description"]);
|
||
assert_eq!(container(&format!("<rdf:Bag>{schema}</rdf:Bag><rdf:Bag/>")), ["rdf:Bag, rdf:Bag"]);
|
||
assert_eq!(container(""), [""]);
|
||
// The container itself is written as pdfaExtension too (TechNote 0009, 4.1).
|
||
let xmp = concat!(
|
||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">"#,
|
||
r#"<rdf:Description rdf:about="" xmlns:ext="http://www.aiim.org/pdfa/ns/extension/" "#,
|
||
r#"xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"><ext:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">"#,
|
||
r#"<pdfaSchema:schema>S</pdfaSchema:schema><pdfaSchema:namespaceURI>http://e/</pdfaSchema:namespaceURI>"#,
|
||
r#"<pdfaSchema:prefix>e</pdfaSchema:prefix></rdf:li></rdf:Bag></ext:schemas></rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||
);
|
||
let mut found = xmp_extension::Findings::default();
|
||
found.add(ObjRef::new(3, 0), &xmp::parse(xmp).expect("parses"));
|
||
assert!(found.missing.is_empty());
|
||
let written: Vec<(&str, &str, &str)> = found.prefixes.iter().map(|p| (p.namespace, p.own, p.written.as_str())).collect();
|
||
assert_eq!(written, [("http://www.aiim.org/pdfa/ns/extension/", "pdfaExtension", "ext")]);
|
||
// Through verify, under 6.6.2.3.3 and not fixable.
|
||
let xmp = concat!(
|
||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">"#,
|
||
r#"<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" "#,
|
||
r#"xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"><pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">"#,
|
||
r#"<pdfaSchema:schema>S</pdfaSchema:schema><pdfaSchema:prefix>e</pdfaSchema:prefix></rdf:li></rdf:Bag></pdfaExtension:schemas>"#,
|
||
r#"</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||
);
|
||
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
|
||
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
|
||
let issues: Vec<Issue> = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").collect();
|
||
assert_eq!(issues.len(), 1);
|
||
assert_eq!(issues[0].message, "The XMP extension schema #1 in metadata stream 3 0 R has no pdfaSchema:namespaceURI entry");
|
||
assert_eq!(issues[0].object, Some(ObjRef::new(3, 0)));
|
||
assert!(!issues[0].fixable);
|
||
}
|
||
|
||
/// PDF/A-2 6.6.2.3.3: an entry that names something is incomplete when it is empty, while the
|
||
/// human-readable descriptions may be empty (TechNote 0009, 4.3 to 4.6).
|
||
#[test]
|
||
fn xmp_extension_naming_entries_are_not_empty() {
|
||
let complete = schema_without("");
|
||
let emptied = |name: &str, value: &str, with: &str| {
|
||
let element = format!("<{name}>{value}</{name}>");
|
||
assert_eq!(complete.matches(&element).count(), 1, "{name}");
|
||
complete.replace(&element, &format!("<{name}>{with}</{name}>"))
|
||
};
|
||
let said = |schema: &str| -> Vec<(String, bool)> { extension_findings(schema, OWN).missing.into_iter().map(|m| (m.entry, m.empty)).collect() };
|
||
for (name, value) in [
|
||
("pdfaSchema:namespaceURI", "http://example.com/ns/"),
|
||
("pdfaSchema:prefix", "ex"),
|
||
("pdfaProperty:name", "x"),
|
||
("pdfaProperty:valueType", "x"),
|
||
("pdfaProperty:category", "x"),
|
||
("pdfaType:type", "x"),
|
||
("pdfaType:namespaceURI", "x"),
|
||
("pdfaType:prefix", "x"),
|
||
("pdfaField:name", "x"),
|
||
("pdfaField:valueType", "x"),
|
||
] {
|
||
assert_eq!(said(&emptied(name, value, "")), [(name.to_owned(), true)], "{name}");
|
||
assert_eq!(said(&emptied(name, value, " \n\t ")), [(name.to_owned(), true)], "{name} blank");
|
||
}
|
||
for (name, value) in
|
||
[("pdfaSchema:schema", "Some schema"), ("pdfaProperty:description", "x"), ("pdfaType:description", "x"), ("pdfaField:description", "x")]
|
||
{
|
||
assert_eq!(said(&emptied(name, value, "")), [], "{name}");
|
||
}
|
||
// An empty element written shut, and an empty attribute, are empty too.
|
||
let shut = complete.replace("<pdfaType:prefix>x</pdfaType:prefix>", "<pdfaType:prefix/>");
|
||
assert_eq!(said(&shut), [("pdfaType:prefix".to_owned(), true)]);
|
||
let attribute = r#"</rdf:li><rdf:li><rdf:Description pdfaSchema:schema="S" pdfaSchema:namespaceURI="http://e/" pdfaSchema:prefix=" "/>"#;
|
||
assert_eq!(said(&format!("{complete}{attribute}")), [("pdfaSchema:prefix".to_owned(), true)]);
|
||
// A URI given as an rdf:resource, or a value given as rdf:value, is a value.
|
||
let resource =
|
||
complete.replace("<pdfaType:namespaceURI>x</pdfaType:namespaceURI>", r#"<pdfaType:namespaceURI rdf:resource="http://t.example/"/>"#);
|
||
assert_eq!(said(&resource), []);
|
||
let value = complete.replace("<pdfaType:type>x</pdfaType:type>", r#"<pdfaType:type rdf:value="T"/>"#);
|
||
assert_eq!(said(&value), []);
|
||
// A comment is not a value.
|
||
assert_eq!(said(&emptied("pdfaType:prefix", "x", "<!-- t -->")), [("pdfaType:prefix".to_owned(), true)]);
|
||
|
||
// Through verify, with their own wordings.
|
||
let xmp = concat!(
|
||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">"#,
|
||
r#"<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" "#,
|
||
r#"xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaType="http://www.aiim.org/pdfa/ns/type#">"#,
|
||
r#"<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">"#,
|
||
r#"<pdfaSchema:schema>S</pdfaSchema:schema><pdfaSchema:namespaceURI>http://a.example/</pdfaSchema:namespaceURI>"#,
|
||
r#"<pdfaSchema:prefix></pdfaSchema:prefix><pdfaSchema:valueType><rdf:Seq><rdf:li rdf:parseType="Resource">"#,
|
||
r#"<pdfaType:type>T</pdfaType:type><pdfaType:namespaceURI>http://t.example/</pdfaType:namespaceURI>"#,
|
||
r#"<pdfaType:prefix> </pdfaType:prefix><pdfaType:description>d</pdfaType:description>"#,
|
||
r#"<pdfaType:field><rdf:Seq/></pdfaType:field></rdf:li></rdf:Seq></pdfaSchema:valueType>"#,
|
||
r#"</rdf:li></rdf:Bag></pdfaExtension:schemas></rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||
);
|
||
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
|
||
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
|
||
let messages: Vec<String> = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").map(|i| i.message).collect();
|
||
assert_eq!(
|
||
messages,
|
||
[
|
||
"The XMP extension schema http://a.example/ in metadata stream 3 0 R has an empty pdfaSchema:prefix entry",
|
||
"The XMP extension schema http://a.example/ in metadata stream 3 0 R describes T with an empty pdfaType:prefix entry",
|
||
]
|
||
);
|
||
}
|
||
|
||
/// PDF/A-2 6.6.2.3.3: each incomplete description is reported by itself, named by its schema's
|
||
/// namespace URI, its own name and its metadata stream, and so is each misused prefix and container.
|
||
#[test]
|
||
fn xmp_extension_findings_say_where_they_are() {
|
||
// Two schemas lacking the same entries are two findings, each with its schema.
|
||
let schema = |uri: &str, property: &str| {
|
||
format!(
|
||
concat!(
|
||
r#"<pdfaSchema:schema>S</pdfaSchema:schema><pdfaSchema:namespaceURI>{uri}</pdfaSchema:namespaceURI>"#,
|
||
r#"<pdfaSchema:prefix>e</pdfaSchema:prefix><pdfaSchema:property><rdf:Seq><rdf:li rdf:parseType="Resource">{property}"#,
|
||
r#"</rdf:li></rdf:Seq></pdfaSchema:property>"#
|
||
),
|
||
uri = uri,
|
||
property = property
|
||
)
|
||
};
|
||
let named = "<pdfaProperty:name>p</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType>";
|
||
let two = format!(r#"{}</rdf:li><rdf:li rdf:parseType="Resource">{}"#, schema("http://a.example/", named), schema("http://b.example/", ""));
|
||
let found = extension_findings(&two, OWN);
|
||
let said: Vec<(&str, Option<&str>, &str)> = found.missing.iter().map(|m| (m.schema.as_str(), m.item.as_deref(), m.entry.as_str())).collect();
|
||
assert_eq!(
|
||
said,
|
||
[
|
||
("http://a.example/", Some("p"), "pdfaProperty:category"),
|
||
("http://a.example/", Some("p"), "pdfaProperty:description"),
|
||
("http://b.example/", Some("#1"), "pdfaProperty:name"),
|
||
("http://b.example/", Some("#1"), "pdfaProperty:valueType"),
|
||
("http://b.example/", Some("#1"), "pdfaProperty:category"),
|
||
("http://b.example/", Some("#1"), "pdfaProperty:description"),
|
||
]
|
||
);
|
||
assert!(found.missing.iter().all(|m| m.object == ObjRef::new(3, 0)));
|
||
// A field is named by its value type and its own name.
|
||
let field = schema_without("pdfaField:description")
|
||
.replace(">x</pdfaType:type>", ">Dim</pdfaType:type>")
|
||
.replace(">x</pdfaField:name>", ">w</pdfaField:name>");
|
||
let found = extension_findings(&field, OWN);
|
||
assert_eq!(found.missing.iter().map(|m| m.item.as_deref()).collect::<Vec<_>>(), [Some("Dim/w")]);
|
||
// A name the packet gives is repeated only so far.
|
||
let long = format!("http://example.com/{}", "a".repeat(400));
|
||
let found = extension_findings(&schema(&long, ""), OWN);
|
||
assert!(found.missing[0].schema.ends_with('…') && found.missing[0].schema.chars().count() == 201, "{}", found.missing[0].schema);
|
||
// So many findings are enough.
|
||
let many = vec![schema("http://m.example/", ""); 400].join(r#"</rdf:li><rdf:li rdf:parseType="Resource">"#);
|
||
assert_eq!(extension_findings(&many, OWN).missing.len(), 1000);
|
||
|
||
// Through verify: each wording, with the stream it is in.
|
||
let verify_packet = |description: &str| -> Vec<String> {
|
||
let xmp = format!(
|
||
concat!(
|
||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">"#,
|
||
r#"<rdf:Description rdf:about="" {}</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||
),
|
||
description
|
||
);
|
||
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
|
||
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
|
||
verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").map(|i| i.message).collect()
|
||
};
|
||
let ns = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#""#;
|
||
assert_eq!(
|
||
verify_packet(&format!(
|
||
r#"{ns}><pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">{}</rdf:li></rdf:Bag></pdfaExtension:schemas>"#,
|
||
schema("http://a.example/", named)
|
||
)),
|
||
[
|
||
"The XMP extension schema http://a.example/ in metadata stream 3 0 R describes p without a pdfaProperty:category entry",
|
||
"The XMP extension schema http://a.example/ in metadata stream 3 0 R describes p without a pdfaProperty:description entry",
|
||
]
|
||
);
|
||
assert_eq!(
|
||
verify_packet(&format!(
|
||
r#"{ns}><pdfaExtension:schemas><rdf:Seq><rdf:li rdf:parseType="Resource">{}</rdf:li></rdf:Seq></pdfaExtension:schemas>"#,
|
||
schema(
|
||
"http://a.example/",
|
||
&format!("{named}<pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>d</pdfaProperty:description>")
|
||
)
|
||
)),
|
||
["The XMP extension schema container in metadata stream 3 0 R holds rdf:Seq, not one rdf:Bag"]
|
||
);
|
||
assert_eq!(
|
||
verify_packet(&format!(r#"{ns}><pdfaExtension:schemas/>"#)),
|
||
["The XMP extension schema container in metadata stream 3 0 R holds no rdf:Bag"]
|
||
);
|
||
let other = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:s="http://www.aiim.org/pdfa/ns/schema#""#;
|
||
assert_eq!(
|
||
verify_packet(&format!(
|
||
r#"{other}><pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource"><s:schema>S</s:schema><s:namespaceURI>http://a.example/</s:namespaceURI><s:prefix>e</s:prefix></rdf:li></rdf:Bag></pdfaExtension:schemas>"#
|
||
)),
|
||
["Metadata stream 3 0 R writes the namespace http://www.aiim.org/pdfa/ns/schema# with the prefix s, not pdfaSchema"]
|
||
);
|
||
let default = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/""#;
|
||
assert_eq!(
|
||
verify_packet(&format!(
|
||
r#"{default}><pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource"><schema xmlns="http://www.aiim.org/pdfa/ns/schema#">S</schema><pdfaSchema:namespaceURI xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#">http://a.example/</pdfaSchema:namespaceURI><pdfaSchema:prefix xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#">e</pdfaSchema:prefix></rdf:li></rdf:Bag></pdfaExtension:schemas>"#
|
||
)),
|
||
["Metadata stream 3 0 R writes the namespace http://www.aiim.org/pdfa/ns/schema# without a prefix, not with pdfaSchema"]
|
||
);
|
||
}
|
||
|
||
/// PDF/A-2 6.6.2.3.1 for the TIFF, EXIF and Additional EXIF schemas: a property is known by its
|
||
/// namespace URI, whatever prefix the packet binds to it, and is named by the namespace's
|
||
/// preferred prefix.
|
||
#[test]
|
||
fn xmp_exif_properties_are_known_by_namespace_uri() {
|
||
let tiff = r#"xmlns:foo="http://ns.adobe.com/tiff/1.0/""#;
|
||
assert_eq!(mistyped(&format!(r#"><foo:ImageWidth {tiff}>256 mm</foo:ImageWidth>"#)), ["tiff:ImageWidth"]);
|
||
assert!(mistyped(&format!(r#"><foo:ImageWidth {tiff}>256</foo:ImageWidth>"#)).is_empty());
|
||
assert_eq!(mistyped(r#"xmlns:bar="http://ns.adobe.com/exif/1.0/" bar:GPSLatitude="51.5N">"#), ["exif:GPSLatitude"]);
|
||
assert_eq!(
|
||
mistyped(r#"><baz:Lens xmlns:baz="http://ns.adobe.com/exif/1.0/aux/"><rdf:Seq><rdf:li>50mm</rdf:li></rdf:Seq></baz:Lens>"#),
|
||
["aux:Lens"]
|
||
);
|
||
// The same local names in another namespace, even under the preferred prefixes, are not these
|
||
// properties.
|
||
let other = r#"xmlns:tiff="http://example.com/not-tiff/""#;
|
||
assert!(mistyped(&format!(r#"><tiff:ImageWidth {other}>256 mm</tiff:ImageWidth>"#)).is_empty());
|
||
assert!(mistyped(r#"xmlns:exif="http://example.com/not-exif/" exif:GPSLatitude="51.5N">"#).is_empty());
|
||
assert!(mistyped(r#"><aux:Lens xmlns:aux="http://example.com/not-aux/"><rdf:Seq><rdf:li>50mm</rdf:li></rdf:Seq></aux:Lens>"#).is_empty());
|
||
}
|
||
|
||
/// Holes are filled in one pass: the first `{}` from `value`, named holes from `values`, and what
|
||
/// has nothing for it, a value's own braces and a brace left open stay as they are.
|
||
#[test]
|
||
fn wordings_fill_their_holes_once() {
|
||
let values = [("{a}", "{b}".to_owned()), ("{b}", "B".to_owned())];
|
||
assert_eq!(fill("x {a} {b} {c} {} {}", Some("{a}"), &values), "x {b} B {c} {a} {}");
|
||
assert_eq!(fill("{}", None, &[]), "{}");
|
||
assert_eq!(fill("open { and é {a", Some("v"), &values), "open { and é {a");
|
||
assert_eq!(fill("é{}ü", Some("v"), &[]), "évü");
|
||
}
|
||
|
||
/// PDF/A-2 6.6.2.3.1: a mistyped property is reported with the type the XMP Specification gives it,
|
||
/// as the specification writes it.
|
||
#[test]
|
||
fn xmp_mistyped_properties_name_their_type() {
|
||
let types = |body: &str| -> Vec<(String, String)> {
|
||
let xmp = format!(
|
||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF {XMP_NS}><rdf:Description rdf:about="" {body}</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||
);
|
||
xmp_types::mistyped(&xmp::parse(&xmp).expect("parses")).into_iter().map(|((name, _), ty)| (name, ty)).collect()
|
||
};
|
||
let t = |name: &str, ty: &str| (name.to_owned(), ty.to_owned());
|
||
assert_eq!(
|
||
types(r#"crs:Contrast="high" crs:Exposure="bright" xmp:CreateDate="never">"#),
|
||
[t("crs:Contrast", "Integer"), t("crs:Exposure", "Real"), t("xmp:CreateDate", "Date")]
|
||
);
|
||
assert_eq!(
|
||
types("><xmpTPg:PlateNames>Cyan</xmpTPg:PlateNames><dc:title>T</dc:title>"),
|
||
[t("dc:title", "Lang Alt"), t("xmpTPg:PlateNames", "seq Text")]
|
||
);
|
||
assert_eq!(
|
||
types("><xmpTPg:MaxPageSize>A4</xmpTPg:MaxPageSize><xmpMM:History>h</xmpMM:History>"),
|
||
[t("xmpMM:History", "seq ResourceEvent"), t("xmpTPg:MaxPageSize", "Dimensions")]
|
||
);
|
||
assert_eq!(types("><xmpMM:Pantry>p</xmpMM:Pantry>"), [t("xmpMM:Pantry", "bag struct")]);
|
||
// The TIFF and EXIF types, as the 2005 edition writes them.
|
||
assert_eq!(types(r#"tiff:XResolution="0.5" exif:GPSLatitude="N">"#), [t("exif:GPSLatitude", "GPSCoordinate"), t("tiff:XResolution", "Rational")]);
|
||
assert_eq!(
|
||
types("><tiff:BitsPerSample>8</tiff:BitsPerSample><exif:Flash>fired</exif:Flash><exif:OECF>o</exif:OECF>"),
|
||
[t("exif:Flash", "Flash"), t("exif:OECF", "OECF/SFR"), t("tiff:BitsPerSample", "seq Integer")]
|
||
);
|
||
}
|
||
|
||
/// PDF/A-2 6.6.2.3.1: a property is known by its namespace URI, whatever prefix the packet binds
|
||
/// to it, and is named by the namespace's preferred prefix and as the packet writes it. A property
|
||
/// a predefined namespace doesn't define, and a namespace that isn't predefined, are left to the
|
||
/// clauses about them.
|
||
#[test]
|
||
fn xmp_properties_are_known_by_namespace_uri_and_named_as_written() {
|
||
let crs = "http://ns.adobe.com/camera-raw-settings/1.0/";
|
||
let found = |body: &str| -> Vec<(String, String)> {
|
||
let xmp = format!(
|
||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF {XMP_NS}><rdf:Description rdf:about="" {body}</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||
);
|
||
xmp_types::mistyped(&xmp::parse(&xmp).expect("parses")).into_keys().collect()
|
||
};
|
||
let n = |name: &str, written: &str| (name.to_owned(), written.to_owned());
|
||
// Another prefix for the namespace, as an attribute and as an element, and no prefix at all.
|
||
assert_eq!(found(&format!(r#"xmlns:c="{crs}" c:Contrast="high">"#)), [n("crs:Contrast", "c:Contrast")]);
|
||
assert_eq!(found(&format!(r#"><c:Exposure xmlns:c="{crs}">bright</c:Exposure>"#)), [n("crs:Exposure", "c:Exposure")]);
|
||
assert_eq!(found(&format!(r#"><Exposure xmlns="{crs}">bright</Exposure>"#)), [n("crs:Exposure", "Exposure")]);
|
||
assert!(found(&format!(r#"xmlns:c="{crs}" c:Contrast="5">"#)).is_empty());
|
||
// The preferred prefix is written as it is.
|
||
assert_eq!(found(r#"crs:Contrast="high">"#), [n("crs:Contrast", "crs:Contrast")]);
|
||
// A long prefix is repeated only so far.
|
||
let long = "p".repeat(100);
|
||
let said = found(&format!(r#"xmlns:{long}="{crs}" {long}:Contrast="high">"#));
|
||
assert_eq!(said.len(), 1);
|
||
assert_eq!(said[0].1, format!("{}…:Contrast", "p".repeat(64)));
|
||
// The preferred prefix bound to another namespace is not that namespace.
|
||
assert!(found(r#"><crs:Contrast xmlns:crs="http://example.com/not-crs/">high</crs:Contrast>"#).is_empty());
|
||
assert!(found(r#"xmlns:dc="http://example.com/not-dc/" dc:title="T">"#).is_empty());
|
||
// A property a predefined namespace doesn't define, with any value.
|
||
assert!(found(r#"crs:NotAProperty="high" dc:notAProperty="T">"#).is_empty());
|
||
assert!(found("><xmp:NotAProperty><rdf:Seq><rdf:li>x</rdf:li></rdf:Seq></xmp:NotAProperty>").is_empty());
|
||
// A namespace that isn't predefined, with a predefined property's local name.
|
||
assert!(found(r#"xmlns:ex="http://example.com/ns/" ex:Contrast="high">"#).is_empty());
|
||
assert!(found(r#"><ex:title xmlns:ex="http://example.com/ns/">T</ex:title>"#).is_empty());
|
||
|
||
// Through verify, the written name is said when it differs.
|
||
let xmp = format!(
|
||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF {XMP_NS}><rdf:Description rdf:about="" xmlns:c="{crs}" c:Contrast="high" crs:Exposure="bright"/></rdf:RDF></x:xmpmeta>"#
|
||
);
|
||
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
|
||
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
|
||
let messages: Vec<String> = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.1").map(|i| i.message).collect();
|
||
assert_eq!(
|
||
messages,
|
||
[
|
||
"The XMP property crs:Contrast in metadata stream 3 0 R, written c:Contrast, should have the value type Integer",
|
||
"The XMP property crs:Exposure in metadata stream 3 0 R should have the value type Real",
|
||
]
|
||
);
|
||
}
|
||
|
||
/// PDF/A-2 6.6.2.3.1, the RDF/XML value forms XMP allows (ISO 16684-1 7.9.2.5): one `rdf:value`
|
||
/// in a qualified value, no parse type but `Resource`, and an `rdf:resource` element left empty.
|
||
#[test]
|
||
fn xmp_value_forms_are_those_xmp_allows() {
|
||
// A qualified value has exactly one rdf:value.
|
||
assert_eq!(
|
||
mistyped(r#"><crs:Contrast rdf:parseType="Resource"><rdf:value>5</rdf:value><rdf:value>five</rdf:value></crs:Contrast>"#),
|
||
["crs:Contrast"]
|
||
);
|
||
assert_eq!(
|
||
mistyped(r#"><crs:Contrast><rdf:Description rdf:value="five"><rdf:value>5</rdf:value></rdf:Description></crs:Contrast>"#),
|
||
["crs:Contrast"]
|
||
);
|
||
assert!(mistyped(r#"><crs:Contrast><rdf:Description><rdf:value>5</rdf:value></rdf:Description></crs:Contrast>"#).is_empty());
|
||
// Only parseType="Resource" has a place in XMP; an rdf:resource element holds no element.
|
||
assert_eq!(mistyped(r#"><crs:Contrast rdf:parseType="Literal">5</crs:Contrast>"#), ["crs:Contrast"]);
|
||
assert_eq!(mistyped(r#"><xmpMM:DerivedFrom rdf:parseType="Literal" stRef:documentID="d"/>"#), ["xmpMM:DerivedFrom"]);
|
||
assert_eq!(
|
||
mistyped(r#"><xmpMM:DerivedFrom rdf:resource="urn:example"><rdf:Description stRef:documentID="d"/></xmpMM:DerivedFrom>"#),
|
||
["xmpMM:DerivedFrom"]
|
||
);
|
||
}
|
||
|
||
/// PDF/A asks for the fonts "used for rendering" to be embedded (ISO 19005-2, 6.2.11.4.1). A font
|
||
/// that only the form's default resources hold (`/AcroForm /DR`, ISO 32000-1 12.7.2) draws
|
||
/// nothing, as in veraPDF's 6-4-1-t02-pass-a; one that a widget's appearance stream draws with is
|
||
/// named in that stream's `/Resources`, and still has to be embedded.
|
||
#[test]
|
||
fn only_fonts_something_draws_with_must_be_embedded() {
|
||
let fonts =
|
||
|doc: &Document| -> Vec<String> { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.11.4").map(|i| i.message).collect() };
|
||
let form = |appearance_fonts: &str| {
|
||
build(
|
||
&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 4 0 R /ZaDb 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Resources << >> /Annots [6 0 R] >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /F 4 /Rect [10 10 90 30] /P 3 0 R /AP << /N 7 0 R >> >>",
|
||
&format!("<< /Type /XObject /Subtype /Form /BBox [0 0 80 20] /Resources << {appearance_fonts} >> /Length 0 >>\nstream\n\nendstream"),
|
||
// An object that is nothing but a reference to the Helvetica font (ISO 32000-1 7.3.10).
|
||
"4 0 R",
|
||
],
|
||
"",
|
||
)
|
||
};
|
||
assert!(fonts(&form("")).is_empty(), "{:?}", fonts(&form("")));
|
||
assert_eq!(fonts(&form("/Font << /Helv 4 0 R >>")), ["The font Helvetica isn't embedded"]);
|
||
// Named through a reference to it, it is still the font something draws with.
|
||
assert_eq!(fonts(&form("/Font << /Helv 8 0 R >>")), ["The font Helvetica isn't embedded"]);
|
||
// A page's own font is checked as before.
|
||
assert_eq!(fonts(&messy()), ["The font Helvetica isn't embedded"]);
|
||
}
|
||
|
||
/// PDF/A-2 6.1.2: the file starts with `%PDF-1.0` to `%PDF-1.7` and an end-of-line marker, then a
|
||
/// comment of `%` and four bytes above 127; Save as PDF/A rewrites a header that doesn't.
|
||
#[test]
|
||
fn file_header_is_checked_and_rewritten() {
|
||
let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"];
|
||
let header = |h: &[u8]| -> Vec<String> {
|
||
let doc = build_with_header(h, &objs, "");
|
||
verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.2").map(|i| i.message).collect()
|
||
};
|
||
let version = || vec!["The file doesn't start with a %PDF-1.0 to %PDF-1.7 header line".to_string()];
|
||
let comment = || vec!["The file header isn't followed by a comment of four bytes above 127".to_string()];
|
||
for fine in [&b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"[..], b"%PDF-1.4\r\n%\x80\x81\x82\x83af\r\n", b"%PDF-1.0\r%\xFF\xFF\xFF\xFF\r"] {
|
||
assert!(header(fine).is_empty(), "{}", String::from_utf8_lossy(fine));
|
||
}
|
||
let wrong_version = [
|
||
&b"%PDF-2.0\n%\xE2\xE3\xCF\xD3\n"[..],
|
||
b"%PDF-1.9\n%\xE2\xE3\xCF\xD3\n",
|
||
b"%PDF-1.70\n%\xE2\xE3\xCF\xD3\n",
|
||
b"%PDF-1.7 \n%\xE2\xE3\xCF\xD3\n",
|
||
b"SomeData\n%PDF-1.7\n%\xE2\xE3\xCF\xD3\n",
|
||
];
|
||
for wrong in wrong_version {
|
||
assert_eq!(header(wrong), version(), "{}", String::from_utf8_lossy(wrong));
|
||
}
|
||
let wrong_comment = [
|
||
&b"%PDF-1.7\n%\xE2\xE3\xCF\n"[..],
|
||
b"%PDF-1.7\ninvalidData\n",
|
||
b"%PDF-1.7\n%a\xE3\xCF\xD3\n",
|
||
b"%PDF-1.7\n\n%\xE2\xE3\xCF\xD3\n",
|
||
b"%PDF-1.7\n",
|
||
];
|
||
for wrong in wrong_comment {
|
||
assert_eq!(header(wrong), comment(), "{}", String::from_utf8_lossy(wrong));
|
||
}
|
||
// Save as PDF/A rewrites the header: the version drops to 1.7 and the next save is full.
|
||
let mut doc = build_with_header(b"%PDF-2.0\n%ab\n", &objs, "");
|
||
let report = convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(report.fixed.iter().any(|f| f == "Rewrote the file header"));
|
||
assert_eq!(doc.version(), "1.7");
|
||
assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.2"));
|
||
let saved = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
|
||
assert!(saved.starts_with(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"));
|
||
// A good version with a short comment is rewritten too, keeping its version.
|
||
let mut doc = build_with_header(b"%PDF-1.4\n%ab\n", &objs, "");
|
||
assert!(!doc.full_save_required());
|
||
let report = convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(report.fixed.iter().any(|f| f == "Rewrote the file header"));
|
||
assert!(doc.full_save_required());
|
||
assert_eq!(doc.version(), "1.4");
|
||
// A good header is left as it is.
|
||
let mut doc = build(&objs, "");
|
||
let report = convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(!report.fixed.iter().any(|f| f == "Rewrote the file header"));
|
||
}
|
||
|
||
/// PDF/A-2 6.2.5: a graphics state has no transfer function, halftones of type 1 or 5 without a
|
||
/// name, and a standard rendering intent, wherever it is kept.
|
||
#[test]
|
||
fn extended_graphics_states_are_checked() {
|
||
let gs = |states: &str| -> Vec<String> {
|
||
let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ExtGState << {states} >> >> >>");
|
||
let doc = build(
|
||
&[
|
||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||
&page,
|
||
"<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>",
|
||
"<< /HalftoneType 1 /Frequency 60 /Angle 45 /SpotFunction /Round >>",
|
||
"<< /TR 4 0 R >>",
|
||
"<< /Type /Halftone /HalftoneType 6 /Width 2 /Height 2 /Length 4 >>\nstream\nabcd\nendstream",
|
||
"<< /Resources << /ExtGState << /X << /TR 4 0 R >> >> >> >>",
|
||
],
|
||
"",
|
||
);
|
||
let issues = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.5");
|
||
issues.inspect(|i| assert!(!i.fixable, "{}", i.message)).map(|i| i.message).collect()
|
||
};
|
||
let tr = "A graphics state has a transfer function (TR)";
|
||
let tr2 = "A graphics state has a transfer function (TR2) other than Default";
|
||
let name = "A halftone has a HalftoneName";
|
||
// What 6.2.5 allows.
|
||
assert!(gs("/A << /TR2 /Default /HT 5 0 R /RI /Perceptual >> /B << /HT /Default /RI /AbsoluteColorimetric >>").is_empty());
|
||
assert!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Cyan 5 0 R >> /RI /Saturation >> /B << /TR null >>").is_empty());
|
||
// Transfer functions.
|
||
assert_eq!(gs("/A << /TR 4 0 R >>"), [tr]);
|
||
assert_eq!(gs("/A << /TR /Identity >>"), [tr]);
|
||
assert_eq!(gs("/A 6 0 R"), [tr]);
|
||
assert_eq!(gs("/A << /TR2 4 0 R >>"), [tr2]);
|
||
assert_eq!(gs("/A << /TR2 /Identity >>"), [tr2]);
|
||
assert_eq!(gs("/A << /HTP [0 1] >>"), ["A graphics state has a halftone phase (HTP)"]);
|
||
// Halftones: the type, also of a type 5 halftone's colorants and of a stream, and the name.
|
||
assert_eq!(gs("/A << /HT << /HalftoneType 6 >> >>"), ["A halftone has type 6: only types 1 and 5 are allowed"]);
|
||
assert_eq!(gs("/A << /HT 7 0 R >>"), ["A halftone has type 6: only types 1 and 5 are allowed"]);
|
||
assert_eq!(
|
||
gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Magenta << /HalftoneType 10 >> >> >>"),
|
||
["A halftone has type 10: only types 1 and 5 are allowed"]
|
||
);
|
||
assert_eq!(gs("/A << /HT << /HalftoneType 1 /HalftoneName (Screen) >> >>"), [name]);
|
||
assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default << /HalftoneType 1 /HalftoneName (S) >> >> >>"), [name]);
|
||
// Rendering intents.
|
||
assert_eq!(gs("/A << /RI /Custom >>"), ["A graphics state has the rendering intent Custom, which isn't a standard one"]);
|
||
assert_eq!(gs("/A << /RI (Perceptual) >>"), ["A graphics state has a rendering intent that isn't a name"]);
|
||
// A halftone's type is required; a null name is no name.
|
||
let untyped = ["A halftone's HalftoneType is missing or isn't an integer"];
|
||
assert_eq!(gs("/A << /HT << /Frequency 60 >> >>"), untyped);
|
||
assert_eq!(gs("/A << /HT << /HalftoneType 1.0 >> >>"), untyped);
|
||
assert!(gs("/A << /HT << /HalftoneType 1 /HalftoneName null >> >>").is_empty());
|
||
// Transfer functions in halftones: only, and always, for a non-primary colorant.
|
||
let in_halftone = "A halftone has a transfer function, which only a non-primary colorant's halftone may have";
|
||
assert_eq!(gs("/A << /HT << /HalftoneType 1 /TransferFunction 4 0 R >> >>"), [in_halftone]);
|
||
assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Cyan << /HalftoneType 1 /TransferFunction /Identity >> >> >>"), [in_halftone]);
|
||
// The primary components are those of DeviceGray, DeviceRGB and DeviceCMYK (ISO 32000-1 10.5.5.6).
|
||
assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Gray << /HalftoneType 1 /TransferFunction /Identity >> >> >>"), [in_halftone]);
|
||
assert_eq!(
|
||
gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Spot 5 0 R /Red 5 0 R /Gray 5 0 R >> >>"),
|
||
["The halftone for the colorant Spot has no transfer function"]
|
||
);
|
||
assert!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Black 5 0 R /Spot << /HalftoneType 1 /TransferFunction 4 0 R >> >> >>").is_empty());
|
||
// Private application data isn't graphics resources.
|
||
assert!(
|
||
gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private << /Resources << /ExtGState << /X << /TR 4 0 R >> >> >> >> >>").is_empty()
|
||
);
|
||
// Nor is an object (8) only private data refers to; one the page refers to is read.
|
||
assert!(gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private 8 0 R >> >> /Z <<").is_empty());
|
||
assert_eq!(gs("/A << /RI /Perceptual >> >> /Other 8 0 R /Z <<"), [tr]);
|
||
// A shading pattern holds one graphics state, not a dictionary of them.
|
||
let pattern = |gs: &str| format!("/A << /RI /Perceptual >> >> /Pattern << /P << /PatternType 2 /Shading << /ShadingType 2 >> /ExtGState {gs} >>");
|
||
assert_eq!(gs(&pattern("<< /TR 4 0 R >>")), [tr]);
|
||
assert!(gs(&pattern("<< /HT << /HalftoneType 1 >> >>")).is_empty());
|
||
}
|
||
|
||
/// From the review of the 6.2.5 check: references that differ only in generation reach the same
|
||
/// object, which is read once.
|
||
#[test]
|
||
fn graphics_state_scan_reads_each_object_once_whatever_the_generation() {
|
||
let gens: String = (0..10).map(|g| format!("4 {g} R ")).collect();
|
||
let page = format!("<< /Type /Page /Parent 2 0 R /Same [{gens}] >>");
|
||
let doc =
|
||
build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page, "[1 2 3]"], "");
|
||
// The catalog, the page tree, the page and object 4.
|
||
assert_eq!(gstate::reached(&doc), 4);
|
||
}
|
||
|
||
/// PDF/A-2 6.1.3: the trailer `startxref` points to has a file identifier, two non-empty
|
||
/// strings; Save as PDF/A gives a file one.
|
||
#[test]
|
||
fn trailer_file_identifier_is_checked_and_added() {
|
||
let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"];
|
||
let id = |extra: &str| -> Vec<String> {
|
||
let doc = build(&objs, extra);
|
||
verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.3").map(|i| i.message).collect()
|
||
};
|
||
let missing = ["The trailer has no file identifier (ID)"];
|
||
let malformed = ["The file identifier (ID) in the trailer isn't two non-empty strings"];
|
||
assert!(id("/ID [<0011> (ab)]").is_empty());
|
||
assert_eq!(id(""), missing);
|
||
assert_eq!(id("/ID null"), missing);
|
||
for wrong in ["/ID [<><>]", "/ID [<0011>]", "/ID [<0011> <0011> <0011>]", "/ID [1 2]", "/ID (ab)"] {
|
||
assert_eq!(id(wrong), malformed, "{wrong}");
|
||
}
|
||
// An update without an /ID has none, whatever the trailer it replaces had.
|
||
let base = build(&objs, "/ID [<0011> <0011>]");
|
||
let mut bytes = base.bytes().to_vec();
|
||
let prev = bytes.windows(9).rposition(|w| w == b"startxref").unwrap();
|
||
let prev: usize = String::from_utf8_lossy(&bytes[prev + 10..]).lines().next().unwrap().trim().parse().unwrap();
|
||
let at = bytes.len();
|
||
bytes.extend_from_slice(
|
||
format!("xref\n0 1\n0000000000 65535 f \ntrailer\n<< /Size 3 /Root 1 0 R /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(),
|
||
);
|
||
let updated = Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||
assert!(verify(&updated, Level::A2b).iter().any(|i| i.message == missing[0]));
|
||
// Save as PDF/A adds one, which a save keeps.
|
||
for extra in ["", "/ID [<><>]"] {
|
||
let mut doc = build(&objs, extra);
|
||
let report = convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(report.fixed.iter().any(|f| f == "Added a file identifier (ID)"), "{extra}");
|
||
assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}");
|
||
let saved = pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap();
|
||
let saved = Document::open(std::sync::Arc::new(saved)).unwrap();
|
||
assert!(verify(&saved, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}");
|
||
}
|
||
// A good one is kept as it is.
|
||
let mut doc = build(&objs, "/ID [<0011> <0022>]");
|
||
let before = doc.trailer().get(b"ID").cloned();
|
||
assert!(!convert(&mut doc, Level::A2b).unwrap().fixed.iter().any(|f| f.contains("identifier")));
|
||
assert_eq!(doc.trailer().get(b"ID").cloned(), before);
|
||
// A malformed one keeps its non-empty first string, the permanent identifier (14.4).
|
||
let first = |doc: &Document| doc.trailer().get(b"ID").and_then(|id| id.as_array()?.first()?.as_string().map(|s| s.bytes.clone()));
|
||
let mut doc = build(&objs, "/ID [<0011> <>]");
|
||
let report = convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(report.fixed.iter().any(|f| f == "Completed the file identifier (ID)"));
|
||
assert_eq!(first(&doc), Some(vec![0x00, 0x11]));
|
||
assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.3"));
|
||
let mut doc = build(&objs, "/ID [<> <0011>]");
|
||
convert(&mut doc, Level::A2b).unwrap();
|
||
assert_eq!(first(&doc).map(|b| b.len()), Some(16));
|
||
// A first string longer than a conforming file may hold (Annex C) is made afresh too.
|
||
let long = format!("/ID [<{}> <>]", "ab".repeat(32768));
|
||
let mut doc = build(&objs, &long);
|
||
let report = convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(report.fixed.iter().any(|f| f == "Added a file identifier (ID)"));
|
||
assert_eq!(first(&doc).map(|b| b.len()), Some(16));
|
||
let longest = format!("/ID [<{}> <>]", "ab".repeat(32767));
|
||
let mut doc = build(&objs, &longest);
|
||
convert(&mut doc, Level::A2b).unwrap();
|
||
assert_eq!(first(&doc).map(|b| b.len()), Some(32767));
|
||
}
|
||
|
||
/// PDF/A-2 6.5.1: the PDF 1.2 SetState and NOP actions aren't allowed, and a Named action may
|
||
/// only name NextPage, PrevPage, FirstPage or LastPage; Save as PDF/A removes the others.
|
||
#[test]
|
||
fn set_state_no_op_and_named_actions_are_checked() {
|
||
let doc_with = |action: &str| {
|
||
let outline = format!("<< /Title (x) /Parent 4 0 R /A {action} >>");
|
||
build(
|
||
&[
|
||
"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>",
|
||
"<< /Type /Pages /Kids [] /Count 0 >>",
|
||
&outline,
|
||
"<< /Type /Outlines /First 3 0 R /Last 3 0 R /Count 1 >>",
|
||
"/NextPage",
|
||
"/Named",
|
||
"/NOP",
|
||
],
|
||
"",
|
||
)
|
||
};
|
||
let actions = |action: &str| -> Vec<String> {
|
||
verify(&doc_with(action), Level::A2b).into_iter().filter(|i| i.clause == "6.5.1").map(|i| i.message).collect()
|
||
};
|
||
assert_eq!(actions("<< /Type /Action /S /SetState /T [] >>"), ["A SetState action isn't allowed"]);
|
||
assert_eq!(actions("<< /S /NOP >>"), ["A NOP action isn't allowed"]);
|
||
assert_eq!(actions("<< /S /Named /N /Print >>"), ["A Named Print action isn't allowed"]);
|
||
assert_eq!(actions("<< /S /Named >>"), ["A Named action isn't allowed"]);
|
||
// Behind an allowed action, in its /Next.
|
||
assert_eq!(actions("<< /S /GoTo /D [0 /Fit] /Next << /S /NOP >> >>"), ["A NOP action isn't allowed"]);
|
||
for allowed in ["NextPage", "PrevPage", "FirstPage", "LastPage"] {
|
||
assert!(actions(&format!("<< /S /Named /N /{allowed} >>")).is_empty(), "{allowed}");
|
||
}
|
||
// The name given by reference (object 5 is /NextPage).
|
||
assert!(actions("<< /S /Named /N 5 0 R >>").is_empty());
|
||
// The type given by reference (object 6 is /Named, 7 is /NOP).
|
||
assert_eq!(actions("<< /S 6 0 R /N /Print >>"), ["A Named Print action isn't allowed"]);
|
||
assert_eq!(actions("<< /S 7 0 R >>"), ["A NOP action isn't allowed"]);
|
||
assert!(actions("<< /S 6 0 R /N 5 0 R >>").is_empty());
|
||
// Save as PDF/A removes them.
|
||
for action in ["<< /S /SetState /T [] >>", "<< /S /NOP >>", "<< /S /Named /N /Print >>"] {
|
||
let mut doc = doc_with(action);
|
||
convert(&mut doc, Level::A2b).unwrap();
|
||
assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.5.1"), "{action}");
|
||
}
|
||
// A permitted Named action survives it, also one naming its page move by reference.
|
||
for action in ["<< /S /Named /N /NextPage >>", "<< /S /Named /N 5 0 R >>"] {
|
||
let mut doc = doc_with(action);
|
||
convert(&mut doc, Level::A2b).unwrap();
|
||
let outline = doc.get(ObjRef { num: 3, generation: 0 });
|
||
let kept = outline
|
||
.as_dict()
|
||
.and_then(|d| d.get(b"A"))
|
||
.and_then(Object::as_dict)
|
||
.map(|a| (a.name(b"S").map(<[u8]>::to_vec), a.get(b"N").map(|n| doc.resolve(n))));
|
||
let (s, n) = kept.unwrap_or_else(|| panic!("{action}: the action is gone"));
|
||
assert_eq!(s.as_deref(), Some(&b"Named"[..]), "{action}");
|
||
assert_eq!(n.as_deref().and_then(Object::as_name), Some(&b"NextPage"[..]), "{action}");
|
||
}
|
||
}
|
||
|
||
/// The SetState, NOP and Named checks of 6.5.1 apply wherever an action can be: the catalog's
|
||
/// `/OpenAction`, an action standing alone as an object of its own (here one a page's `/AA`
|
||
/// refers to), and a link's `/A`, not only an outline item's. A Named action's `/N` given by
|
||
/// reference to a forbidden name is reported by that name. Save as PDF/A removes each one and
|
||
/// keeps a Named page move in the same places.
|
||
#[test]
|
||
fn set_state_no_op_and_named_actions_are_checked_wherever_actions_are() {
|
||
// Object 5 is the standalone action, 6 is /Print and 7 is /NextPage.
|
||
let doc_with = |open_action: &str, page_aa: &str, link_action: &str, standalone: &str| {
|
||
let catalog = format!("<< /Type /Catalog /Pages 2 0 R {open_action} >>");
|
||
let page = format!("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] {page_aa} >>");
|
||
let link = format!("<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R /F 4 {link_action} >>");
|
||
build(&[&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page, &link, standalone, "/Print", "/NextPage"], "")
|
||
};
|
||
let goto = "<< /S /GoTo /D [3 0 R /Fit] >>";
|
||
// Where the action is, as (open action, page /AA, link /A, object 5).
|
||
let placed = |action: &str| {
|
||
[
|
||
("an open action", doc_with(&format!("/OpenAction {action}"), "", "", goto)),
|
||
("an open action by reference", doc_with("/OpenAction 5 0 R", "", "", action)),
|
||
("a page's /AA", doc_with("", "/AA << /O 5 0 R >>", "", action)),
|
||
("a link's /A", doc_with("", "", &format!("/A {action}"), goto)),
|
||
]
|
||
};
|
||
let forbidden = [
|
||
("<< /S /SetState /T [] >>", "A SetState action isn't allowed"),
|
||
("<< /S /NOP >>", "A NOP action isn't allowed"),
|
||
("<< /S /Named /N /Print >>", "A Named Print action isn't allowed"),
|
||
("<< /S /Named /N 6 0 R >>", "A Named Print action isn't allowed"),
|
||
];
|
||
let plain = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() };
|
||
for (action, message) in forbidden {
|
||
for (place, mut doc) in placed(action) {
|
||
let issues = verify(&doc, Level::A2b);
|
||
let found: Vec<&str> = issues.iter().filter(|i| i.clause == "6.5.1").map(|i| i.message.as_str()).collect();
|
||
assert_eq!(found, [message], "{action} in {place}: {issues:#?}");
|
||
let r = convert(&mut doc, Level::A2b).unwrap();
|
||
let left: Vec<_> = r.remaining.iter().filter(|i| i.clause.starts_with("6.5")).collect();
|
||
assert!(left.is_empty(), "{action} in {place}: {left:#?}");
|
||
let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap();
|
||
for kind in [&b"/SetState"[..], b"/NOP", b"/Named"] {
|
||
assert!(!bytes.windows(kind.len()).any(|w| w == kind), "{action} in {place}: {}", String::from_utf8_lossy(&bytes));
|
||
}
|
||
}
|
||
}
|
||
// A Named page move, with its name given by reference, is allowed and kept outside /AA.
|
||
for (place, mut doc) in placed("<< /S /Named /N 7 0 R >>") {
|
||
let issues = verify(&doc, Level::A2b);
|
||
assert!(!issues.iter().any(|i| i.clause == "6.5.1"), "{place}: {issues:#?}");
|
||
convert(&mut doc, Level::A2b).unwrap();
|
||
let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap();
|
||
let kept = bytes.windows(6).any(|w| w == b"/Named");
|
||
assert_eq!(kept, place != "a page's /AA", "{place}: {}", String::from_utf8_lossy(&bytes));
|
||
}
|
||
}
|
||
|
||
/// 6.2.10: without a PDF/A output intent, a page whose resources can paint transparently names its
|
||
/// blending colour space in its `/Group`. Transparency is found through graphics states, images,
|
||
/// transparency groups, nested form XObjects, Type 3 fonts, patterns and annotation appearances.
|
||
#[test]
|
||
fn a_page_with_transparency_and_no_output_intent_names_its_blending_space() {
|
||
// With `intent`, the catalog has a PDF/A output intent, its profile the last object.
|
||
let blending = |intent: bool, page_extra: &str, resources: &str, extra: &[&str]| -> Vec<String> {
|
||
let profile = extra.len() + 4;
|
||
let intents =
|
||
format!("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB) /DestOutputProfile {profile} 0 R >>]");
|
||
let catalog = format!("<< /Type /Catalog /Pages 2 0 R {} >>", if intent { intents.as_str() } else { "" });
|
||
let page = format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> {page_extra} >>");
|
||
let mut objs: Vec<&str> = vec![&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page];
|
||
objs.extend_from_slice(extra);
|
||
if intent {
|
||
objs.push(
|
||
"<< /N 3 /Length 0 >>
|
||
stream
|
||
|
||
endstream",
|
||
);
|
||
}
|
||
verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect()
|
||
};
|
||
let said = ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"];
|
||
let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream");
|
||
let gs = |entries: &str| format!("/ExtGState << /G << {entries} >> >>");
|
||
for (what, resources, extra) in [
|
||
("fill alpha", gs("/ca 0.5"), vec![]),
|
||
("stroke alpha", gs("/CA 0.25"), vec![]),
|
||
("blend mode", gs("/BM /Darken"), vec![]),
|
||
("blend mode array", gs("/BM [/Multiply /Normal]"), vec![]),
|
||
("soft mask", gs("/SMask << /S /Luminosity /G 4 0 R >>"), vec![form("")]),
|
||
(
|
||
"image soft mask",
|
||
"/XObject << /Im 4 0 R >>".to_owned(),
|
||
vec![
|
||
"<< /Subtype /Image /Width 1 /Height 1 /SMask 5 0 R /Length 0 >>\nstream\n\nendstream".to_owned(),
|
||
"<< /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 0 >>\nstream\n\nendstream".to_owned(),
|
||
],
|
||
),
|
||
(
|
||
"SMaskInData",
|
||
"/XObject << /Im 4 0 R >>".to_owned(),
|
||
vec!["<< /Subtype /Image /Width 1 /Height 1 /SMaskInData 1 /Length 0 >>\nstream\n\nendstream".to_owned()],
|
||
),
|
||
("transparency group", "/XObject << /Fm 4 0 R >>".to_owned(), vec![form("/Group << /S /Transparency >>")]),
|
||
(
|
||
"nested form",
|
||
"/XObject << /Fm 4 0 R >>".to_owned(),
|
||
vec![form("/Resources << /XObject << /In 5 0 R >> >>"), form("/Resources << /ExtGState << /G << /ca 0.5 >> >> >>")],
|
||
),
|
||
(
|
||
"Type 3 font",
|
||
"/Font << /T3 4 0 R >>".to_owned(),
|
||
vec!["<< /Type /Font /Subtype /Type3 /Resources << /ExtGState << /G << /CA 0.5 >> >> >> >>".to_owned()],
|
||
),
|
||
("tiling pattern", "/Pattern << /P 4 0 R >>".to_owned(), vec![form("/PatternType 1 /Resources << /ExtGState << /G << /BM /Screen >> >> >>")]),
|
||
("shading pattern", "/Pattern << /P << /PatternType 2 /ExtGState << /ca 0.5 >> >> >>".to_owned(), vec![]),
|
||
] {
|
||
let extra: Vec<&str> = extra.iter().map(String::as_str).collect();
|
||
assert_eq!(blending(false, "", &resources, &extra), said, "{what}");
|
||
// A page group with a blending colour space, or a PDF/A output intent, satisfies the rule.
|
||
assert!(
|
||
blending(false, "/Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >>", &resources, &extra).is_empty(),
|
||
"{what}: page /CS"
|
||
);
|
||
assert_eq!(blending(false, "/Group << /S /Transparency >>", &resources, &extra), said, "{what}: page group without /CS");
|
||
assert!(blending(true, "", &resources, &extra).is_empty(), "{what}: output intent");
|
||
}
|
||
// Annotation appearances, as one stream or a dictionary of streams by state.
|
||
let annot = |ap: &str| format!("<< /Type /Annot /Subtype /Widget /Rect [0 0 10 10] /AP << /N {ap} >> >>");
|
||
assert_eq!(blending(false, "/Annots [4 0 R]", "", &[&annot("5 0 R"), &form("/Group << /S /Transparency >>")]), said, "appearance");
|
||
assert_eq!(
|
||
blending(false, "/Annots [4 0 R]", "", &[&annot("<< /On 5 0 R /Off 6 0 R >>"), &form(""), &form("/Group << /S /Transparency >>")]),
|
||
said,
|
||
"appearance states"
|
||
);
|
||
// Opaque painting needs no blending colour space: alpha 1, Normal or Compatible blending, no
|
||
// soft mask, an image without one, a form that is no transparency group.
|
||
for (what, resources, extra) in [
|
||
("opaque", gs("/CA 1 /ca 1.0 /BM /Normal /SMask /None"), vec![]),
|
||
("compatible", gs("/BM [/Compatible /Darken]"), vec![]),
|
||
(
|
||
"image",
|
||
"/XObject << /Im 4 0 R >>".to_owned(),
|
||
vec!["<< /Subtype /Image /Width 1 /Height 1 /SMaskInData 0 /Length 0 >>\nstream\n\nendstream".to_owned()],
|
||
),
|
||
(
|
||
"plain form, its resources cycling back to itself",
|
||
"/XObject << /Fm 4 0 R >>".to_owned(),
|
||
vec![form("/Resources << /XObject << /Me 4 0 R >> >>")],
|
||
),
|
||
(
|
||
"Type 1 font",
|
||
"/Font << /F 4 0 R >>".to_owned(),
|
||
vec!["<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Resources << /ExtGState << /G << /ca 0.5 >> >> >> >>".to_owned()],
|
||
),
|
||
] {
|
||
let extra: Vec<&str> = extra.iter().map(String::as_str).collect();
|
||
assert!(blending(false, "", &resources, &extra).is_empty(), "{what}");
|
||
}
|
||
}
|
||
|
||
/// 6.2.10: a graphics state's blend mode, or each name of a blend mode array, is one ISO 32000-1
|
||
/// 11.3.5 defines, whether the graphics state is an object of its own or written in a page's
|
||
/// resources.
|
||
#[test]
|
||
fn blend_modes_are_the_ones_iso_32000_1_defines() {
|
||
let modes = |resources: &str, extra: &[&str]| -> Vec<String> {
|
||
let page = format!(
|
||
"<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << {resources} >> >>"
|
||
);
|
||
let mut objs: Vec<&str> =
|
||
vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page];
|
||
objs.extend_from_slice(extra);
|
||
verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect()
|
||
};
|
||
let custom = ["A graphics state uses the blend mode Custom, which ISO 32000-1 doesn't define"];
|
||
assert_eq!(modes("/ExtGState << /G 4 0 R >>", &["<< /BM /Custom >>"]), custom, "an object of its own");
|
||
assert_eq!(modes("/ExtGState << /G << /BM /Custom >> >>", &[]), custom, "written in the resources");
|
||
assert_eq!(modes("/ExtGState << /G << /BM [/Multiply /Custom] >> >>", &[]), custom, "in an array");
|
||
let all = "/Normal /Compatible /Multiply /Screen /Overlay /Darken /Lighten /ColorDodge /ColorBurn /HardLight /SoftLight /Difference /Exclusion /Hue /Saturation /Color /Luminosity";
|
||
assert!(modes(&format!("/ExtGState << /G << /BM [{all}] >> >>"), &[]).is_empty());
|
||
}
|
||
|
||
/// 6.2.10's blending colour space is one PDF/A accepts without an output intent: not null, and
|
||
/// not a device space unless the page gives it a default (6.2.4.3). An output intent stands in
|
||
/// for it only with a profile.
|
||
#[test]
|
||
fn a_blending_space_is_device_independent_and_an_intent_has_a_profile() {
|
||
let page_said = ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"];
|
||
let check = |catalog_extra: &str, page_extra: &str, extra: &[&str]| -> Vec<String> {
|
||
let catalog = format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>");
|
||
let page = format!("<< /Type /Page /Parent 2 0 R {page_extra} >>");
|
||
let mut objs: Vec<&str> = vec![&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page];
|
||
objs.extend_from_slice(extra);
|
||
verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect()
|
||
};
|
||
let alpha = "/ExtGState << /G << /ca 0.5 >> >>";
|
||
let page = |group: &str, colour_spaces: &str| format!("/Group << /S /Transparency {group} >> /Resources << {alpha} {colour_spaces} >>");
|
||
assert_eq!(check("", &page("/CS null", ""), &[]), page_said, "a null /CS");
|
||
assert_eq!(check("", &page("/CS 9 0 R", ""), &[]), page_said, "a /CS naming no object");
|
||
// A device space needs a default of as many components (ISO 32000-1 8.6.5.6).
|
||
let cmyk_profile = "<< /N 4 /Length 0 >>\nstream\n\nendstream";
|
||
let cal = |family: &str| format!("[/{family} << /WhitePoint [0.9505 1 1.089] >>]");
|
||
for (device, default, space) in [
|
||
("DeviceGray", "DefaultGray", cal("CalGray")),
|
||
("DeviceRGB", "DefaultRGB", cal("CalRGB")),
|
||
("DeviceCMYK", "DefaultCMYK", "[/ICCBased 4 0 R]".to_owned()),
|
||
] {
|
||
assert_eq!(check("", &page(&format!("/CS /{device}"), ""), &[cmyk_profile]), page_said, "{device}");
|
||
let defaults = format!("/ColorSpace << /{default} {space} >>");
|
||
assert!(check("", &page(&format!("/CS /{device}"), &defaults), &[cmyk_profile]).is_empty(), "{device} with {default}");
|
||
let null = format!("/ColorSpace << /{default} null >>");
|
||
assert_eq!(check("", &page(&format!("/CS /{device}"), &null), &[cmyk_profile]), page_said, "{device} with a null {default}");
|
||
}
|
||
let wrong = format!("/ColorSpace << /DefaultGray {} >>", cal("CalRGB"));
|
||
assert_eq!(check("", &page("/CS /DeviceGray", &wrong), &[]), page_said, "DeviceGray with a CalRGB default");
|
||
// Only CalGray, CalRGB and gray, RGB or CMYK ICCBased spaces blend (ISO 32000-1 11.3.4).
|
||
assert!(check("", &page("/CS [/CalGray << /WhitePoint [0.9505 1 1.089] >>]", ""), &[]).is_empty(), "CalGray");
|
||
let icc = |n: u8, data: &str| format!("<< /N {n} /Length {} >>\nstream\n{data}\nendstream", data.len());
|
||
assert!(check("", &page("/CS [/ICCBased 4 0 R]", ""), &[&icc(3, "")]).is_empty(), "an ICC profile of three components");
|
||
for (what, cs, extra) in [
|
||
("Indexed", "/CS [/Indexed /DeviceRGB 1 <000000FFFFFF>]".to_owned(), vec![]),
|
||
("CalRGB without its dictionary", "/CS [/CalRGB]".to_owned(), vec![]),
|
||
("CalGray without a white point", "/CS [/CalGray << >>]".to_owned(), vec![]),
|
||
("CalRGB with a short white point", "/CS [/CalRGB << /WhitePoint [0.9505 1] >>]".to_owned(), vec![]),
|
||
("CalRGB with a zero white point", "/CS [/CalRGB << /WhitePoint [0 0 0] >>]".to_owned(), vec![]),
|
||
("CalGray with a white point whose Y isn't 1", "/CS [/CalGray << /WhitePoint [0.9505 0.5 1.089] >>]".to_owned(), vec![]),
|
||
("CalRGB with a non-number in its white point", "/CS [/CalRGB << /WhitePoint [0.9505 1 /Z 1.089] >>]".to_owned(), vec![]),
|
||
("Lab", "/CS [/Lab << /WhitePoint [0.9505 1 1.089] >>]".to_owned(), vec![]),
|
||
(
|
||
"Separation",
|
||
"/CS [/Separation /Spot /DeviceCMYK 4 0 R]".to_owned(),
|
||
vec!["<< /FunctionType 2 /Domain [0 1] /C1 [0 0 0 1] /N 1 >>".to_owned()],
|
||
),
|
||
("an ICC profile of two components", "/CS [/ICCBased 4 0 R]".to_owned(), vec![icc(2, "")]),
|
||
("a Lab ICC profile", "/CS [/ICCBased 4 0 R]".to_owned(), vec![icc(3, &format!("{}Lab ", "\0".repeat(16)))]),
|
||
(
|
||
"an ICC profile of one component whose header says RGB",
|
||
"/CS [/ICCBased 4 0 R]".to_owned(),
|
||
vec![icc(1, &format!("{}RGB ", "\0".repeat(16)))],
|
||
),
|
||
("a pattern space", "/CS /Pattern".to_owned(), vec![]),
|
||
] {
|
||
let extra: Vec<&str> = extra.iter().map(String::as_str).collect();
|
||
assert_eq!(check("", &page(&cs, ""), &extra), page_said, "{what}");
|
||
}
|
||
// A null soft mask is none: an otherwise opaque graphics state is not transparency.
|
||
assert!(check("", "/Resources << /ExtGState << /G << /SMask null >> >> >>", &[]).is_empty(), "/SMask null");
|
||
let intent = |profile: &str| format!("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB) {profile} >>]");
|
||
let opaque_group = format!("/Resources << {alpha} >>");
|
||
assert_eq!(check(&intent("/DestOutputProfile null"), &opaque_group, &[]), page_said, "a null profile");
|
||
assert_eq!(check(&intent(""), &opaque_group, &[]), page_said, "no profile");
|
||
assert!(check(&intent("/DestOutputProfile 4 0 R"), &opaque_group, &["<< /N 3 /Length 0 >>\nstream\n\nendstream"]).is_empty(), "a profile");
|
||
// Private data is no graphics state: a /BM there is not a blend mode.
|
||
assert!(check("/PieceInfo << /App << /Private << /BM /Bookkeeping >> >> >>", "", &[]).is_empty(), "/PieceInfo");
|
||
}
|
||
|
||
/// A form XObject first reached at the depth limit, where its resources go unread, is read again
|
||
/// when a shallower place names it, so what it holds is not missed.
|
||
#[test]
|
||
fn transparency_reached_first_too_deep_is_found_from_a_shallower_place() {
|
||
let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream");
|
||
// A chain of 32 forms, objects 4 to 35, the last naming form 36, which holds a translucent
|
||
// graphics state; the page names the chain first and form 36 after it.
|
||
let mut objs: Vec<String> = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".to_owned(),
|
||
"<< /Type /Page /Parent 2 0 R /Resources << /XObject << /A 4 0 R /B 36 0 R >> >> >>".to_owned(),
|
||
];
|
||
for n in 4..=35 {
|
||
objs.push(form(&format!("/Resources << /XObject << /X {} 0 R >> >>", n + 1)));
|
||
}
|
||
objs.push(form("/Resources << /ExtGState << /G << /ca 0.5 >> >> >>"));
|
||
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||
let said: Vec<String> = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect();
|
||
assert_eq!(said, ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"]);
|
||
}
|
||
|
||
/// 6.2.10: an unknown blend mode is reported on the page whose resources reach it, as the
|
||
/// transparency issue is, each page once.
|
||
#[test]
|
||
fn an_unknown_blend_mode_names_its_page() {
|
||
let page = "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << /ExtGState << /G 5 0 R >> >> >>";
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>",
|
||
"<< /Type /Page /Parent 2 0 R >>",
|
||
page,
|
||
"<< /BM /Custom >>",
|
||
];
|
||
let found: Vec<(String, Option<usize>)> =
|
||
verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| (i.message, i.page)).collect();
|
||
assert_eq!(found, [("A graphics state uses the blend mode Custom, which ISO 32000-1 doesn't define".to_owned(), Some(1))]);
|
||
}
|
||
|
||
/// A `/BM` array of many unknown names gathers a bounded number of them.
|
||
#[test]
|
||
fn unknown_blend_mode_names_are_capped() {
|
||
let names: String = (0..1000).map(|k| format!("/Custom{k} ")).collect();
|
||
let page = format!(
|
||
"<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << /ExtGState << /G << /BM [/Multiply {names}] >> >> >> >>"
|
||
);
|
||
let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page];
|
||
let said: Vec<String> = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect();
|
||
assert_eq!(said.len(), 32);
|
||
assert_eq!(said.first().map(String::as_str), Some("A graphics state uses the blend mode Custom0, which ISO 32000-1 doesn't define"));
|
||
}
|
||
|
||
/// Pages that share a form read it once: the next page takes what was found under it from
|
||
/// [`transparency::Shared`] in one read, and still reports it.
|
||
#[test]
|
||
fn pages_sharing_a_form_read_it_once() {
|
||
let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream");
|
||
let pages = 50;
|
||
let first_form = 3 + pages;
|
||
let kids: String = (0..pages).map(|k| format!("{} 0 R ", 3 + k)).collect();
|
||
let mut objs =
|
||
vec!["<< /Type /Catalog /Pages 2 0 R >>".to_owned(), format!("<< /Type /Pages /Kids [{kids}] /Count {pages} /MediaBox [0 0 200 200] >>")];
|
||
for _ in 0..pages {
|
||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Resources << /XObject << /F {first_form} 0 R >> >> >>"));
|
||
}
|
||
// A chain of ten forms, each with twenty graphics states, the last one translucent.
|
||
let gstates: String = (0..20).map(|k| format!("/G{k} << /CA 1 >> ")).collect();
|
||
for n in 0..10 {
|
||
let next = if n < 9 { format!("/XObject << /X {} 0 R >>", first_form + n + 1) } else { "/ExtGState << /T << /ca 0.5 >> >>".to_owned() };
|
||
objs.push(form(&format!("/Resources << /ExtGState << {gstates} >> {next} >>")));
|
||
}
|
||
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||
let doc = build(&objs, "");
|
||
let list = pdfcraft_model::pages(&doc);
|
||
let mut shared = transparency::Shared::default();
|
||
let reads: Vec<usize> = list.iter().map(|p| transparency::page_reads(&doc, &p.dict, &mut shared)).collect();
|
||
assert!(reads.first().is_some_and(|&n| n > 200), "the first page reads the chain: {reads:?}");
|
||
assert!(reads.iter().skip(1).all(|&n| n == 2), "the others its resources and the form: {reads:?}");
|
||
let said = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").count();
|
||
assert_eq!(said, pages, "every page has transparency");
|
||
}
|
||
|
||
/// A form whose reading stopped at the depth limit on one page is not kept as read: a page that
|
||
/// names it from a shallower place reads what lies below it.
|
||
#[test]
|
||
fn a_form_cut_short_on_one_page_is_read_again_on_the_next() {
|
||
let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream");
|
||
// Page 1 (object 3) names a chain of 33 forms, objects 5 to 37, whose last holds a translucent
|
||
// graphics state beyond the depth limit; page 2 (object 4) names form 20, from which it is near.
|
||
let mut objs: Vec<String> = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
|
||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".to_owned(),
|
||
"<< /Type /Page /Parent 2 0 R /Resources << /XObject << /A 5 0 R >> >> >>".to_owned(),
|
||
"<< /Type /Page /Parent 2 0 R /Resources << /XObject << /A 20 0 R >> >> >>".to_owned(),
|
||
];
|
||
for n in 5..=36 {
|
||
objs.push(form(&format!("/Resources << /XObject << /X {} 0 R >> >>", n + 1)));
|
||
}
|
||
objs.push(form("/Resources << /ExtGState << /G << /ca 0.5 >> >> >>"));
|
||
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||
let said: Vec<String> = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect();
|
||
assert_eq!(said, ["Page 2 has transparency but no device-independent blending colour space or PDF/A output intent"]);
|
||
}
|