diff --git a/ROADMAP.md b/ROADMAP.md index 961d69281..3c34c89c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -110,7 +110,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. - Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported. - File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. -- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs blend in that space: CMYK print exports write their transparency groups and page groups in DeviceCMYK (PDF/X-4 also carries the CMYK working space as its `GTS_PDFX` output intent); RGB exports give pages with transparency a DeviceRGB page group. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colors keep to its gamut, as when composited in CMYK). +- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs blend in that space: CMYK print exports write their transparency groups and page groups in DeviceCMYK (PDF/X-4 also carries the CMYK working space as its `GTS_PDFX` output intent; plain print, PDF/X-4, booklet and CMYK-containing RGB exports attach the working CMYK profile as `/DefaultCMYK`); RGB exports give pages with transparency a DeviceRGB page group. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colors keep to its gamut, as when composited in CMYK). - High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection). - Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans). - Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images. @@ -394,6 +394,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-13** · PDF exports outside PDF/X-1a and PDF/A-2b attach the CMYK working space's ICC profile as `/DefaultCMYK` (`[/ICCBased …]`) on every page, inherited `/Pages` resource dict, Form XObject, SMask form, tiling pattern, and Type3 font resource scope (#2743). Plain print exports and booklets embed one shared Flate-compressed `/N 4` profile stream without adding `/OutputIntents` or `/Trapped`; PDF/X-4 reuses its catalog `/DestOutputProfile`; RGB-blend documents attach `/DefaultCMYK` when they draw in `DeviceCMYK` and skip it for pure RGB. Viewers that honor `/DefaultCMYK` instead of `/OutputIntents` (including Apple Quartz and MuPDF) now render CMYK colors consistently with the working space across both plain and PDF/X-4 exports. No readiness percentage change. - **2026-11-12** · Properties › Text Frame shows the frame's inset spacing (Top, Bottom, Left and Right with a link that makes all four the same; one Inset for a non-rectangular frame) and four Vertical Justification buttons (Top, Center, Bottom, Justify), and the section also shows with the Type tool in a frame, where it changes the frame that shows the insertion point. No readiness percentage change. - **2026-11-12** · An edit of a long threaded story composes the paragraphs it can change and copies the other lines from the composition before the edit; every typed character composed the whole story. In a 240-paragraph story a typed character composes 3 paragraphs (240 before). An edit that moves lines into other frames composes on until a paragraph starts in the same frame and place as before, or to the story's end, and stories with footnotes, tables, anchored objects, balanced columns or keep options that move lines are composed whole. No readiness percentage change. - **2026-11-12** · Composing a story with endnotes, anchored objects or cross-references counts their marks in one pass over the story, instead of counting again from the story start for every paragraph. No readiness percentage change. diff --git a/crates/engine/src/cmd/export/pdf.rs b/crates/engine/src/cmd/export/pdf.rs index a11c4e22a..09a9b9a1e 100644 --- a/crates/engine/src/cmd/export/pdf.rs +++ b/crates/engine/src/cmd/export/pdf.rs @@ -712,4 +712,37 @@ mod overset_tests { let r = s.execute("file.exportPdf", &json!({"pages": [1], "spreads": true})).unwrap(); assert!(about(&r).is_empty(), "the cover's spread holds no overset frame: {r}"); } + + /// Plain print exports attach `/DefaultCMYK` without `/OutputIntents`, PDF/X-4 keeps both + /// cleanly, and PDF/X-1a omits `/DefaultCMYK`. + #[test] + fn export_pdf_command_attaches_default_cmyk_according_to_standard() { + let mut s = Session::new(); + s.execute("file.new", &json!({"intent": "print"})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap(); + s.execute("swatch.create", &json!({"name": "Slate", "color": {"c": 42.5, "m": 17.5, "y": 16.0, "k": 7.1}})).unwrap(); + s.execute("object.fill", &json!({"swatch": "Slate"})).unwrap(); + + let export = |s: &mut Session, extra: serde_json::Value| { + let res = s.execute("file.exportPdf", &extra).unwrap(); + let raw = crate::cmd::base64_decode(res["base64"].as_str().unwrap()); + (raw, res["warnings"].clone()) + }; + + let (plain, plain_w) = export(&mut s, json!({"tagged": true})); + assert_eq!(plain_w, json!([]), "plain export warnings: {plain_w}"); + let plain_text = String::from_utf8_lossy(&plain); + assert!(plain_text.contains("/DefaultCMYK ["), "plain export must carry /DefaultCMYK"); + assert!(!plain_text.contains("/OutputIntents"), "plain export must not carry /OutputIntents"); + + let (x4, x4_w) = export(&mut s, json!({"standard": "x4"})); + assert_eq!(x4_w, json!([]), "PDF/X-4 warnings: {x4_w}"); + let x4_text = String::from_utf8_lossy(&x4); + assert!(x4_text.contains("/DefaultCMYK ["), "PDF/X-4 export must carry /DefaultCMYK"); + assert!(x4_text.contains("/OutputIntents"), "PDF/X-4 export must keep /OutputIntents"); + + let (x1a, _) = export(&mut s, json!({"standard": "x1a"})); + let x1a_text = String::from_utf8_lossy(&x1a); + assert!(!x1a_text.contains("/DefaultCMYK"), "PDF/X-1a must not carry ICCBased DefaultCMYK"); + } } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 3df877c5c..912823d5e 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -25,11 +25,11 @@ log = { workspace = true } chrono = { workspace = true } # The placed-PDF version check names hayro's PdfVersion (krilla::pdf::Pdf::version returns it). hayro-syntax = "0.7.2" +flate2 = "1" [dev-dependencies] hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } hayro-cmap = "0.1" kurbo = { workspace = true } tiff = { workspace = true } -flate2 = "1" designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/pdf/src/default_cmyk.rs b/crates/pdf/src/default_cmyk.rs new file mode 100644 index 000000000..5b30aa39c --- /dev/null +++ b/crates/pdf/src/default_cmyk.rs @@ -0,0 +1,749 @@ +//! Default CMYK colour profile attachment for PDF export. +//! +//! Writes `/DefaultCMYK [/ICCBased R]` into every resource scope that does not already +//! define one (Pages, inherited `/Pages` ancestors, Form XObjects, SMask forms, TilingPatterns, +//! and Type3 fonts). PDF/X-4 reuses the catalog output intent's `/DestOutputProfile` stream; plain +//! print exports embed one shared Flate-compressed ICC profile stream. + +use std::collections::{BTreeMap, HashSet}; +use std::io::Write; + +use flate2::Compression; +use flate2::write::ZlibEncoder; +use hayro_syntax::Pdf; +use hayro_syntax::content::UntypedIter; +use hayro_syntax::object::{Dict, Name, Object, ObjectIdentifier, Stream}; +use hayro_syntax::page::{Page, Resources}; + +use crate::optional_content::{append_with, edited_with, has_entry, trailer_size}; +use crate::pdfx::pdfx_intents; +use crate::{PdfError, Result}; + +/// Maximum nesting depth when walking `/Parent`, Form XObjects, patterns, and soft masks. +const MAX_DEPTH: usize = 32; + +/// Maximum object-number probes beyond trailer `/Size` before treating the xref as damaged. +const MAX_ID_PROBES: usize = 65_536; + +fn damaged() -> PdfError { + PdfError::Write("the exported PDF could not be updated with a DefaultCMYK profile cleanly".into()) +} + +fn encrypted() -> PdfError { + PdfError::Write("an encrypted PDF cannot be updated with a DefaultCMYK profile".into()) +} + +/// Ensures every page and nested resource scope in `bytes` carries `/DefaultCMYK` mapped to an +/// `/ICCBased` 4-channel ICC profile stream, unless that scope already defines `/DefaultCMYK`. +/// +/// When `reuse_output_intent` is true and the PDF has a 4-channel `GTS_PDFX` `/DestOutputProfile`, +/// `/DefaultCMYK` references that stream directly without embedding a duplicate profile. +pub(crate) fn apply(bytes: &[u8], icc: &[u8], reuse_output_intent: bool) -> Result> { + let old_size = trailer_size(bytes, damaged, encrypted)?; + let pdf = Pdf::new(bytes.to_vec()).map_err(|_| damaged())?; + if pdf.pages().is_empty() { + return Ok(bytes.to_vec()); + } + + let existing_icc = if reuse_output_intent { output_intent_profile(&pdf) } else { None }; + let (icc_id, new_icc_body) = match existing_icc { + Some(id) => (id, None), + None => { + if icc.is_empty() { + return Err(damaged()); + } + (next_object_id(&pdf, old_size)?, Some(encode_icc_stream(icc)?)) + } + }; + + let mut updater = Updater::new(&pdf, icc_id); + for page in pdf.pages().iter() { + updater.visit_page(page)?; + } + + if updater.changes.is_empty() { + return Ok(bytes.to_vec()); + } + if let Some(body) = new_icc_body { + updater.changes.insert(icc_id, body); + } + let ordered: Vec<(ObjectIdentifier, Vec)> = updater.changes.into_iter().collect(); + append_with(bytes, &ordered, None, damaged, encrypted) +} + +/// Attaches `/DefaultCMYK` to an exported PDF when appropriate for `standard`: plain (`None`) and +/// `PdfX4` exports attach the profile whenever `cmyk_icc` was prepared for CMYK print output or +/// the PDF actually draws in `DeviceCMYK`. +pub(crate) fn attach(bytes: Vec, cmyk_icc: Option<&[u8]>, standard: crate::Standard, warnings: &mut Vec) -> Result> { + if !matches!(standard, crate::Standard::None | crate::Standard::PdfX4) { + return Ok(bytes); + } + let fallback; + let icc = match cmyk_icc { + Some(icc) => icc, + None if uses_device_cmyk(&bytes) => { + let (_, profile, warning) = crate::pdfx::print_settings_and_profile(false)?; + warnings.extend(warning); + fallback = profile; + &fallback + } + None => return Ok(bytes), + }; + apply(&bytes, icc, standard == crate::Standard::PdfX4) +} + +/// Returns whether any page or nested graphic in `bytes` uses `DeviceCMYK` (directly, as a +/// transparency group, image, shading, tiling pattern, or Type3 font glyph). +pub(crate) fn uses_device_cmyk(bytes: &[u8]) -> bool { + let Ok(pdf) = Pdf::new(bytes.to_vec()) else { + return false; + }; + let mut scan = CmykScan::default(); + for page in pdf.pages().iter() { + if scan.page(page) { + return true; + } + } + false +} + +/// Returns the first 4-channel `/DestOutputProfile` stream ID from `/OutputIntents`. +fn output_intent_profile(pdf: &Pdf) -> Option { + pdfx_intents(pdf).into_iter().find_map(|oi| { + let profile = oi.get::>(b"DestOutputProfile")?; + (profile.dict().get::(b"N") == Some(4)).then(|| profile.obj_id()) + }) +} + +/// Returns the first object number `>= start` not already present in the cross-reference table. +fn next_object_id(pdf: &Pdf, start: i32) -> Result { + let mut id = start.max(1); + for _ in 0..MAX_ID_PROBES { + let candidate = ObjectIdentifier::new(id, 0); + if pdf.xref().get::>(candidate).is_none() { + return Ok(candidate); + } + id = id.checked_add(1).ok_or_else(damaged)?; + } + Err(damaged()) +} + +fn encode_icc_stream(icc: &[u8]) -> Result> { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(icc).map_err(|_| damaged())?; + let compressed = encoder.finish().map_err(|_| damaged())?; + let mut out = format!("<>\nstream\n", compressed.len()).into_bytes(); + out.extend_from_slice(&compressed); + out.extend_from_slice(b"\nendstream"); + Ok(out) +} + +/// Target object to update when a scope's `/Resources` dict is inline (or omitted on a page). +#[derive(Clone)] +enum ScopeOwner<'a> { + Dict(ObjectIdentifier, Dict<'a>), + Stream(Stream<'a>), +} + +struct Updater<'a> { + pdf: &'a Pdf, + default_cmyk: String, + changes: BTreeMap>, + seen_res: HashSet, + seen_cs: HashSet, + seen_owners: HashSet, + seen_streams: HashSet, + seen_fonts: HashSet, +} + +impl<'a> Updater<'a> { + fn new(pdf: &'a Pdf, icc_id: ObjectIdentifier) -> Self { + Self { + pdf, + default_cmyk: format!("[/ICCBased {} {} R]", icc_id.obj_number, icc_id.gen_number), + changes: BTreeMap::new(), + seen_res: HashSet::new(), + seen_cs: HashSet::new(), + seen_owners: HashSet::new(), + seen_streams: HashSet::new(), + seen_fonts: HashSet::new(), + } + } + + fn visit_page(&mut self, page: &'a Page<'a>) -> Result<()> { + let raw = page.raw(); + if has_entry(raw, b"Resources") { + let page_id = raw.obj_id().ok_or_else(damaged)?; + let owner = ScopeOwner::Dict(page_id, raw.clone()); + return self.visit_resource_owner(raw, Some(owner), 0); + } + + // Pages inherit `/Resources` from their nearest `/Parent` ancestor that defines one. + let mut parent_ref = raw.get_ref(b"Parent"); + let mut seen_parents = HashSet::new(); + for _ in 0..MAX_DEPTH { + let Some(r) = parent_ref else { break }; + let parent_id: ObjectIdentifier = r.into(); + if !seen_parents.insert(parent_id) { + return Err(damaged()); + } + let parent = self.pdf.xref().get::>(parent_id).ok_or_else(damaged)?; + if has_entry(&parent, b"Resources") { + let owner = ScopeOwner::Dict(parent_id, parent.clone()); + return self.visit_resource_owner(&parent, Some(owner), 0); + } + parent_ref = parent.get_ref(b"Parent"); + } + if parent_ref.is_some() { + return Err(damaged()); + } + + // Neither the page nor any ancestor had `/Resources`: attach one directly to the page. + let page_id = raw.obj_id().ok_or_else(damaged)?; + if !self.seen_owners.insert(page_id) { + return Ok(()); + } + let res_bytes = format!("<>>>", self.default_cmyk); + let updated = edited_with(raw, &[(b"Resources", res_bytes.as_bytes())], damaged)?; + self.changes.insert(page_id, updated); + Ok(()) + } + + fn visit_resource_owner(&mut self, owner_dict: &Dict<'a>, owner: Option>, depth: usize) -> Result<()> { + if depth >= MAX_DEPTH || !has_entry(owner_dict, b"Resources") { + return Ok(()); + } + let res_ref = owner_dict.get_ref(b"Resources").map(ObjectIdentifier::from); + if let Some(id) = res_ref + && !self.seen_res.insert(id) + { + return Ok(()); + } + let res_dict = owner_dict.get::>(b"Resources").ok_or_else(damaged)?; + self.ensure_default_cmyk(&res_dict, res_ref, owner)?; + self.walk_children(&res_dict, depth) + } + + fn ensure_default_cmyk(&mut self, res_dict: &Dict<'a>, res_ref: Option, owner: Option>) -> Result<()> { + let cs_opt = if has_entry(res_dict, b"ColorSpace") { Some(res_dict.get::>(b"ColorSpace").ok_or_else(damaged)?) } else { None }; + if cs_opt.as_ref().is_some_and(|cs| has_entry(cs, b"DefaultCMYK")) { + return Ok(()); + } + + if let Some(cs_ref) = res_dict.get_ref(b"ColorSpace").map(ObjectIdentifier::from) { + if !self.seen_cs.insert(cs_ref) { + return Ok(()); + } + let cs_dict = cs_opt.as_ref().ok_or_else(damaged)?; + let updated_cs = edited_with(cs_dict, &[(b"DefaultCMYK", self.default_cmyk.as_bytes())], damaged)?; + self.changes.insert(cs_ref, updated_cs); + return Ok(()); + } + + let new_cs = match cs_opt.as_ref() { + Some(cs_dict) => edited_with(cs_dict, &[(b"DefaultCMYK", self.default_cmyk.as_bytes())], damaged)?, + None => format!("<>", self.default_cmyk).into_bytes(), + }; + + if let Some(res_id) = res_ref { + let updated_res = edited_with(res_dict, &[(b"ColorSpace", new_cs.as_slice())], damaged)?; + self.changes.insert(res_id, updated_res); + return Ok(()); + } + + if let Some(owner) = owner { + let new_res = edited_with(res_dict, &[(b"ColorSpace", new_cs.as_slice())], damaged)?; + self.write_inline_resources(owner, &new_res)?; + } + Ok(()) + } + + fn write_inline_resources(&mut self, owner: ScopeOwner<'a>, new_res: &[u8]) -> Result<()> { + match owner { + ScopeOwner::Dict(id, dict) => { + if !self.seen_owners.insert(id) { + return Ok(()); + } + let updated = edited_with(&dict, &[(b"Resources", new_res)], damaged)?; + self.changes.insert(id, updated); + } + ScopeOwner::Stream(stream) => { + let id = stream.obj_id(); + if !self.seen_owners.insert(id) { + return Ok(()); + } + let raw = stream.raw_data(); + let len_str = raw.len().to_string(); + let dict = edited_with(stream.dict(), &[(b"Resources", new_res), (b"Length", len_str.as_bytes())], damaged)?; + let mut body = dict; + body.extend_from_slice(b"\nstream\n"); + body.extend_from_slice(&raw); + body.extend_from_slice(b"\nendstream"); + self.changes.insert(id, body); + } + } + Ok(()) + } + + fn walk_children(&mut self, res: &Dict<'a>, depth: usize) -> Result<()> { + let next = depth + 1; + if next >= MAX_DEPTH { + return Ok(()); + } + + if let Some(xobjects) = res.get::>(b"XObject") { + for key in xobjects.keys() { + if let Some(stream) = xobjects.get::>(key.as_ref()) + && stream.dict().get::>(b"Subtype").as_deref() == Some(b"Form") + { + self.visit_stream(&stream, next)?; + } + } + } + + if let Some(extgstate) = res.get::>(b"ExtGState") { + for key in extgstate.keys() { + if let Some(gs) = extgstate.get::>(key.as_ref()) + && let Some(smask) = gs.get::>(b"SMask") + && let Some(form) = smask.get::>(b"G") + { + self.visit_stream(&form, next)?; + } + } + } + + if let Some(patterns) = res.get::>(b"Pattern") { + for key in patterns.keys() { + if let Some(stream) = patterns.get::>(key.as_ref()) { + self.visit_stream(&stream, next)?; + } + } + } + + if let Some(fonts) = res.get::>(b"Font") { + for key in fonts.keys() { + if let Some(font) = fonts.get::>(key.as_ref()) + && font.get::>(b"Subtype").as_deref() == Some(b"Type3") + { + let font_id = fonts.get_ref(key.as_ref()).map(ObjectIdentifier::from).or_else(|| font.obj_id()); + if let Some(id) = font_id + && !self.seen_fonts.insert(id) + { + continue; + } + let owner = font_id.map(|id| ScopeOwner::Dict(id, font.clone())); + self.visit_resource_owner(&font, owner, next)?; + } + } + } + Ok(()) + } + + fn visit_stream(&mut self, stream: &Stream<'a>, depth: usize) -> Result<()> { + if depth >= MAX_DEPTH || !self.seen_streams.insert(stream.obj_id()) { + return Ok(()); + } + let owner = ScopeOwner::Stream(stream.clone()); + self.visit_resource_owner(stream.dict(), Some(owner), depth) + } +} + +fn is_device_cmyk(cs: &Object<'_>) -> bool { + match cs { + Object::Name(n) => matches!(n.as_ref(), b"DeviceCMYK" | b"CMYK"), + Object::Array(a) => { + let mut items = a.iter::>(); + match items.next() { + Some(Object::Name(n)) if matches!(n.as_ref(), b"Indexed" | b"I") => { + matches!(items.next(), Some(Object::Name(b)) if matches!(b.as_ref(), b"DeviceCMYK" | b"CMYK")) + } + _ => false, + } + } + _ => false, + } +} + +#[derive(Default)] +struct CmykScan { + seen_streams: HashSet, + seen_fonts: HashSet, +} + +impl CmykScan { + fn page(&mut self, page: &Page<'_>) -> bool { + let res = page.resources(); + if page.raw().get::>(b"Group").is_some_and(|g| Self::group(&g)) { + return true; + } + page.page_stream().is_some_and(|data| self.content(data, res, 0)) + } + + fn group(group: &Dict<'_>) -> bool { + group.get::>(b"CS").is_some_and(|cs| is_device_cmyk(&cs)) + } + + fn content(&mut self, data: &[u8], res: &Resources<'_>, depth: usize) -> bool { + if depth >= MAX_DEPTH { + return false; + } + let mut ops = UntypedIter::new(data); + while let Some(op) = ops.next() { + let operator: &[u8] = op.operator; + let last = op.operands().last().cloned(); + let name = || last.clone().and_then(Object::into_name); + match operator { + b"k" | b"K" => return true, + b"cs" | b"CS" => { + if name().is_some_and(|n| n.as_ref() == b"DeviceCMYK" || res.get_color_space(&n).is_some_and(|cs| is_device_cmyk(&cs))) { + return true; + } + } + b"sh" => { + if name().and_then(|n| res.get_shading(&n)).is_some_and(|sh| Self::shading(sh)) { + return true; + } + } + b"scn" | b"SCN" => { + if let Some(p) = name().and_then(|n| res.get_pattern(&n)) + && self.pattern(p, res, depth) + { + return true; + } + } + b"gs" => { + let form = name() + .and_then(|n| res.get_ext_g_state(&n)) + .and_then(|gs| gs.get::>(b"SMask")) + .and_then(|m| m.get::>(b"G")); + if let Some(form) = form + && self.form(&form, res, depth) + { + return true; + } + } + b"Do" => { + if let Some(xo) = name().and_then(|n| res.get_x_object(&n)) { + let hit = match xo.dict().get::>(b"Subtype").as_deref() { + Some(b"Image") => Self::image(&xo), + Some(b"Form") => self.form(&xo, res, depth), + _ => false, + }; + if hit { + return true; + } + } + } + b"BI" | b"EI" => { + if let Some(Object::Stream(img)) = last + && Self::inline_image(&img, res) + { + return true; + } + } + b"Tf" => { + if let Some(font_name) = op.operands().next().cloned().and_then(Object::into_name) + && let Some(font) = res.get_font(&font_name) + && font.get::>(b"Subtype").as_deref() == Some(b"Type3") + && self.type3(&font, res, depth) + { + return true; + } + } + _ => {} + } + } + false + } + + fn image(img: &Stream<'_>) -> bool { + if img.dict().get::(b"ImageMask") == Some(true) { + return false; + } + img.dict().get::>(b"ColorSpace").is_some_and(|cs| is_device_cmyk(&cs)) + } + + fn inline_image(img: &Stream<'_>, res: &Resources<'_>) -> bool { + if img.dict().get::(b"IM") == Some(true) || img.dict().get::(b"ImageMask") == Some(true) { + return false; + } + let cs = img.dict().get::>(b"CS").or_else(|| img.dict().get::>(b"ColorSpace")); + let cs = match cs { + Some(Object::Name(n)) if !matches!(n.as_ref(), b"DeviceRGB" | b"RGB" | b"G" | b"DeviceGray" | b"CMYK" | b"DeviceCMYK") => { + res.get_color_space(&n) + } + cs => cs, + }; + cs.is_some_and(|cs| is_device_cmyk(&cs)) + } + + fn shading(sh: Object<'_>) -> bool { + let dict = match sh { + Object::Dict(d) => d, + Object::Stream(s) => s.dict().clone(), + _ => return false, + }; + dict.get::>(b"ColorSpace").is_some_and(|cs| is_device_cmyk(&cs)) + } + + fn pattern(&mut self, pat: Object<'_>, res: &Resources<'_>, depth: usize) -> bool { + match pat { + Object::Dict(d) => d.get::>(b"Shading").is_some_and(Self::shading), + Object::Stream(s) => self.form(&s, res, depth), + _ => false, + } + } + + fn form(&mut self, form: &Stream<'_>, parent: &Resources<'_>, depth: usize) -> bool { + if depth >= MAX_DEPTH || !self.seen_streams.insert(form.obj_id()) { + return false; + } + let res = Resources::from_parent(form.dict().get::>(b"Resources").unwrap_or_default(), parent.clone()); + if form.dict().get::>(b"Group").is_some_and(|g| Self::group(&g)) { + return true; + } + form.decoded().is_ok_and(|data| self.content(&data, &res, depth + 1)) + } + + fn type3(&mut self, font: &Dict<'_>, parent: &Resources<'_>, depth: usize) -> bool { + if depth >= MAX_DEPTH { + return false; + } + if let Some(id) = font.obj_id() + && !self.seen_fonts.insert(id) + { + return false; + } + let res = match font.get::>(b"Resources") { + Some(own) => Resources::from_parent(own, parent.clone()), + None => parent.clone(), + }; + let Some(procs) = font.get::>(b"CharProcs") else { + return false; + }; + for key in procs.keys() { + if let Some(glyph) = procs.get::>(key.as_ref()) + && self.seen_streams.insert(glyph.obj_id()) + && glyph.decoded().is_ok_and(|data| self.content(&data, &res, depth + 1)) + { + return true; + } + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use craft_cms::builtin::COATED_FOGRA39_ICC; + + fn build_pdf(objects: &[Vec], trailer_extra: &str) -> Vec { + let mut out = b"%PDF-1.4\n".to_vec(); + let mut offsets = vec![0usize]; + for (idx, body) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", idx + 1).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + let size = objects.len() + 1; + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f\r\n").as_bytes()); + for offset in offsets.iter().skip(1) { + out.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out + } + + fn read_default_cmyk<'a>(res: &'a Resources<'a>) -> Option<(i32, Vec)> { + let cs = res.get_color_space(&Name::new_unescaped(b"DefaultCMYK"))?.into_array()?; + let mut items = cs.iter::>(); + let tag = items.next()?.into_name()?; + assert_eq!(tag.as_ref(), b"ICCBased"); + let stream = items.next()?.into_stream()?; + assert_eq!(stream.dict().get::(b"N"), Some(4)); + Some((stream.obj_id().obj_number, stream.decoded().ok()?.into_owned())) + } + + #[test] + fn inline_resources_inherited_pages_preserved_existing_and_cyclic_forms() { + let generic = designcraft_color::cms::Cms::new(&designcraft_color::cms::ColorSettings { + cmyk: designcraft_color::cms::GENERIC_CMYK.into(), + ..Default::default() + }) + .unwrap(); + let custom_icc = generic.output_intent().unwrap().icc.into_owned(); + assert_ne!(custom_icc, COATED_FOGRA39_ICC); + // 1: Catalog + // 2: Pages (carries inherited /Resources with existing /ColorSpace << /CS0 ... >>) + // 3: Page 1 (inherits /Resources from Pages 2) + // 4: Page 2 (inline /Resources with inline Form XObject 6, preserves content) + // 5: Page 3 (already defines /DefaultCMYK pointing to 7) + // 6: Cyclic Form XObject (inline /Resources with XObject /Self -> 6) + // 7: Pre-existing custom ICC stream (tagged /N 4) + let form = + b"<>>>/Length 8>>\nstream\n0 0 0 1 k\nendstream".to_vec(); + let existing_icc = b"<>\nstream\ntest\nendstream".to_vec(); + let pdf_bytes = build_pdf( + &[ + b"<>".to_vec(), + b"<>>>>>".to_vec(), + b"<>".to_vec(), + b"<>>>>>".to_vec(), + b"<>>>>>".to_vec(), + form, + existing_icc, + ], + "", + ); + + let updated = apply(&pdf_bytes, &custom_icc, false).unwrap(); + let parsed = Pdf::new(updated.clone()).unwrap(); + + // Inherited `/Pages` dict got DefaultCMYK while keeping CS0. + let (p1_id, p1_prof) = read_default_cmyk(parsed.pages()[0].resources()).expect("page 1 inherits DefaultCMYK"); + assert_eq!(p1_prof, custom_icc); + assert!(parsed.pages()[0].resources().get_color_space(&Name::new_unescaped(b"CS0")).is_some()); + + // Page 2 inline Resources + Form 6 inline Resources share the exact same embedded ICC stream id. + let (p2_id, _) = read_default_cmyk(parsed.pages()[1].resources()).expect("page 2 DefaultCMYK"); + assert_eq!(p1_id, p2_id); + let form_obj = parsed.pages()[1].resources().get_x_object(&Name::new_unescaped(b"Fm0")).unwrap(); + let form_res = Resources::from_parent(form_obj.dict().get::>(b"Resources").unwrap(), parsed.pages()[1].resources().clone()); + let (f_id, f_prof) = read_default_cmyk(&form_res).expect("cyclic form DefaultCMYK"); + assert_eq!(f_id, p1_id); + assert_eq!(f_prof, custom_icc); + + // Page 3's pre-existing /DefaultCMYK was preserved (kept pointing to 7). + let (p3_id, p3_prof) = read_default_cmyk(parsed.pages()[2].resources()).expect("page 3 kept existing DefaultCMYK"); + assert_eq!(p3_id, 7); + assert_eq!(p3_prof, b"test"); + + // Idempotent when applied again after all scopes already have /DefaultCMYK. + let second = apply(&updated, &custom_icc, false).unwrap(); + assert_eq!(second, updated); + } + + #[test] + fn excessive_form_nesting_is_bounded_and_never_overflows() { + let mut objects = vec![ + b"<>".to_vec(), + b"<>".to_vec(), + b"<>>>>>".to_vec(), + ]; + for i in 0..40 { + let next_obj = objects.len() + 2; + let body = if i < 39 { + format!("<>>>/Length 0>>\nstream\n\nendstream") + } else { + "<>/Length 0>>\nstream\n\nendstream".to_string() + }; + objects.push(body.into_bytes()); + } + let pdf_bytes = build_pdf(&objects, ""); + let updated = apply(&pdf_bytes, COATED_FOGRA39_ICC, false).unwrap(); + let parsed = Pdf::new(updated).unwrap(); + assert!(read_default_cmyk(parsed.pages()[0].resources()).is_some()); + } + + #[test] + fn encrypted_and_damaged_pdfs_fail_gracefully() { + let enc = build_pdf( + &[ + b"<>".to_vec(), + b"<>".to_vec(), + b"<>".to_vec(), + ], + "/Encrypt 99 0 R", + ); + assert!(apply(&enc, COATED_FOGRA39_ICC, false).is_err()); + assert!(apply(b"not a pdf", COATED_FOGRA39_ICC, false).is_err()); + assert!( + apply( + &build_pdf( + &[ + b"<>".to_vec(), + b"<>".to_vec(), + b"<>".to_vec() + ], + "" + ), + &[], + false + ) + .is_err() + ); + assert!(!uses_device_cmyk(b"not a pdf")); + } + + #[test] + fn uses_device_cmyk_distinguishes_pure_rgb_from_cmyk() { + let make = |content: &[u8]| { + build_pdf( + &[ + b"<>".to_vec(), + b"<>".to_vec(), + b"<>>>".to_vec(), + format!("<>\nstream\n{}\nendstream", content.len(), String::from_utf8_lossy(content)).into_bytes(), + ], + "", + ) + }; + assert!(!uses_device_cmyk(&make(b"1 0 0 rg 0 0 10 10 re f"))); + assert!(uses_device_cmyk(&make(b"0 0 0 1 k 0 0 10 10 re f"))); + assert!(uses_device_cmyk(&make(b"/DeviceCMYK cs 1 0 0 0 scn 0 0 10 10 re f"))); + } + + #[test] + fn smask_tiling_pattern_type3_and_indirect_colorspace_receive_default_cmyk() { + // 1: Catalog, 2: Pages, 3: Page (Resources -> 4) + // 4: Page Resources (indirect ColorSpace -> 5, ExtGState /GS with SMask /G -> 6, Pattern /P1 -> 7, Font /F3 -> 8) + // 5: Indirect ColorSpace (preserves /Sep while adding /DefaultCMYK) + // 6: SMask Form (indirect Resources -> 9) + // 7: TilingPattern (inline Resources with /ColorSpace <<>>) + // 8: Type3 Font (inline Resources with /ColorSpace <<>>, CharProcs /a -> 11) + // 9: SMask Resources + // 10: Page content stream drawing Type3 glyph `(a)` + // 11: Type3 CharProc stream drawing in DeviceCMYK (`0 0 0 1 k`) + let pdf_bytes = build_pdf( + &[ + b"<>".to_vec(), + b"<>".to_vec(), + b"<>".to_vec(), + b"<>>>>>/Pattern<>/Font<>>>".to_vec(), + b"<>".to_vec(), + b"<>\nstream\n\nendstream".to_vec(), + b"<>>>/Length 0>>\nstream\n\nendstream".to_vec(), + b"<>/Encoding<>/FirstChar 97/LastChar 97/Widths[10]/Resources<>>>>>".to_vec(), + b"<>>>".to_vec(), + b"<>\nstream\n/F3 10 Tf (a) Tj\nendstream".to_vec(), + b"<>\nstream\n0 0 0 1 k 0 0 5 5 re f\nendstream".to_vec(), + ], + "", + ); + assert!(uses_device_cmyk(&pdf_bytes), "Type3 glyph CMYK usage should be detected"); + let updated = apply(&pdf_bytes, COATED_FOGRA39_ICC, false).unwrap(); + let parsed = Pdf::new(updated).unwrap(); + let page_res = parsed.pages()[0].resources(); + let (p_id, _) = read_default_cmyk(page_res).expect("page indirect ColorSpace DefaultCMYK"); + assert!(page_res.get_color_space(&Name::new_unescaped(b"Sep")).is_some(), "existing indirect ColorSpace entry kept"); + + let smask = + page_res.get_ext_g_state(&Name::new_unescaped(b"GS")).unwrap().get::>(b"SMask").unwrap().get::>(b"G").unwrap(); + let smask_res = Resources::from_parent(smask.dict().get::>(b"Resources").unwrap(), page_res.clone()); + assert_eq!(read_default_cmyk(&smask_res).expect("SMask form DefaultCMYK").0, p_id); + + let pat = match page_res.get_pattern(&Name::new_unescaped(b"P1")).unwrap() { + Object::Stream(s) => s, + other => panic!("expected pattern stream, got {other:?}"), + }; + let pat_res = Resources::from_parent(pat.dict().get::>(b"Resources").unwrap(), page_res.clone()); + assert_eq!(read_default_cmyk(&pat_res).expect("TilingPattern DefaultCMYK").0, p_id); + + let font = page_res.get_font(&Name::new_unescaped(b"F3")).unwrap(); + let font_res = Resources::from_parent(font.get::>(b"Resources").unwrap(), page_res.clone()); + assert_eq!(read_default_cmyk(&font_res).expect("Type3 font DefaultCMYK").0, p_id); + } +} diff --git a/crates/pdf/src/export/booklet.rs b/crates/pdf/src/export/booklet.rs index bc2138623..915163404 100644 --- a/crates/pdf/src/export/booklet.rs +++ b/crates/pdf/src/export/booklet.rs @@ -82,12 +82,12 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> } let pdf_opts = PdfOptions { title: opts.title.clone(), ..PdfOptions::default() }; let mut warnings = Vec::new(); - let base = if print_color(doc, &pdf_opts) { - let (settings, warning) = crate::pdfx::print_settings(false)?; + let (base, cmyk_icc) = if print_color(doc, &pdf_opts) { + let (settings, icc, warning) = crate::pdfx::print_settings_and_profile(false)?; warnings.extend(warning); - settings + (settings, Some(icc)) } else { - krilla::SerializeSettings::default() + (krilla::SerializeSettings::default(), None) }; let settings = krilla::SerializeSettings { compress_content_streams: true, ..base }; let metadata = file_info_metadata(doc, opts.title.as_deref(), None); @@ -169,6 +169,7 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> bytes = crate::optional_content::register(&bytes)?; } let mut warnings = ex.warnings; + bytes = crate::default_cmyk::attach(bytes, cmyk_icc.as_deref(), Standard::None, &mut warnings)?; warnings.dedup(); Ok(ExportReport { bytes, pages: pairs.len(), warnings }) } diff --git a/crates/pdf/src/export/pipeline.rs b/crates/pdf/src/export/pipeline.rs index 85d39c2e9..2b96c7332 100644 --- a/crates/pdf/src/export/pipeline.rs +++ b/crates/pdf/src/export/pipeline.rs @@ -186,14 +186,14 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions }; let configuration = write::configuration(version, archival)?; let mut warnings = Vec::new(); - let base = if print_color(doc, opts) { + let (base, cmyk_icc) = if print_color(doc, opts) { // An output intent only for PDF/X, as in InDesign. - let (settings, warning) = crate::pdfx::print_settings(opts.standard.is_pdfx())?; + let (settings, icc, warning) = crate::pdfx::print_settings_and_profile(opts.standard.is_pdfx())?; warnings.extend(warning); let pdfx_version = (opts.standard == Standard::PdfX4).then(|| "PDF/X-4".to_string()); - krilla::SerializeSettings { pdfx_version, ..settings } + (krilla::SerializeSettings { pdfx_version, ..settings }, Some(icc)) } else { - krilla::SerializeSettings::default() + (krilla::SerializeSettings::default(), None) }; let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..base }; let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); @@ -289,11 +289,14 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions } add_form_fields(&mut bytes, &mut warnings, doc, &sheets, opts); if opts.standard == Standard::PdfX4 { - // The XMP dates krilla doesn't write, then check the result. + // The XMP dates krilla doesn't write, then check the result after DefaultCMYK is attached. match crate::pdfx::make_pdfx4(&bytes, created) { Some(b) => bytes = b, None => warnings.push("PDF/X-4: the XMP metadata date couldn't be added".into()), } + } + bytes = crate::default_cmyk::attach(bytes, cmyk_icc.as_deref(), opts.standard, &mut warnings)?; + if opts.standard == Standard::PdfX4 { for issue in crate::pdfx::check_pdfx4(&bytes) { warnings.push(format!("PDF/X-4: {issue}")); } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b0ceaefff..bc34c7f84 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -43,6 +43,7 @@ mod links; mod marks; mod text; +mod default_cmyk; mod forms; mod optional_content; mod pdfx; diff --git a/crates/pdf/src/optional_content.rs b/crates/pdf/src/optional_content.rs index 08dbbe0bd..b51e06384 100644 --- a/crates/pdf/src/optional_content.rs +++ b/crates/pdf/src/optional_content.rs @@ -26,8 +26,13 @@ fn references(dict: &Dict<'_>, key: &[u8]) -> Result> { } fn edited(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])]) -> Result> { + edited_with(dict, replacements, invalid) +} + +/// Rewrites `dict`, replacing or inserting entries in `replacements` (an empty value deletes the key). +pub(crate) fn edited_with(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])], error: fn() -> PdfError) -> Result> { let data = dict.data(); - let body = data.strip_prefix(b"<<").and_then(|d| d.strip_suffix(b">>")).ok_or_else(invalid)?; + let body = if data.is_empty() { b"".as_slice() } else { data.strip_prefix(b"<<").and_then(|d| d.strip_suffix(b">>")).ok_or_else(error)? }; let mut reader = Reader::new(body); let mut out = b"<<".to_vec(); loop { @@ -36,12 +41,12 @@ fn edited(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])]) -> Result> { break; } let start = reader.offset(); - let name = reader.read_without_context::>().ok_or_else(invalid)?; + let name = reader.read_without_context::>().ok_or_else(error)?; reader.skip_white_spaces_and_comments(); - reader.skip::>>(false).ok_or_else(invalid)?; + reader.skip::>>(false).ok_or_else(error)?; if !replacements.iter().any(|(key, _)| *key == name.as_ref()) { out.push(b'\n'); - out.extend_from_slice(body.get(start..reader.offset()).ok_or_else(invalid)?); + out.extend_from_slice(body.get(start..reader.offset()).ok_or_else(error)?); } } for (key, value) in replacements { @@ -100,12 +105,12 @@ pub(crate) fn trailer_at(bytes: &[u8], offset: usize) -> Option> { /// Whether `dict` has `key`: a literal `null` value is the same as no entry. A reference counts, /// since a trailer has no context to resolve it in. -fn has_entry(dict: &Dict<'_>, key: &[u8]) -> bool { +pub(crate) fn has_entry(dict: &Dict<'_>, key: &[u8]) -> bool { dict.contains_key(key) && !matches!(dict.get_raw::>(key), Some(MaybeRef::NotRef(Object::Null(_)))) } -fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec)], root: ObjectIdentifier) -> Result> { - let damaged = || PdfError::Write("the placed PDF's cross-reference table is damaged; layer changes could not be preserved safely".into()); +/// Validates the cross-reference chain, rejects encrypted trailers, and returns `(prev, trailer, size)`. +fn trailer_and_size<'a>(bytes: &'a [u8], damaged: fn() -> PdfError, encrypted: fn() -> PdfError) -> Result<(usize, Dict<'a>, i32)> { let at = crate::transitions::rfind(bytes, b"startxref").ok_or_else(damaged)?; let tail = std::str::from_utf8(bytes.get(at..).ok_or_else(damaged)?).map_err(|_| damaged())?; let prev = crate::transitions::int_after(tail, "startxref").ok_or_else(damaged)?; @@ -121,7 +126,7 @@ fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec)], root: ObjectIde } let section = trailer_at(bytes, at).ok_or_else(damaged)?; if has_entry(§ion, b"Encrypt") { - return Err(invalid()); + return Err(encrypted()); } offset = section.get::(b"Prev"); if offset.is_none() && has_entry(§ion, b"Prev") { @@ -131,44 +136,86 @@ fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec)], root: ObjectIde if offset.is_some() { return Err(damaged()); } - trailer.get::(b"Size").filter(|n| *n > 0).ok_or_else(damaged)?; + let size = trailer.get::(b"Size").filter(|n| *n > 0).ok_or_else(damaged)?; + Ok((prev, trailer, size)) +} + +/// Validates an unencrypted PDF's trailer chain and returns its trailer `/Size`. +pub(crate) fn trailer_size(bytes: &[u8], damaged: fn() -> PdfError, encrypted: fn() -> PdfError) -> Result { + let (_, _, size) = trailer_and_size(bytes, damaged, encrypted)?; + Ok(size) +} + +/// Appends updated or newly allocated objects as a classic incremental update, keeping `/Size` +/// at least `max_obj + 1` and rejecting encrypted PDFs. +pub(crate) fn append_with( + bytes: &[u8], + changes: &[(ObjectIdentifier, Vec)], + root: Option, + damaged: fn() -> PdfError, + encrypted: fn() -> PdfError, +) -> Result> { + let (prev, trailer, old_size) = trailer_and_size(bytes, damaged, encrypted)?; + if changes.is_empty() { + return Ok(bytes.to_vec()); + } + let mut unique = std::collections::BTreeMap::new(); + for (id, body) in changes { + if id.obj_number <= 0 || u16::try_from(id.gen_number).is_err() { + return Err(damaged()); + } + unique.insert(*id, body.as_slice()); + } + let max_obj = unique.keys().next_back().map(|id| id.obj_number).unwrap_or(0); + let new_size = max_obj.checked_add(1).ok_or_else(damaged)?.max(old_size); let mut out = bytes.to_vec(); out.push(b'\n'); - let mut offsets = Vec::new(); - for (id, body) in changes { - offsets.push((*id, out.len())); + let mut offsets = Vec::with_capacity(unique.len()); + for (id, body) in unique { + offsets.push((id, out.len())); out.extend_from_slice(format!("{} {} obj\n", id.obj_number, id.gen_number).as_bytes()); out.extend_from_slice(body); out.extend_from_slice(b"\nendobj\n"); } let xref = out.len(); out.extend_from_slice(b"xref\n"); - offsets.sort_by_key(|(id, _)| *id); for (id, offset) in offsets { out.extend_from_slice(format!("{} 1\n{offset:010} {:05} n\r\n", id.obj_number, id.gen_number).as_bytes()); } - let root = format!("{} {} R", root.obj_number, root.gen_number); let prev = prev.to_string(); - let trailer = edited( - &trailer, - &[ - (b"Root", root.as_bytes()), - (b"Prev", prev.as_bytes()), - (b"XRefStm", b""), - (b"Type", b""), - (b"W", b""), - (b"Index", b""), - (b"Length", b""), - (b"Filter", b""), - (b"DecodeParms", b""), - ], - )?; + let size = new_size.to_string(); + let root_ref = root.map(|r| format!("{} {} R", r.obj_number, r.gen_number)); + let mut replacements: Vec<(&[u8], &[u8])> = vec![ + (b"Size", size.as_bytes()), + (b"Prev", prev.as_bytes()), + (b"XRefStm", b""), + (b"Type", b""), + (b"W", b""), + (b"Index", b""), + (b"Length", b""), + (b"Filter", b""), + (b"DecodeParms", b""), + ]; + if let Some(root_ref) = root_ref.as_ref() { + replacements.push((b"Root", root_ref.as_bytes())); + } + let trailer = edited_with(&trailer, &replacements, damaged)?; out.extend_from_slice(b"trailer\n"); out.extend_from_slice(&trailer); out.extend_from_slice(format!("\nstartxref\n{xref}\n%%EOF\n").as_bytes()); Ok(out) } +fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec)], root: ObjectIdentifier) -> Result> { + append_with( + bytes, + changes, + Some(root), + || PdfError::Write("the placed PDF's cross-reference table is damaged; layer changes could not be preserved safely".into()), + invalid, + ) +} + #[derive(Clone)] pub(crate) struct Variant { pub data: Arc>, diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 731a5989d..db7cff864 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -18,19 +18,36 @@ use crate::transitions::{find, int_after, object, rfind}; mod fonts; -/// The serialization settings for CMYK print output: DeviceCMYK and CMYK blending groups in the -/// document's CMYK working space (Coated FOGRA39 when it has none or its profile can't be -/// written). With `intent` (PDF/X), the working space is also the `GTS_PDFX` output intent, with -/// `/Trapped /False`. The second value is a warning when the working space's profile was replaced. -pub(crate) fn print_settings(intent: bool) -> crate::Result<(krilla::SerializeSettings, Option)> { - print_settings_with(&designcraft_color::cms::active(), intent) +/// [`print_settings_and_profile`] without the ICC bytes, kept for tests. +#[cfg(test)] +pub(crate) fn print_settings_with(cms: &designcraft_color::cms::Cms, intent: bool) -> crate::Result<(krilla::SerializeSettings, Option)> { + let (settings, _, warning) = print_settings_and_profile_with(cms, intent)?; + Ok((settings, warning)) } -/// [`print_settings`] for the working spaces of `cms`. -pub(crate) fn print_settings_with(cms: &designcraft_color::cms::Cms, intent: bool) -> crate::Result<(krilla::SerializeSettings, Option)> { +/// The serialization settings for CMYK print output: DeviceCMYK and CMYK blending groups in the +/// document's CMYK working space (Coated FOGRA39 when it has none or its profile can't be +/// written), together with the validated ICC profile bytes actually used. With `intent` (PDF/X), +/// the working space is also the `GTS_PDFX` output intent, with `/Trapped /False`. The third +/// value is a warning when the working space's profile was replaced. +pub(crate) fn print_settings_and_profile(intent: bool) -> crate::Result<(krilla::SerializeSettings, Vec, Option)> { + print_settings_and_profile_with(&designcraft_color::cms::active(), intent) +} + +/// [`print_settings_and_profile`] for the working spaces of `cms`. +pub(crate) fn print_settings_and_profile_with( + cms: &designcraft_color::cms::Cms, + intent: bool, +) -> crate::Result<(krilla::SerializeSettings, Vec, Option)> { let write = |color: PrintColor<'_>| if intent { print::settings(color) } else { print::cmyk_blending(color.profile) }; - let fogra39 = || write(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)); - let Ok(space) = cms.output_intent() else { return Ok((fogra39()?, None)) }; + let fogra39 = || { + let profile = craft_cms::builtin::COATED_FOGRA39_ICC; + Ok::<_, crate::PdfError>((write(PrintColor::fogra39(profile))?, profile.to_vec())) + }; + let Ok(space) = cms.output_intent() else { + let (settings, icc) = fogra39()?; + return Ok((settings, icc, None)); + }; let registered = space.identifier != "Custom"; let color = PrintColor { profile: &space.icc, @@ -40,9 +57,10 @@ pub(crate) fn print_settings_with(cms: &designcraft_color::cms::Cms, intent: boo info: Some(&space.condition), }; match write(color) { - Ok(settings) => Ok((settings, None)), + Ok(settings) => Ok((settings, space.icc.into_owned(), None)), Err(WriteError::InvalidProfile) => { - Ok((fogra39()?, Some(format!("the CMYK working space `{}` couldn't be written to the PDF; Coated FOGRA39 was used", space.condition)))) + let (settings, icc) = fogra39()?; + Ok((settings, icc, Some(format!("the CMYK working space `{}` couldn't be written to the PDF; Coated FOGRA39 was used", space.condition)))) } Err(e) => Err(e.into()), } diff --git a/crates/pdf/src/print_tests.rs b/crates/pdf/src/print_tests.rs index 6c5dafcb2..456c13d9c 100644 --- a/crates/pdf/src/print_tests.rs +++ b/crates/pdf/src/print_tests.rs @@ -287,3 +287,193 @@ fn knockout_and_isolation_are_group_attributes() { assert_eq!(attributes(grouped(true, true)), [(true, true)]); assert_eq!(attributes(grouped(false, false)), []); } + +/// Read the `/DefaultCMYK` ICC stream (object id and decoded profile bytes) from `res`. +fn default_cmyk_profile(res: &hayro_syntax::page::Resources<'_>) -> Option<(i32, i32, Vec)> { + let cs = res.get_color_space(&Name::new_unescaped(b"DefaultCMYK"))?.into_array()?; + let mut items = cs.iter::>(); + let name = items.next()?.into_name()?; + if name.as_ref() != b"ICCBased" { + return None; + } + let icc = items.next()?.into_stream()?; + let n = icc.dict().get::(b"N")?; + let id = icc.obj_id().obj_number; + let decoded = icc.decoded().ok()?.into_owned(); + Some((id, n, decoded)) +} + +/// Plain print exports, PDF/X-4 and Print Booklet attach the working CMYK profile as `/DefaultCMYK` +/// on every page and form XObject without adding `/OutputIntents` outside PDF/X; PDF/X-1a and +/// PDF/A-2b leave `/DefaultCMYK` out. +#[test] +fn default_cmyk_profile_is_written_where_appropriate() { + use designcraft_color::swatch::{ColorType, SwatchValue}; + use designcraft_color::{Color, Swatch}; + + let mut d = transparent_doc(); + d.swatches.push(Swatch::color("Card Blue", Color::cmyk(0.425, 0.175, 0.16, 0.071))); + add_box(&mut d, Rect::new(20.0, 20.0, 100.0, 100.0), "Card Blue", |_| {}); + d.swatches.push(Swatch { + name: "Spot Gold".into(), + value: SwatchValue::Color { color: Color::cmyk(0.0, 0.3, 1.0, 0.0), color_type: ColorType::Spot }, + locked: false, + named: true, + hidden: false, + }); + add_box(&mut d, Rect::new(40.0, 40.0, 120.0, 120.0), "Spot Gold", |_| {}); + + let expected = craft_cms::builtin::COATED_FOGRA39_ICC; + + // 1. Plain export (Standard::None): no OutputIntents / Trapped, but /DefaultCMYK on page and form XObjects. + let plain = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(output_intent(&plain).is_none()); + assert!(!String::from_utf8_lossy(&plain).contains("/OutputIntents")); + let pdf = Pdf::new(plain.clone()).expect("parse plain"); + let page_res = pdf.pages()[0].resources(); + let (icc_id, n, prof) = default_cmyk_profile(page_res).expect("page /DefaultCMYK in plain export"); + assert_eq!(n, 4); + assert_eq!(prof, expected); + // Spot Separation resource is kept alongside /DefaultCMYK. + assert!(!page_res.color_spaces.is_empty()); + let has_separation = page_res.color_spaces.entries().any(|(k, _)| k.as_ref() != b"DefaultCMYK"); + assert!(has_separation, "spot color space entry must be preserved alongside DefaultCMYK"); + // Transparency group Form XObject has its own /Resources dict, so it also needs /DefaultCMYK. + let mut forms = 0usize; + for name in page_res.x_objects.keys() { + if let Some(xo) = page_res.get_x_object(&name) + && xo.dict().get::(b"Subtype").as_deref() == Some(b"Form") + { + forms += 1; + let form_res = hayro_syntax::page::Resources::from_scope(xo.dict().get::(b"Resources"), page_res.clone()); + let (form_icc_id, form_n, _) = default_cmyk_profile(&form_res).expect("form /DefaultCMYK in plain export"); + assert_eq!(form_n, 4); + assert_eq!(form_icc_id, icc_id, "all scopes share one embedded ICC stream"); + } + } + assert!(forms > 0, "transparent box must produce a Form XObject"); + + // 2. PDF/X-4: passes check_pdfx4, keeps OutputIntents, and reuses DestOutputProfile for DefaultCMYK. + let x4_report = + crate::export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: crate::Standard::PdfX4, ..PdfOptions::default() }).unwrap(); + assert!(x4_report.warnings.is_empty(), "{:?}", x4_report.warnings); + assert!(crate::check_pdfx4(&x4_report.bytes).is_empty()); + let x4_pdf = Pdf::new(x4_report.bytes.clone()).expect("parse x4"); + let catalog = x4_pdf.xref().get::(x4_pdf.xref().root_id()).unwrap(); + let intents = catalog.get::(b"OutputIntents").unwrap(); + let oi = intents.iter::().next().unwrap(); + let dest_ref = oi.get_ref(b"DestOutputProfile").expect("DestOutputProfile ref"); + let (x4_icc_id, x4_n, x4_prof) = default_cmyk_profile(x4_pdf.pages()[0].resources()).expect("page /DefaultCMYK in PDF/X-4"); + assert_eq!(x4_n, 4); + assert_eq!(x4_icc_id, dest_ref.obj_number, "PDF/X-4 DefaultCMYK must reuse DestOutputProfile"); + assert_eq!(x4_prof, expected); + + // 3. Booklet export also includes /DefaultCMYK. + let booklet = crate::export_booklet(&d, &Cache::new(), &crate::BookletOptions::default()).unwrap(); + let booklet_pdf = Pdf::new(booklet.bytes).expect("parse booklet"); + let (_, b_n, b_prof) = default_cmyk_profile(booklet_pdf.pages()[0].resources()).expect("booklet /DefaultCMYK"); + assert_eq!(b_n, 4); + assert_eq!(b_prof, expected); + + // 4. PDF/X-1a and PDF/A-2b must NOT carry /DefaultCMYK (ICCBased is forbidden in PDF/X-1a; PDF/A-2b is RGB). + let x1a = crate::export_pdf( + &d, + &Cache::new(), + &PdfOptions { standard: crate::Standard::PdfX1a, created: Some(1_700_000_000), ..PdfOptions::default() }, + ) + .unwrap(); + assert!(crate::check_pdfx1a(&x1a).is_empty()); + let x1a_pdf = Pdf::new(x1a).expect("parse x1a"); + assert!(default_cmyk_profile(x1a_pdf.pages()[0].resources()).is_none(), "PDF/X-1a cannot carry ICCBased DefaultCMYK"); + + let pdfa = crate::export_pdf( + &d, + &Cache::new(), + &PdfOptions { standard: crate::Standard::PdfA2b, created: Some(1_700_000_000), ..PdfOptions::default() }, + ) + .unwrap(); + let pdfa_pdf = Pdf::new(pdfa).expect("parse pdfa"); + assert!(default_cmyk_profile(pdfa_pdf.pages()[0].resources()).is_none()); +} + +/// Draw fills via hayro-interpret on page 0 and return each solid fill's resolved `[R, G, B, A]`. +fn interpreted_fill_rgba(bytes: &[u8]) -> Vec<[u8; 4]> { + use hayro_interpret::font::Glyph; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + interpret_page, + }; + struct Collector(Vec<[u8; 4]>); + impl Device<'_> for Collector { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, paint: &Paint<'_>, mode: &PathDrawMode) { + if matches!(mode, PathDrawMode::Fill(_)) + && let Paint::Color(c) = paint + { + self.0.push(c.to_rgba().to_rgba8()); + } + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + let cache = InterpreterCache::new(); + let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 400.0, 400.0), &cache, pdf.xref(), InterpreterSettings::default()); + let mut out = Collector(Vec::new()); + interpret_page(&pdf.pages()[0], &mut ctx, &mut out); + out.0 +} + +/// With `/DefaultCMYK`, hayro-interpret maps `DeviceCMYK(0.425, 0.175, 0.16, 0.071)` through the +/// Coated FOGRA39 ICC profile (`~[152, 179, 195]`) identically in plain export and PDF/X-4, instead +/// of falling back to naive untagged `1 - c` (`[136, 195, 199]`). +#[test] +fn default_cmyk_resolves_cmyk_colors_through_icc_profile() { + use designcraft_color::{Color, Swatch}; + + let mut d = Document::new(&NewDocument::default()); + d.swatches.push(Swatch::color("Slate", Color::cmyk(0.425, 0.175, 0.16, 0.071))); + add_box(&mut d, Rect::new(20.0, 20.0, 120.0, 120.0), "Slate", |_| {}); + + let plain = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + let x4 = export_pdf(&d, &Cache::new(), &PdfOptions { standard: crate::Standard::PdfX4, ..PdfOptions::default() }).unwrap(); + + let plain_fills = interpreted_fill_rgba(&plain); + let x4_fills = interpreted_fill_rgba(&x4); + assert_eq!(plain_fills.len(), 1); + assert_eq!(plain_fills, x4_fills, "plain export and PDF/X-4 must resolve CMYK identically"); + let [r, g, b, a] = plain_fills[0]; + assert_eq!(a, 255); + assert!( + (148..=158).contains(&r) && (175..=185).contains(&g) && (190..=200).contains(&b), + "expected FOGRA39 slate ~[152,179,195], got {:?}", + [r, g, b] + ); +} + +/// RGB-blend documents skip `/DefaultCMYK` when they contain only RGB, and attach it when they draw CMYK. +#[test] +fn rgb_blend_document_attaches_default_cmyk_only_when_cmyk_is_present() { + use designcraft_color::{Color, Swatch}; + + let mut d = Document::new(&NewDocument::default()); + d.settings.blend_space = BlendSpace::Rgb; + d.swatches.push(Swatch::color("PureRGB", Color::rgb(0.2, 0.5, 0.8))); + d.swatches.push(Swatch::color("Slate", Color::cmyk(0.425, 0.175, 0.16, 0.071))); + add_box(&mut d, Rect::new(10.0, 10.0, 100.0, 100.0), "PureRGB", |_| {}); + let pure_rgb = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + let pure_pdf = Pdf::new(pure_rgb).unwrap(); + assert!(default_cmyk_profile(pure_pdf.pages()[0].resources()).is_none(), "pure RGB export should not embed a CMYK ICC profile"); + + add_box(&mut d, Rect::new(120.0, 10.0, 200.0, 100.0), "Slate", |_| {}); + let mixed = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + let mixed_pdf = Pdf::new(mixed).unwrap(); + let (_, n, prof) = default_cmyk_profile(mixed_pdf.pages()[0].resources()).expect("RGB doc with CMYK graphic needs /DefaultCMYK"); + assert_eq!(n, 4); + assert_eq!(prof, craft_cms::builtin::COATED_FOGRA39_ICC); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 52bd45ddc..05eace82e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -327,7 +327,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. - Editorial notes (`note.new/edit/delete/convertToText/convertFromText/split/list`, Notes panel with Split Note, Type › Notes › Convert to Note): anchored in text, flagged on screen, never printed or exported. - File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. -- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs blend in that space: CMYK print exports write their transparency groups and page groups in DeviceCMYK (PDF/X-4 also carries the CMYK working space as its `GTS_PDFX` output intent); RGB exports give pages with transparency a DeviceRGB page group. On screen, CMYK spreads with separable blend modes use two ink-channel passes through the working color settings. Parent-page items, nested groups, effects and pasteboard share that path; 100% K in Color Dodge whitens colored areas while paper stays outside the blend backdrop. Effect blend layers sit outside their clips and offscreen filters, preserving their backdrop in RGB too. The pass count is independent of blended-object count, and unchanged viewports reuse one cached raster (64 MiB cap). Spreads containing Hue, Saturation, Color or Luminosity still use the existing RGB blend followed by a CMYK proof approximation; full nonseparable CMYK blending remains open. +- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs blend in that space: CMYK print exports write their transparency groups and page groups in DeviceCMYK (PDF/X-4 also carries the CMYK working space as its `GTS_PDFX` output intent; plain print, PDF/X-4, booklet and CMYK-containing RGB exports attach the working CMYK profile as `/DefaultCMYK`); RGB exports give pages with transparency a DeviceRGB page group. On screen, CMYK spreads with separable blend modes use two ink-channel passes through the working color settings. Parent-page items, nested groups, effects and pasteboard share that path; 100% K in Color Dodge whitens colored areas while paper stays outside the blend backdrop. Effect blend layers sit outside their clips and offscreen filters, preserving their backdrop in RGB too. The pass count is independent of blended-object count, and unchanged viewports reuse one cached raster (64 MiB cap). Spreads containing Hue, Saturation, Color or Luminosity still use the existing RGB blend followed by a CMYK proof approximation; full nonseparable CMYK blending remains open. - High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection). - Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans). - Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images.