Issue #263: combine, extract and split keep the PDF/X output intent and identification (#334)

Combine, extract and split built the new document from copies of the
pages and carried bookmarks, fields, layers, attachments and four Info
keys, but not the catalog's /OutputIntents, the GTS_PDFXVersion,
GTS_PDFXConformance and Trapped Info entries, or the XMP pdfxid
identification. A PDF/X file stopped being one, and a RIP or preflight
lost the intended output condition.

Extract and split now carry the output intents (with their ICC
profiles, through the same object map as the pages), the Info entries
(the version read from Info, else XMP) and a new XMP packet holding the
identification, trapping and title. The source's packet is not copied:
it can claim standards the new file no longer meets, such as PDF/UA,
whose structure tree is not copied. Combine carries the first source's
standard only when every source declares the same version, trapping
and output intents (type, condition and profile bytes).

Not yet: a warning when combine's sources disagree, and XMP dates.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Fazmin Nizam
2026-10-08 16:04:03 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent a21637c757
commit a2d92daa00
5 changed files with 414 additions and 24 deletions
+43 -15
View File
@@ -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<ObjRef, ObjRef>) -> 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<Vec<ObjRef>, 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<ObjRef> = 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<ObjRef>, HashMap<ObjRef, ObjRef>), OrganizeError> {
/// What `import_pages_mapped` copied.
struct Imported {
/// The new pages, in order.
pages: Vec<ObjRef>,
/// Source page → destination page.
page_map: HashMap<ObjRef, ObjRef>,
/// Every source object copied → its copy.
objects: HashMap<ObjRef, ObjRef>,
}
/// `import_pages`, also returning what was copied from where.
fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result<Imported, OrganizeError> {
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<Document, OrganizeError> {
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<Vec<Document>, 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<Document, OrganizeError> {
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<ObjRef, ObjRef>)> = None;
let mut agreed = true;
for (title, src, chosen) in sources {
let n = crate::page_count(src)?;
let pages: Vec<usize> = 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<ObjRef> = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect();
+9 -3
View File
@@ -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<String> {
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<Object>) -> 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));
}
+195
View File
@@ -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<Object>,
/// Each output intent's type (`/S`), printing condition and decoded profile, to compare.
conditions: Vec<(Vec<u8>, Vec<u8>, Vec<u8>)>,
/// `GTS_PDFXVersion` ("PDF/X-4"), from the document information or else the XMP metadata.
version: Option<String>,
/// `GTS_PDFXConformance` ("PDF/X-1a:2001"), which PDF/X-1a and PDF/X-3 add.
conformance: Option<String>,
/// `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<ObjRef, ObjRef>) -> 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!(" <dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:title>\n", esc(&t)));
}
if let Some(t) = standard.trapped {
props.push_str(&format!(" <pdf:Trapped>{t}</pdf:Trapped>\n"));
}
props.push_str(&format!(" <pdfxid:GTS_PDFXVersion>{}</pdfxid:GTS_PDFXVersion>\n", esc(version)));
if let Some(c) = &standard.conformance {
props.push_str(&format!(" <pdfxid:GTS_PDFXConformance>{}</pdfxid:GTS_PDFXConformance>\n", esc(c)));
}
format!(
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n\
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
<rdf:Description rdf:about=\"\"\n\
xmlns:dc=\"http://purl.org/dc/elements/1.1/\"\n\
xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"\n\
xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\">\n\
{props} </rdf:Description>\n\
</rdf:RDF>\n\
</x:xmpmeta>\n\
<?xpacket end=\"w\"?>"
)
}
/// 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<String> {
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 (`<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>`).
fn xmp_value(xmp: &str, key: &str) -> Option<String> {
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!("</{key}>"))?)?;
Some(unescape(value.trim())).filter(|v| !v.is_empty())
}
fn unescape(s: &str) -> String {
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
/// `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::<String>()
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
+161
View File
@@ -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 = "<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><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:pdfxid=\"http://www.npes.org/pdfx/ns/id/\" xmlns:pdfuaid=\"http://www.aiim.org/pdfua/ns/id/\" \
pdfxid:GTS_PDFXVersion=\"PDF/X-4\" pdfuaid:part=\"1\"/></rdf:RDF></x:xmpmeta><?xpacket end=\"r\"?>";
/// 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<String> = 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<u8>)>,
version: Option<String>,
trapped: Option<Vec<u8>>,
xmp: Option<String>,
}
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("<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>"), "{xmp}");
assert!(xmp.contains(">Card</rdf:li>") && xmp.contains("<pdf:Trapped>False</pdf:Trapped>"), "{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("<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>")));
}
#[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 = "<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:pdfxid=\"http://www.npes.org/pdfx/ns/id/\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\">\
<pdfxid:GTS_PDFXVersion> PDF/X-4 </pdfxid:GTS_PDFXVersion><pdf:Trapped>True</pdf:Trapped></rdf:Description></rdf:RDF></x:xmpmeta>";
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("<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>")));
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<String> = 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\n<pdfxid:GTS_PDFXConformance>X\nendstream".into(), // 7
"<< /GTS_PDFXVersion (<PDF/X-4 & \"more\">\\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("<pdfxid:GTS_PDFXVersion>&lt;PDF/X-4 &amp; &quot;more&quot;&gt;</pdfxid:GTS_PDFXVersion>"), "{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());
+6 -6
View File
@@ -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"