diff --git a/Cargo.lock b/Cargo.lock index b8cbdd0df..fa4fd2146 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1548,7 +1548,7 @@ dependencies = [ [[package]] name = "craft-psd" version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895#2e670cf9377b523112f3fe4b409399772c745895" +source = "git+https://github.com/storytold/craft-libs?rev=ea1c84290dadec636900b4d1bce107e59ca3f0d6#ea1c84290dadec636900b4d1bce107e59ca3f0d6" dependencies = [ "encoding_rs", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 7f6d06e81..ebd97694e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ craft-docx = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe243 craft-wordmodel = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-csv = { git = "https://github.com/storytold/craft-libs", rev = "387c64892b30fdad491b263cc3d9eac660d88dcc", version = "=0.1.0" } craft-image-codecs = { git = "https://github.com/storytold/craft-libs", rev = "ea1c84290dadec636900b4d1bce107e59ca3f0d6", version = "=0.1.0" } -craft-psd = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "2e670cf9377b523112f3fe4b409399772c745895", version = "=0.1.0" } +craft-psd = { git = "https://github.com/storytold/craft-libs", rev = "ea1c84290dadec636900b4d1bce107e59ca3f0d6", version = "=0.1.0" } craft-file-formats = { git = "https://github.com/storytold/craft-libs", rev = "eed3c28cf016723d004d6f2c79c61424c15d4558", version = "=0.1.0" } craft-opc = { git = "https://github.com/storytold/craft-libs", rev = "8abfa9d0f14bafbfc3449fc552ffeafb9ca6a551", version = "=0.1.0" } craft-pptx = { git = "https://github.com/storytold/craft-libs", rev = "f6cab1980", version = "=0.1.0" } diff --git a/ROADMAP.md b/ROADMAP.md index ae9c05d82..c52332752 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,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. @@ -399,6 +399,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m - **2026-11-14** · IDML export keeps each endnote reference as its number in superscript text, as the endnote frame lists it, instead of dropping it; the endnotes are still written as plain text, without IDML's endnote elements (#2753). No readiness percentage change. - **2026-11-14** · Object › Text Frame Options shows the inset spacing (one Inset for a non-rectangular frame), the column width and gutter and the paragraph spacing limit in the document's ruler units, as the Properties panel does, instead of always in picas, and a bare number typed in any of its measure fields is in those units (#2739). No readiness percentage change. - **2026-11-12** · A render decodes the placed images in view that are not cached yet on several threads before it draws, instead of one after another while drawing. Page thumbnails, image exports and the first canvas frame of a page with six 24 MP photos take about 1.2 s instead of 2.8 s. No readiness percentage change. +- **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 5450f60a9..e29e5340b 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -24,6 +24,7 @@ image = { workspace = true } thiserror = { workspace = true } log = { workspace = true } chrono = { workspace = true } +flate2 = "1" [dev-dependencies] image = { workspace = true, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } @@ -31,5 +32,4 @@ hayro-interpret = { version = "0.7.0", default-features = false, features = ["em 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..972eae853 --- /dev/null +++ b/crates/pdf/src/default_cmyk.rs @@ -0,0 +1,1029 @@ +//! 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 craft_pdf::syntax::Pdf; +use craft_pdf::syntax::content::UntypedIter; +use craft_pdf::syntax::object::{Dict, Name, Object, ObjectIdentifier, Stream}; +use craft_pdf::syntax::page::{Page, Resources}; +use flate2::Compression; +use flate2::write::ZlibEncoder; + +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; + +/// Maximum `scan_stream` invocations across a document before aborting `CmykScan`. +const MAX_SCAN_STREAMS: 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()) +} + +fn depth_limit() -> PdfError { + PdfError::Write("the exported PDF exceeds the DefaultCMYK nesting depth limit".into()) +} + +fn scan_limit() -> PdfError { + PdfError::Write("the exported PDF exceeds the DefaultCMYK stream scan budget".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, special color space alternate/base, or +/// Type3 font glyph). +pub(crate) fn uses_device_cmyk(bytes: &[u8]) -> Result { + let pdf = Pdf::new(bytes.to_vec()).map_err(|_| damaged())?; + let mut scan = CmykScan::default(); + for page in pdf.pages().iter() { + if scan.page(page)? { + return Ok(true); + } + } + Ok(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 { + return Err(depth_limit()); + } + if !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 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") + { + // Never fall back to `font.obj_id()` for inline Type3 dicts: it returns the enclosing object. + let font_id = fonts.get_ref(key.as_ref()).map(ObjectIdentifier::from); + if let Some(id) = font_id + && !self.seen_fonts.insert(id) + { + continue; + } + if next >= MAX_DEPTH { + return Err(depth_limit()); + } + 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 !self.seen_streams.insert(stream.obj_id()) { + return Ok(()); + } + if depth >= MAX_DEPTH { + return Err(depth_limit()); + } + let owner = ScopeOwner::Stream(stream.clone()); + self.visit_resource_owner(stream.dict(), Some(owner), depth) + } +} + +fn is_device_cmyk(cs: &Object<'_>, res: &Resources<'_>, depth: usize) -> Result { + if depth >= MAX_DEPTH { + return Err(depth_limit()); + } + match cs { + Object::Name(n) => match n.as_ref() { + b"DeviceCMYK" | b"CMYK" => Ok(true), + b"DeviceRGB" | b"RGB" | b"DeviceGray" | b"G" | b"CalGray" | b"CalRGB" | b"Lab" | b"Pattern" | b"DefaultCMYK" => Ok(false), + other => match res.get_color_space(&Name::new_unescaped(other)) { + Some(named) if !matches!(named, Object::Name(ref m) if m.as_ref() == other) => is_device_cmyk(&named, res, depth + 1), + _ => Ok(false), + }, + }, + Object::Array(a) => { + let mut items = a.iter::>(); + let Some(Object::Name(family)) = items.next() else { + return Ok(false); + }; + match family.as_ref() { + b"DeviceCMYK" | b"CMYK" => Ok(true), + b"Indexed" | b"I" | b"Pattern" => items.next().map_or(Ok(false), |base| is_device_cmyk(&base, res, depth + 1)), + b"Separation" | b"DeviceN" => { + let _names = items.next(); + items.next().map_or(Ok(false), |alt| is_device_cmyk(&alt, res, depth + 1)) + } + _ => Ok(false), + } + } + _ => Ok(false), + } +} + +type ResourceLayer<'a> = [&'a [u8]; 6]; + +#[derive(Hash, PartialEq, Eq)] +struct StreamKey<'a> { + id: ObjectIdentifier, + resources: Vec>, +} + +impl<'a> StreamKey<'a> { + fn new(id: ObjectIdentifier, res: &Resources<'a>) -> Self { + let mut resources = Vec::new(); + let mut cur = Some(res); + while let Some(r) = cur { + resources.push([r.color_spaces.data(), r.shadings.data(), r.patterns.data(), r.ext_g_states.data(), r.x_objects.data(), r.fonts.data()]); + cur = r.parent(); + } + Self { id, resources } + } +} + +fn find_font_with_id<'a>(res: &Resources<'a>, name: &Name<'_>) -> Option<(Dict<'a>, Option)> { + let mut cur = Some(res); + while let Some(r) = cur { + if let Some(font) = r.fonts.get::>(name.as_ref()) { + let id = r.fonts.get_ref(name.as_ref()).map(ObjectIdentifier::from); + return Some((font, id)); + } + cur = r.parent(); + } + None +} + +#[derive(Default)] +struct CmykScan<'a> { + active_streams: HashSet, + active_fonts: HashSet, + completed_false_streams: HashSet>, + completed_false_fonts: HashSet>, + scan_calls: usize, +} + +impl<'a> CmykScan<'a> { + fn page(&mut self, page: &'a Page<'a>) -> Result { + let res = page.resources(); + if let Some(g) = page.raw().get::>(b"Group") + && Self::group(&g, res)? + { + return Ok(true); + } + let Some(data) = page.page_stream() else { + return Ok(false); + }; + let mut hit_cycle = false; + self.content(data, res, 0, &mut hit_cycle) + } + + fn group(group: &Dict<'_>, res: &Resources<'_>) -> Result { + match group.get::>(b"CS") { + Some(cs) => is_device_cmyk(&cs, res, 0), + None => Ok(false), + } + } + + fn content(&mut self, data: &[u8], res: &Resources<'a>, depth: usize, hit_cycle: &mut bool) -> Result { + if depth >= MAX_DEPTH { + return Err(depth_limit()); + } + 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 Ok(true), + b"cs" | b"CS" => { + if let Some(n) = name() + && is_device_cmyk(&Object::Name(n), res, 0)? + { + return Ok(true); + } + } + b"sh" => { + if let Some(sh) = name().and_then(|n| res.get_shading(&n)) + && Self::shading(sh, res)? + { + return Ok(true); + } + } + b"scn" | b"SCN" => { + if let Some(p) = name().and_then(|n| res.get_pattern(&n)) + && self.pattern(p, res, depth, hit_cycle)? + { + return Ok(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, hit_cycle)? + { + return Ok(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, res)?, + Some(b"Form") => self.form(&xo, res, depth, hit_cycle)?, + _ => false, + }; + if hit { + return Ok(true); + } + } + } + b"BI" | b"EI" => { + if let Some(Object::Stream(img)) = last + && Self::inline_image(&img, res)? + { + return Ok(true); + } + } + b"Tf" => { + if let Some(font_name) = op.operands().next().cloned().and_then(Object::into_name) + && let Some((font, font_id)) = find_font_with_id(res, &font_name) + && font.get::>(b"Subtype").as_deref() == Some(b"Type3") + && self.type3(&font, font_id, res, depth, hit_cycle)? + { + return Ok(true); + } + } + _ => {} + } + } + Ok(false) + } + + fn image(img: &Stream<'_>, res: &Resources<'_>) -> Result { + if img.dict().get::(b"ImageMask") == Some(true) { + return Ok(false); + } + match img.dict().get::>(b"ColorSpace") { + Some(cs) => is_device_cmyk(&cs, res, 0), + None => Ok(false), + } + } + + fn inline_image(img: &Stream<'_>, res: &Resources<'_>) -> Result { + if img.dict().get::(b"IM") == Some(true) || img.dict().get::(b"ImageMask") == Some(true) { + return Ok(false); + } + let cs = img.dict().get::>(b"CS").or_else(|| img.dict().get::>(b"ColorSpace")); + match cs { + Some(cs) => is_device_cmyk(&cs, res, 0), + None => Ok(false), + } + } + + fn shading(sh: Object<'_>, res: &Resources<'_>) -> Result { + let dict = match sh { + Object::Dict(d) => d, + Object::Stream(s) => s.dict().clone(), + _ => return Ok(false), + }; + match dict.get::>(b"ColorSpace") { + Some(cs) => is_device_cmyk(&cs, res, 0), + None => Ok(false), + } + } + + fn pattern(&mut self, pat: Object<'a>, res: &Resources<'a>, depth: usize, hit_cycle: &mut bool) -> Result { + match pat { + Object::Dict(d) => match d.get::>(b"Shading") { + Some(sh) => Self::shading(sh, res), + None => Ok(false), + }, + Object::Stream(s) => self.form(&s, res, depth, hit_cycle), + _ => Ok(false), + } + } + + fn scan_stream(&mut self, stream: &Stream<'a>, res: Resources<'a>, depth: usize, hit_cycle: &mut bool, check_group: bool) -> Result { + if self.scan_calls >= MAX_SCAN_STREAMS { + return Err(scan_limit()); + } + self.scan_calls += 1; + let id = stream.obj_id(); + if self.active_streams.contains(&id) { + *hit_cycle = true; + return Ok(false); + } + if depth >= MAX_DEPTH { + return Err(depth_limit()); + } + let key = StreamKey::new(id, &res); + if self.completed_false_streams.contains(&key) { + return Ok(false); + } + if check_group + && let Some(g) = stream.dict().get::>(b"Group") + && Self::group(&g, &res)? + { + return Ok(true); + } + let data = stream.decoded().map_err(|_| damaged())?; + self.active_streams.insert(id); + let mut inner_cycle = false; + let res_found = self.content(&data, &res, depth, &mut inner_cycle); + self.active_streams.remove(&id); + if inner_cycle { + *hit_cycle = true; + } + let found = res_found?; + if !found && !inner_cycle { + self.completed_false_streams.insert(key); + } + Ok(found) + } + + fn form(&mut self, form: &Stream<'a>, parent: &Resources<'a>, depth: usize, hit_cycle: &mut bool) -> Result { + let res = Resources::from_scope(form.dict().get::>(b"Resources"), parent.clone()); + self.scan_stream(form, res, depth + 1, hit_cycle, true) + } + + fn type3( + &mut self, + font: &Dict<'a>, + font_id: Option, + parent: &Resources<'a>, + depth: usize, + hit_cycle: &mut bool, + ) -> Result { + if let Some(id) = font_id + && self.active_fonts.contains(&id) + { + *hit_cycle = true; + return Ok(false); + } + if depth + 1 >= MAX_DEPTH { + return Err(depth_limit()); + } + let res = Resources::from_scope(font.get::>(b"Resources"), parent.clone()); + let key = font_id.map(|id| StreamKey::new(id, &res)); + if let Some(ref k) = key + && self.completed_false_fonts.contains(k) + { + return Ok(false); + } + let Some(procs) = font.get::>(b"CharProcs") else { + return Ok(false); + }; + if let Some(id) = font_id { + self.active_fonts.insert(id); + } + let mut inner_cycle = false; + let mut found = Ok(false); + for proc_key in procs.keys() { + if let Some(glyph) = procs.get::>(proc_key.as_ref()) { + match self.scan_stream(&glyph, res.clone(), depth + 1, &mut inner_cycle, false) { + Ok(true) => { + found = Ok(true); + break; + } + Ok(false) => {} + Err(e) => { + found = Err(e); + break; + } + } + } + } + if let Some(id) = font_id { + self.active_fonts.remove(&id); + } + if inner_cycle { + *hit_cycle = true; + } + let hit = found?; + if !hit + && !inner_cycle + && let Some(k) = key + { + self.completed_false_fonts.insert(k); + } + Ok(hit) + } +} + +#[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 make_nested = |levels: usize| { + let mut objects = vec![ + b"<>".to_vec(), + b"<>".to_vec(), + b"<>>>>>".to_vec(), + b"<>\nstream\n/F0 Do\nendstream".to_vec(), + ]; + for i in 0..levels { + let next_obj = objects.len() + 2; + let body = if i + 1 < levels { + format!( + "<>>>/Length 5>>\nstream\n/F Do\nendstream" + ) + } else { + "<>/Length 0>>\nstream\n\nendstream".to_string() + }; + objects.push(body.into_bytes()); + } + build_pdf(&objects, "") + }; + + // Just inside MAX_DEPTH (31 Form levels beneath Page at depth 0) succeeds. + let valid_bytes = make_nested(MAX_DEPTH - 1); + let updated = apply(&valid_bytes, COATED_FOGRA39_ICC, false).unwrap(); + let parsed = Pdf::new(updated).unwrap(); + assert!(read_default_cmyk(parsed.pages()[0].resources()).is_some()); + assert!(!uses_device_cmyk(&valid_bytes).unwrap()); + + // Past MAX_DEPTH (40 non-cyclic Form levels) fails explicitly instead of silently truncating. + let deep_bytes = make_nested(40); + assert!(apply(&deep_bytes, COATED_FOGRA39_ICC, false).is_err()); + assert!(uses_device_cmyk(&deep_bytes).is_err()); + } + + #[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").is_err()); + } + + #[test] + fn uses_device_cmyk_distinguishes_pure_rgb_from_cmyk() { + let make = |cs_dict: &str, content: &[u8]| { + build_pdf( + &[ + b"<>".to_vec(), + b"<>".to_vec(), + format!("<>>>>>").into_bytes(), + 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")).unwrap()); + assert!(uses_device_cmyk(&make("", b"0 0 0 1 k 0 0 10 10 re f")).unwrap()); + assert!(uses_device_cmyk(&make("", b"/DeviceCMYK cs 1 0 0 0 scn 0 0 10 10 re f")).unwrap()); + + let sep_rgb = "/CS0[/Separation/Gold/DeviceRGB<>]"; + let sep_cmyk = "/CS0[/Separation/Gold/DeviceCMYK<>]"; + let devn_cmyk = "/CS0[/DeviceN[/Gold]/DeviceCMYK<>]"; + let idx_sep_cmyk = "/CS0[/Indexed[/Separation/Gold/DeviceCMYK<>]1 <00FF>]"; + assert!(!uses_device_cmyk(&make(sep_rgb, b"/CS0 cs 1 scn 0 0 10 10 re f")).unwrap()); + assert!(uses_device_cmyk(&make(sep_cmyk, b"/CS0 cs 1 scn 0 0 10 10 re f")).unwrap()); + assert!(uses_device_cmyk(&make(devn_cmyk, b"/CS0 cs 1 scn 0 0 10 10 re f")).unwrap()); + assert!(uses_device_cmyk(&make(idx_sep_cmyk, b"/CS0 cs 1 scn 0 0 10 10 re f")).unwrap()); + assert!(uses_device_cmyk(&make("/CS0[/Pattern/DeviceCMYK]", b"/CS0 cs 0 0 10 10 re f")).unwrap()); + } + + #[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).unwrap(), "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); + } + + #[test] + fn inline_type3_font_does_not_overwrite_container_resources() { + // Object 4 is an indirect /Resources dictionary containing two inline Type3 fonts: + // /F3 with inline /Resources (must not corrupt object 4) and /F4 with indirect /Resources 5 0 R. + let type3 = |extra: &str| { + format!( + "<>/Encoding<>/FirstChar 97/LastChar 97/Widths[10]/Resources {extra}>>" + ) + }; + let res4 = format!("<>/XObject<>>>", type3("<>>>"), type3("5 0 R")); + let pdf_bytes = build_pdf( + &[ + b"<>".to_vec(), + b"<>".to_vec(), + b"<>".to_vec(), + res4.into_bytes(), + b"<>>>".to_vec(), + b"<>>>/Length 0>>\nstream\n\nendstream".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).unwrap()); + 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 resources retain ColorSpace and get DefaultCMYK"); + assert!(page_res.get_font(&Name::new_unescaped(b"F3")).is_some(), "container /Font /F3 preserved"); + assert!(page_res.get_x_object(&Name::new_unescaped(b"Fm")).is_some(), "container /XObject preserved"); + let f4 = page_res.get_font(&Name::new_unescaped(b"F4")).expect("container /Font /F4 preserved"); + let f4_res = Resources::from_scope(f4.get::>(b"Resources"), page_res.clone()); + assert_eq!(read_default_cmyk(&f4_res).expect("inline Type3 indirect Resources DefaultCMYK").0, p_id); + } + + #[test] + fn shared_form_without_resources_uses_each_pages_resources() { + let two_page_shared_form = |page2_cs: &str| { + // 1: Catalog, 2: Pages (2 kids), 3: Page 1 (CS0 = DeviceRGB, invokes Form 7), + // 4: Page 2 (CS0 = `page2_cs`, invokes Form 7), 5: Page 1 content, 6: Page 2 content, + // 7: Shared Form without /Resources using `/CS0 cs`. + let page_stream = "/Fm Do"; + let form_stream = "/CS0 cs 1 0 0 0 scn 0 0 1 1 re f"; + build_pdf( + &[ + b"<>".to_vec(), + b"<>".to_vec(), + b"<>/XObject<>>>>>".to_vec(), + format!( + "<>/XObject<>>>>>" + ) + .into_bytes(), + format!("<>\nstream\n{page_stream}\nendstream", page_stream.len()).into_bytes(), + format!("<>\nstream\n{page_stream}\nendstream", page_stream.len()).into_bytes(), + format!("<>\nstream\n{form_stream}\nendstream", form_stream.len()) + .into_bytes(), + ], + "", + ) + }; + assert!(!uses_device_cmyk(&two_page_shared_form("/DeviceRGB")).unwrap()); + assert!( + uses_device_cmyk(&two_page_shared_form("/DeviceCMYK")).unwrap(), + "Page 1 negative cache for resource-less Form must not hide Page 2 DeviceCMYK resolution" + ); + } + + #[test] + fn branching_self_referencing_form_chain_hits_scan_budget() { + let make_chain = |levels: usize| { + let xobj_entries: String = (0..levels).map(|i| format!("/F{i} {} 0 R", i + 5)).collect(); + let mut objects = vec![ + b"<>".to_vec(), + b"<>".to_vec(), + format!("<>>>>>").into_bytes(), + b"<>\nstream\n/F0 Do\nendstream".to_vec(), + ]; + for i in 0..levels { + // Omit `/Resources` on each Form so it inherits the page scope rather than holding a + // self-reference inside its own indirect object context. + let ops = if i + 1 < levels { + let next = i + 1; + format!("/F{i} Do /F{next} Do /F{next} Do") + } else { + format!("/F{i} Do") + }; + objects.push(format!("<>\nstream\n{ops}\nendstream", ops.len()).into_bytes()); + } + build_pdf(&objects, "") + }; + + let small = make_chain(4); + assert!(!uses_device_cmyk(&small).unwrap()); + + let deep_branching = make_chain(20); + let err = uses_device_cmyk(&deep_branching).unwrap_err(); + assert_eq!(err.to_string(), scan_limit().to_string()); + let attach_err = attach(deep_branching, None, crate::Standard::None, &mut Vec::new()).unwrap_err(); + assert_eq!(attach_err.to_string(), scan_limit().to_string()); + } +} diff --git a/crates/pdf/src/export/booklet.rs b/crates/pdf/src/export/booklet.rs index 47eab36a0..a902b6e4f 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); @@ -170,6 +170,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 a815f0e4d..b275c2d82 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()); @@ -290,11 +290,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 eb20bf9af..5b9603d38 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 a21d89103..4fd162ea5 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 bae800bd5..59088f5d4 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 ccc646585..66d76b0fe 100644 --- a/crates/pdf/src/print_tests.rs +++ b/crates/pdf/src/print_tests.rs @@ -287,3 +287,217 @@ 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: &craft_pdf::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 = craft_pdf::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 = craft_pdf::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); +} + +/// An RGB-blend document whose only graphic is a CMYK-backed spot swatch writes a `/Separation` +/// color space with a `/DeviceCMYK` alternate, so `/DefaultCMYK` must still be attached. +#[test] +fn rgb_blend_spot_only_document_with_cmyk_alternate_attaches_default_cmyk() { + use designcraft_color::swatch::{ColorType, SwatchValue}; + use designcraft_color::{Color, Swatch}; + + let mut d = Document::new(&NewDocument::default()); + d.settings.blend_space = BlendSpace::Rgb; + 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(10.0, 10.0, 100.0, 100.0), "Spot Gold", |_| {}); + let pdf_bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + let pdf = Pdf::new(pdf_bytes).unwrap(); + let (_, n, prof) = default_cmyk_profile(pdf.pages()[0].resources()).expect("RGB doc with CMYK-alternate spot color 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 f9bfac005..4b5e3c981 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 and each reference's number as superscript text, not IDML endnote elements (#2753). - 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.