diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 05c481f88..9b68dc2ed 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -181,6 +181,13 @@ impl Copier<'_> { } } +/// Copy `o` from `src` into `dst`, reusing the copies listed in `map` (source → destination), so +/// an object the copied pages already brought along (an ICC profile) is not stored twice. +pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap) -> Object { + let mut c = Copier { src, map, pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }; + c.copy_value(dst, o, None) +} + /// Copy pages `src_pages` (0-based, in the given order) of `src` into `dst`, inserting them at /// position `at`. Returns the new page references in order. /// @@ -188,19 +195,24 @@ impl Copier<'_> { /// shared rather than stored twice (see `dedupe`). pub fn import_pages(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result, OrganizeError> { let first_new = dst.object_numbers().last().map_or(1, |n| n + 1); - let pages = import_pages_mapped(dst, src, src_pages, at)?.0; + let pages = import_pages_mapped(dst, src, src_pages, at)?.pages; let created: Vec = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect(); crate::dedupe::dedupe_resources(dst, &created, true); Ok(pages) } -/// `import_pages`, also returning the source-page → destination-page map. -fn import_pages_mapped( - dst: &mut Document, - src: &Document, - src_pages: &[usize], - at: usize, -) -> Result<(Vec, HashMap), OrganizeError> { +/// What `import_pages_mapped` copied. +struct Imported { + /// The new pages, in order. + pages: Vec, + /// Source page → destination page. + page_map: HashMap, + /// Every source object copied → its copy. + objects: HashMap, +} + +/// `import_pages`, also returning what was copied from where. +fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result { let source = walk(src)?; if let Some(bad) = src_pages.iter().find(|i| **i >= source.len()) { return Err(OrganizeError::NoSuchPage(*bad)); @@ -278,7 +290,7 @@ fn import_pages_mapped( let inserted: Vec<(ObjRef, Dict)> = new_pages.iter().map(|r| (*r, Dict::new())).collect(); existing.splice(at..at, inserted); rebuild(dst, &existing)?; - Ok((new_pages, copier.pages)) + Ok(Imported { pages: new_pages, page_map: copier.pages, objects: copier.map }) } /// Look up a named destination in the source (`/Dests` dictionary or `/Names /Dests` tree). @@ -410,16 +422,19 @@ fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), Organize } /// A new document containing copies of `pages` from `src` (Extract Pages / Split). -/// Document information (title, author…) is carried over. +/// Document information (title, author…) is carried over, and so is the print standard the +/// source declares (PDF/X: its output intents and identification, #263). pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { let mut out = Document::new_empty(); // One source: nothing to deduplicate. - import_pages_mapped(&mut out, src, pages, 0)?; + let imported = import_pages_mapped(&mut out, src, pages, 0)?; for key in crate::INFO_KEYS { if let Some(v) = crate::info(src, key) { crate::set_info(&mut out, key, &v)?; } } + // After the title: the XMP packet repeats it. + crate::pdfx::carry(&mut out, src, &crate::pdfx::PrintStandard::of(src), imported.objects)?; // The pages share their source's resource dictionary, so the part would otherwise carry every // XObject the source lists — images and all (#204). Keep only what these pages draw. crate::prune::prune_unused_xobjects(&mut out)?; @@ -463,7 +478,8 @@ pub fn split(src: &Document, by: &SplitBy) -> Result, OrganizeErro /// Combine whole documents, in order, into a new document. Each source gets a top-level /// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files. /// The source's own bookmarks are nested (collapsed) under its entry, and its document-level -/// attachments are carried over. +/// attachments are carried over. When every source declares the same print standard (PDF/X +/// with the same output intent), so does the result. pub fn combine(sources: &[(&str, &Document)]) -> Result { let all: Vec<(&str, &Document, Option<&[usize]>)> = sources.iter().map(|(t, d)| (*t, *d, None)).collect(); combine_selected(&all) @@ -475,6 +491,10 @@ pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Resu let mut out = Document::new_empty(); let mut marks = Vec::new(); let mut attachments = Vec::new(); + // The first source's print standard, and what its pages brought along; it carries over + // only when every source declares the same one. + let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap)> = None; + let mut agreed = true; for (title, src, chosen) in sources { let n = crate::page_count(src)?; let pages: Vec = match chosen { @@ -487,11 +507,19 @@ pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Resu None => (0..n).collect(), }; let at = crate::page_count(&out)?; - let (new, page_map) = import_pages_mapped(&mut out, src, &pages, at)?; - if let Some(first) = new.first() { - marks.push((title.to_string(), *first, *src, page_map)); + let imported = import_pages_mapped(&mut out, src, &pages, at)?; + if let Some(first) = imported.pages.first() { + marks.push((title.to_string(), *first, *src, imported.page_map)); } collect_attachments(&mut out, src, &mut attachments); + let declared = crate::pdfx::PrintStandard::of(src); + match &standard { + None => standard = Some((declared, *src, imported.objects)), + Some((first, _, _)) => agreed &= first.same_as(&declared), + } + } + if let Some((declared, src, objects)) = standard.filter(|_| agreed) { + crate::pdfx::carry(&mut out, src, &declared, objects)?; } // Sources often share fonts, images and profiles (or are the same file): store them once. let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 0a63a0856..1b5d83471 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -18,6 +18,7 @@ mod dedupe; mod import; mod labels; mod outline; +mod pdfx; mod prune; pub mod view; @@ -347,12 +348,17 @@ pub fn info(doc: &Document, key: &str) -> Option { pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { let value = value.trim(); let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value))); + set_info_entry(doc, key.as_bytes(), entry) +} + +/// Set (or remove, with `None`) a document-information entry of any type. +fn set_info_entry(doc: &mut Document, key: &[u8], entry: Option) -> Result<(), OrganizeError> { match doc.trailer().get(b"Info").cloned() { Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => { doc.update_dict(r, |d| match entry { - Some(v) => d.set(key.as_bytes().to_vec(), v), + Some(v) => d.set(key.to_vec(), v), None => { - d.remove(key.as_bytes()); + d.remove(key); } })?; } @@ -362,7 +368,7 @@ pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), Organi Some(Object::Dict(d)) => d.clone(), _ => Dict::new(), }; - d.set(key.as_bytes().to_vec(), v); + d.set(key.to_vec(), v); let r = doc.add(d); doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r)); } diff --git a/crates/organize/src/pdfx.rs b/crates/organize/src/pdfx.rs new file mode 100644 index 000000000..eb240d1a2 --- /dev/null +++ b/crates/organize/src/pdfx.rs @@ -0,0 +1,195 @@ +//! The print standard a document declares (PDF/X, ISO 15930), carried into the new documents +//! Extract, Split and Combine make from its pages (#263). Apart from its pages, a PDF/X file is +//! the catalog's `/OutputIntents` (the printing condition and its ICC profile) and the version +//! it declares, in the document information (`GTS_PDFXVersion`, `GTS_PDFXConformance`, +//! `Trapped`) and in its XMP metadata (`pdfxid:`). +//! +//! The new document gets an XMP packet of its own, holding that identification and the title. +//! The source's packet is not copied: it can also claim standards the new document no longer +//! meets, such as PDF/UA, whose structure tree is not copied. + +use std::collections::HashMap; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; + +use crate::OrganizeError; + +/// Decoding limit for an output profile or an XMP packet (ICC profiles are a few MB at most). +const MAX_DECODED: usize = 64 << 20; + +/// What a document declares about the print standard it meets. +#[derive(Debug, Default)] +pub(crate) struct PrintStandard { + /// The catalog's `/OutputIntents`, as written. + intents: Option, + /// Each output intent's type (`/S`), printing condition and decoded profile, to compare. + conditions: Vec<(Vec, Vec, Vec)>, + /// `GTS_PDFXVersion` ("PDF/X-4"), from the document information or else the XMP metadata. + version: Option, + /// `GTS_PDFXConformance` ("PDF/X-1a:2001"), which PDF/X-1a and PDF/X-3 add. + conformance: Option, + /// `Trapped`. + trapped: Option<&'static str>, + /// Whether the document has XMP metadata (a PDF/X-1a:2001 file, PDF 1.3, has none). + xmp: bool, +} + +impl PrintStandard { + /// What `doc` declares. + pub(crate) fn of(doc: &Document) -> Self { + let catalog = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default(); + let intents = catalog.get(b"OutputIntents").cloned(); + let list = intents.as_ref().and_then(|o| doc.resolve(o).as_array().cloned()).unwrap_or_default(); + let conditions = list + .iter() + .map(|oi| { + let oi = doc.resolve(oi); + let d = oi.as_dict(); + let kind = d.and_then(|d| d.name(b"S")).unwrap_or_default().to_vec(); + let id = d.and_then(|d| d.get(b"OutputConditionIdentifier")).and_then(|v| doc.resolve(v).as_string().map(|s| s.bytes.clone())); + let profile = d.and_then(|d| d.get(b"DestOutputProfile")).map(|p| doc.resolve(p)).and_then(|p| match &*p { + // Undecodable: compare the bytes as stored. + Object::Stream(s) => Some(s.decoded_within(MAX_DECODED).unwrap_or_else(|_| s.raw.as_ref().clone())), + _ => None, + }); + (kind, id.unwrap_or_default(), profile.unwrap_or_default()) + }) + .collect(); + let xmp = metadata(doc, &catalog); + let declared = |info_key: &str, xmp_key: &str| { + crate::info(doc, info_key) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| xmp.as_deref().and_then(|x| xmp_value(x, xmp_key))) + }; + let trapped = info_trapped(doc).or_else(|| xmp.as_deref().and_then(|x| xmp_value(x, "pdf:Trapped")).and_then(|t| trapped(t.as_bytes()))); + Self { + intents, + conditions, + version: declared("GTS_PDFXVersion", "pdfxid:GTS_PDFXVersion"), + conformance: declared("GTS_PDFXConformance", "pdfxid:GTS_PDFXConformance"), + trapped, + xmp: xmp.is_some(), + } + } + + /// Whether `other` declares the same standard: the same version and trapping, and the same + /// output intents (type, printing condition and profile). + pub(crate) fn same_as(&self, other: &Self) -> bool { + self.conditions == other.conditions && self.version == other.version && self.conformance == other.conformance && self.trapped == other.trapped + } +} + +/// Give `dst` the print standard `src` declares: its output intents, copied with their profiles +/// (through `map`, what the pages already brought along), and, for PDF/X, the document +/// information entries and the XMP metadata that identify it. +pub(crate) fn carry(dst: &mut Document, src: &Document, standard: &PrintStandard, map: HashMap) -> Result<(), OrganizeError> { + let root = dst.root().ok_or(OrganizeError::NoPageTree)?; + if let Some(intents) = &standard.intents { + let copied = crate::import::copy_object(dst, src, intents, map); + dst.update_dict(root, |c| c.set(b"OutputIntents".to_vec(), copied))?; + } + let Some(version) = &standard.version else { return Ok(()) }; + crate::set_info_entry(dst, b"GTS_PDFXVersion", Some(Object::String(PdfString::text(version))))?; + if let Some(c) = &standard.conformance { + crate::set_info_entry(dst, b"GTS_PDFXConformance", Some(Object::String(PdfString::text(c))))?; + } + if let Some(t) = standard.trapped { + crate::set_info_entry(dst, b"Trapped", Some(Object::name(t)))?; + } + if standard.xmp { + let mut md = Dict::new(); + md.set(b"Type".to_vec(), Object::name("Metadata")); + md.set(b"Subtype".to_vec(), Object::name("XML")); + // Uncompressed, so other tools can find it. + let m = dst.add(Object::Stream(Stream::from_raw(md, packet(dst, standard, version).into_bytes()))); + dst.update_dict(root, |c| c.set(b"Metadata".to_vec(), Object::Ref(m)))?; + } + Ok(()) +} + +/// The XMP packet identifying `doc` as `version`, with its title and trapping. +fn packet(doc: &Document, standard: &PrintStandard, version: &str) -> String { + let mut props = String::new(); + if let Some(t) = crate::info(doc, "Title").filter(|t| !t.trim().is_empty()) { + props.push_str(&format!(" {}\n", esc(&t))); + } + if let Some(t) = standard.trapped { + props.push_str(&format!(" {t}\n")); + } + props.push_str(&format!(" {}\n", esc(version))); + if let Some(c) = &standard.conformance { + props.push_str(&format!(" {}\n", esc(c))); + } + format!( + "\n\ +\n\ + \n\ + \n\ +{props} \n\ + \n\ +\n\ +" + ) +} + +/// The document information's `/Trapped` (a name, or a string in older files). +fn info_trapped(doc: &Document) -> Option<&'static str> { + let info = doc.resolve(doc.trailer().get(b"Info")?); + let v = doc.resolve(info.as_dict()?.get(b"Trapped")?); + match &*v { + Object::Name(n) => trapped(n), + Object::String(s) => trapped(&s.bytes), + _ => None, + } +} + +fn trapped(v: &[u8]) -> Option<&'static str> { + match v { + b"True" => Some("True"), + b"False" => Some("False"), + b"Unknown" => Some("Unknown"), + _ => None, + } +} + +/// The catalog's XMP metadata, as text. +fn metadata(doc: &Document, catalog: &Dict) -> Option { + match &*doc.resolve(catalog.get(b"Metadata")?) { + Object::Stream(s) => s.decoded_within(MAX_DECODED).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + _ => None, + } +} + +/// A simple XMP property, written as an attribute (`pdfxid:GTS_PDFXVersion="PDF/X-4"`) or as an +/// element (`PDF/X-4`). +fn xmp_value(xmp: &str, key: &str) -> Option { + for quote in ['"', '\''] { + let attr = format!("{key}={quote}"); + if let Some(rest) = xmp.find(&attr).and_then(|i| xmp.get(i + attr.len()..)) { + return rest.find(quote).and_then(|end| rest.get(..end)).map(unescape).filter(|v| !v.is_empty()); + } + } + let open = format!("<{key}>"); + let rest = xmp.get(xmp.find(&open)? + open.len()..)?; + let value = rest.get(..rest.find(&format!(""))?)?; + Some(unescape(value.trim())).filter(|v| !v.is_empty()) +} + +fn unescape(s: &str) -> String { + s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") +} + +/// `s` as XML text: markup escaped, and characters XML 1.0 does not allow left out. +fn esc(s: &str) -> String { + s.chars() + .filter(|c| (!c.is_control() || matches!(c, '\t' | '\n' | '\r')) && !matches!(c, '\u{FFFE}' | '\u{FFFF}')) + .collect::() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 16273e441..cff9f9846 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -401,6 +401,167 @@ fn split_produces_standalone_documents_with_metadata() { } } +// ── PDF/X (#263) ─────────────────────────────────────────────────────────────────────────────── + +/// PDF/X-4 identification in XMP, as an attribute, next to a PDF/UA claim. +const X4_XMP: &str = ""; + +/// A two-page print file whose output intent prints to `condition` with `profile` as its ICC +/// profile, with `info` as its document information and `xmp` as its XMP metadata. +fn doc_print(condition: &str, profile: &str, info: &str, xmp: Option<&str>) -> Document { + let metadata = if xmp.is_some() { " /Metadata 10 0 R" } else { "" }; + let mut b: Vec = vec![ + format!("<< /Type /Catalog /Pages 2 0 R /OutputIntents [7 0 R]{metadata} >>"), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 252 144] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /TrimBox [9 9 243 135] >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /TrimBox [9 9 243 135] >>".into(), // 4 + body("X1"), // 5 + body("X2"), // 6 + format!("<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier ({condition}) /DestOutputProfile 8 0 R >>"), // 7 + format!("<< /N 4 /Length {} >>\nstream\n{profile}\nendstream", profile.len()), // 8 + format!("<< {info} >>"), // 9 + ]; + if let Some(x) = xmp { + b.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{x}\nendstream", x.len())); // 10 + } + open(build(&b, "/Root 1 0 R /Info 9 0 R")) +} + +/// A PDF/X-4 business card printing to `condition` with `profile`. +fn doc_x4(condition: &str, profile: &str) -> Document { + doc_print(condition, profile, "/Title (Card) /GTS_PDFXVersion (PDF/X-4) /Trapped /False", Some(X4_XMP)) +} + +/// What a document says about PDF/X. +#[derive(Debug, Default, PartialEq)] +struct Pdfx { + /// Each output intent's printing condition and decoded profile. + intents: Vec<(String, Vec)>, + version: Option, + trapped: Option>, + xmp: Option, +} + +fn pdfx_of(doc: &Document) -> Pdfx { + let cat = catalog(doc); + let list = cat.get(b"OutputIntents").map(|o| doc.resolve(o).as_array().cloned().unwrap_or_default()).unwrap_or_default(); + let intents = list + .iter() + .filter_map(|oi| { + let d = doc.resolve(oi).as_dict().cloned()?; + let id = d.get(b"OutputConditionIdentifier").and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default(); + let profile = match d.get(b"DestOutputProfile").map(|p| doc.resolve(p)).as_deref() { + Some(Object::Stream(s)) => s.decoded().unwrap(), + _ => Vec::new(), + }; + Some((id, profile)) + }) + .collect(); + let trapped = doc.trailer().get(b"Info").map(|i| doc.resolve(i)).and_then(|i| i.as_dict().and_then(|d| d.name(b"Trapped")).map(<[u8]>::to_vec)); + let xmp = match cat.get(b"Metadata").map(|m| doc.resolve(m)).as_deref() { + Some(Object::Stream(s)) => Some(String::from_utf8(s.decoded().unwrap()).unwrap()), + _ => None, + }; + Pdfx { intents, version: info(doc, "GTS_PDFXVersion"), trapped, xmp } +} + +#[test] +fn extract_and_split_keep_the_pdfx_output_intent_and_identification() { + // #263: the parts had no /OutputIntents, no GTS_PDFXVersion and no XMP pdfxid, so a PDF/X + // file stopped being one. + let src = doc_x4("FOGRA39", "icc-fogra39"); + let mut outs = vec![extract_pages(&src, &[1]).unwrap()]; + outs.extend(split(&src, &SplitBy::PageCount(1)).unwrap()); + for out in &outs { + let x = pdfx_of(&full_roundtrip(out)); + assert_eq!(x.intents, [("FOGRA39".to_string(), b"icc-fogra39".to_vec())]); + assert_eq!(x.version.as_deref(), Some("PDF/X-4")); + assert_eq!(x.trapped.as_deref(), Some(&b"False"[..])); + let xmp = x.xmp.expect("XMP metadata"); + assert!(xmp.contains("PDF/X-4"), "{xmp}"); + assert!(xmp.contains(">Card") && xmp.contains("False"), "{xmp}"); + // The structure tree is not copied, so the source's PDF/UA claim must not be either. + assert!(!xmp.contains("pdfuaid"), "{xmp}"); + } +} + +#[test] +fn combine_keeps_pdfx_when_every_source_agrees() { + // #263: a PDF/X-4 card combined with itself. + let (a, b) = (doc_x4("FOGRA39", "icc-fogra39"), doc_x4("FOGRA39", "icc-fogra39")); + let out = full_roundtrip(&combine(&[("a", &a), ("b", &b)]).unwrap()); + assert_eq!(labels(&out), ["X1", "X2", "X1", "X2"]); + let x = pdfx_of(&out); + assert_eq!(x.intents, [("FOGRA39".to_string(), b"icc-fogra39".to_vec())], "one output intent, not one per source"); + assert_eq!(x.version.as_deref(), Some("PDF/X-4")); + assert!(x.xmp.is_some_and(|x| x.contains("PDF/X-4"))); +} + +#[test] +fn combine_claims_no_pdfx_when_the_sources_disagree() { + // Another printing condition, the same name with another profile, or a source that is not + // PDF/X: no one standard describes the result, so it claims none. + let x4 = doc_x4("FOGRA39", "icc-fogra39"); + for other in [doc_x4("GRACoL2013", "icc-gracol"), doc_x4("FOGRA39", "icc-other"), doc_b()] { + let out = full_roundtrip(&combine(&[("a", &x4), ("b", &other)]).unwrap()); + assert_eq!(pdfx_of(&out), Pdfx::default()); + } +} + +#[test] +fn documents_without_a_print_standard_gain_none() { + assert_eq!(pdfx_of(&full_roundtrip(&extract_pages(&doc_a(), &[0]).unwrap())), Pdfx::default()); + assert_eq!(pdfx_of(&full_roundtrip(&combine(&[("a", &doc_a()), ("b", &doc_b())]).unwrap())), Pdfx::default()); +} + +#[test] +fn the_pdfx_version_comes_from_xmp_or_the_document_information() { + // A PDF/X-4 file may name its version only in XMP (here as an element); a PDF/X-1a:2001 file + // (PDF 1.3) only in its document information, with no XMP, and gets no XMP added. + let xmp = "\ + PDF/X-4 True"; + let x = pdfx_of(&full_roundtrip(&extract_pages(&doc_print("FOGRA51", "icc", "/Title (Flyer)", Some(xmp)), &[0]).unwrap())); + assert_eq!((x.version.as_deref(), x.trapped.as_deref()), (Some("PDF/X-4"), Some(&b"True"[..]))); + assert!(x.xmp.is_some_and(|x| x.contains("PDF/X-4"))); + + let x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001) /Trapped (False)", None); + let out = full_roundtrip(&extract_pages(&x1a, &[0]).unwrap()); + let x = pdfx_of(&out); + assert_eq!((x.version.as_deref(), x.trapped.as_deref()), (Some("PDF/X-1:2001"), Some(&b"False"[..]))); + assert_eq!(info(&out, "GTS_PDFXConformance").as_deref(), Some("PDF/X-1a:2001")); + assert_eq!(x.xmp, None); +} + +#[test] +fn malformed_output_intents_and_identification_do_not_fail() { + // Untrusted input: intents that are not dictionaries, a missing profile, an intent that is + // its own profile, an XMP packet that never closes, and a version full of markup and NULs. + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /OutputIntents [5 0 R 42 (junk) 6 0 R] /Metadata 7 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".into(), // 3 + body("M1"), // 4 + "<< /Type /OutputIntent /S /GTS_PDFX /DestOutputProfile 99 0 R >>".into(), // 5 + "<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (x) /DestOutputProfile 6 0 R >>".into(), // 6 + "<< /Length 30 >>\nstream\nX\nendstream".into(), // 7 + "<< /GTS_PDFXVersion (\\000) /Trapped /Maybe >>".into(), // 8 + ]; + let src = open(build(&b, "/Root 1 0 R /Info 8 0 R")); + let out = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + assert_eq!(labels(&out), ["M1"]); + let list = catalog(&out).get(b"OutputIntents").map(|o| out.resolve(o).as_array().cloned().unwrap_or_default()).unwrap_or_default(); + assert_eq!(list.len(), 4, "every entry is kept as written"); + let x = pdfx_of(&out); + assert_eq!(x.trapped, None, "/Maybe is not a trapping state"); + let xmp = x.xmp.expect("XMP metadata"); + assert!(xmp.contains("<PDF/X-4 & "more">"), "{xmp}"); + assert!(!xmp.contains('\0') && !xmp.contains("GTS_PDFXConformance"), "{xmp}"); +} + #[test] fn duplicate_pages_become_independent_copies() { let out = full_roundtrip(&extract_pages(&doc_b(), &[0, 0]).unwrap()); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 777ca4a92..f5ddc31ca 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2698,8 +2698,8 @@ milestone = "M4" status = "shipped" commands = ["page.extract"] tools = ["page_extract"] -evidence = ["crates/ui-egui/tests/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/automation.rs::combine_extract_and_split"] -notes = "" +evidence = ["crates/ui-egui/tests/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"] +notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied." [[feature]] id = "organize.extract-separate-files" @@ -2818,8 +2818,8 @@ milestone = "M4" status = "shipped" commands = ["page.split"] tools = ["doc_split"] -evidence = ["crates/ui-egui/tests/editing.rs::split_dialog_writes_one_file_per_part", "crates/organize/src/tests.rs::split_ranges_cover_every_page_once", "crates/organize/src/tests.rs::split_produces_standalone_documents_with_metadata"] -notes = "" +evidence = ["crates/ui-egui/tests/editing.rs::split_dialog_writes_one_file_per_part", "crates/organize/src/tests.rs::split_ranges_cover_every_page_once", "crates/organize/src/tests.rs::split_produces_standalone_documents_with_metadata", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"] +notes = "Each part carries the PDF/X output intent and identification (#263), as Extract does." [[feature]] id = "organize.split-before-pages" @@ -2890,8 +2890,8 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/editing.rs::a_folder_adds_its_pdfs_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::combine_opens_protected_files_with_their_passwords"] -notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step." +evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/editing.rs::a_folder_adds_its_pdfs_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"] +notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed." [[feature]] id = "organize.combine-bookmarks"