From a905cb64e1ff1a19a5d1009dce603c87bd01b939 Mon Sep 17 00:00:00 2001 From: AlPaka1234 <356241429+AlPaka1234@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:33:05 +0100 Subject: [PATCH 1/6] PDF export: attach working CMYK profile as /DefaultCMYK (#2743) Fixes #2743. Attach /DefaultCMYK [/ICCBased ...] referencing the CMYK working space profile on every page, inherited /Pages resource dict, Form XObject, SMask form, tiling pattern, and Type3 font resource scope outside PDF/X-1a and PDF/A-2b. Plain print exports and booklets embed one shared Flate-compressed /N 4 ICC profile stream without adding /OutputIntents or /Trapped; PDF/X-4 reuses the catalog GTS_PDFX /DestOutputProfile stream; RGB-blend documents attach /DefaultCMYK only when content or resources use DeviceCMYK. --- ROADMAP.md | 3 +- crates/engine/src/cmd/export/pdf.rs | 33 ++ crates/pdf/Cargo.toml | 2 +- crates/pdf/src/default_cmyk.rs | 749 ++++++++++++++++++++++++++++ crates/pdf/src/export/booklet.rs | 9 +- crates/pdf/src/export/pipeline.rs | 13 +- crates/pdf/src/lib.rs | 1 + crates/pdf/src/optional_content.rs | 103 ++-- crates/pdf/src/pdfx.rs | 42 +- crates/pdf/src/print_tests.rs | 190 +++++++ docs/target-app-parity.md | 2 +- 11 files changed, 1095 insertions(+), 52 deletions(-) create mode 100644 crates/pdf/src/default_cmyk.rs 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. From 13ccea15cad01916d64934d646d8cc9a00eb3b59 Mon Sep 17 00:00:00 2001 From: AlPaka1234 <356241429+AlPaka1234@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:20:37 +0100 Subject: [PATCH 2/6] PDF export: address review feedback on DefaultCMYK attachment (#2743) Do not use font.obj_id() for inline Type3 font resources so the enclosing container is never overwritten; inspect alternate/base spaces of Separation, DeviceN, Pattern, and nested Indexed spaces in uses_device_cmyk; scope scan memoization in CmykScan to each effective resource chain while keeping cycles bounded; return an explicit error when non-cyclic nesting exceeds MAX_DEPTH. --- crates/pdf/src/default_cmyk.rs | 480 ++++++++++++++++++++++++--------- crates/pdf/src/print_tests.rs | 24 ++ 2 files changed, 381 insertions(+), 123 deletions(-) diff --git a/crates/pdf/src/default_cmyk.rs b/crates/pdf/src/default_cmyk.rs index 5b30aa39c..17c64ca7d 100644 --- a/crates/pdf/src/default_cmyk.rs +++ b/crates/pdf/src/default_cmyk.rs @@ -33,6 +33,10 @@ 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()) +} + /// 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`. /// @@ -81,7 +85,7 @@ pub(crate) fn attach(bytes: Vec, cmyk_icc: Option<&[u8]>, standard: crate::S let fallback; let icc = match cmyk_icc { Some(icc) => icc, - None if uses_device_cmyk(&bytes) => { + None if uses_device_cmyk(&bytes)? => { let (_, profile, warning) = crate::pdfx::print_settings_and_profile(false)?; warnings.extend(warning); fallback = profile; @@ -93,18 +97,17 @@ pub(crate) fn attach(bytes: Vec, cmyk_icc: Option<&[u8]>, standard: crate::S } /// 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; - }; +/// 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 true; + if scan.page(page)? { + return Ok(true); } } - false + Ok(false) } /// Returns the first 4-channel `/DestOutputProfile` stream ID from `/OutputIntents`. @@ -210,7 +213,10 @@ impl<'a> Updater<'a> { } 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") { + 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); @@ -287,9 +293,6 @@ impl<'a> Updater<'a> { 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() { @@ -325,12 +328,16 @@ impl<'a> Updater<'a> { 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()); + // 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)?; } @@ -340,52 +347,114 @@ impl<'a> Updater<'a> { } fn visit_stream(&mut self, stream: &Stream<'a>, depth: usize) -> Result<()> { - if depth >= MAX_DEPTH || !self.seen_streams.insert(stream.obj_id()) { + 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<'_>) -> bool { +fn is_device_cmyk(cs: &Object<'_>, res: &Resources<'_>, depth: usize) -> Result { + if depth >= MAX_DEPTH { + return Err(depth_limit()); + } match cs { - Object::Name(n) => matches!(n.as_ref(), b"DeviceCMYK" | b"CMYK"), + 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::>(); - 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")) + 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)) } - _ => false, + _ => Ok(false), } } - _ => 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 { - seen_streams: HashSet, - seen_fonts: HashSet, +struct CmykScan<'a> { + active_streams: HashSet, + active_fonts: HashSet, + completed_false_streams: HashSet>, + completed_false_fonts: HashSet>, } -impl CmykScan { - fn page(&mut self, page: &Page<'_>) -> bool { +impl<'a> CmykScan<'a> { + fn page(&mut self, page: &'a Page<'a>) -> Result { let res = page.resources(); - if page.raw().get::>(b"Group").is_some_and(|g| Self::group(&g)) { - return true; + if let Some(g) = page.raw().get::>(b"Group") + && Self::group(&g, res)? + { + return Ok(true); } - page.page_stream().is_some_and(|data| self.content(data, res, 0)) + 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<'_>) -> bool { - group.get::>(b"CS").is_some_and(|cs| is_device_cmyk(&cs)) + 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<'_>, depth: usize) -> bool { + fn content(&mut self, data: &[u8], res: &Resources<'a>, depth: usize, hit_cycle: &mut bool) -> Result { if depth >= MAX_DEPTH { - return false; + return Err(depth_limit()); } let mut ops = UntypedIter::new(data); while let Some(op) = ops.next() { @@ -393,143 +462,217 @@ impl CmykScan { let last = op.operands().last().cloned(); let name = || last.clone().and_then(Object::into_name); match operator { - b"k" | b"K" => return true, + b"k" | b"K" => return Ok(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; + if let Some(n) = name() + && is_device_cmyk(&Object::Name(n), res, 0)? + { + return Ok(true); } } b"sh" => { - if name().and_then(|n| res.get_shading(&n)).is_some_and(|sh| Self::shading(sh)) { - return true; + 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) + && self.pattern(p, res, depth, hit_cycle)? { - return true; + 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")); + .and_then(|m| m.get::>(b"G")); if let Some(form) = form - && self.form(&form, res, depth) + && self.form(&form, res, depth, hit_cycle)? { - return true; + 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), - Some(b"Form") => self.form(&xo, res, depth), + Some(b"Image") => Self::image(&xo, res)?, + Some(b"Form") => self.form(&xo, res, depth, hit_cycle)?, _ => false, }; if hit { - return true; + return Ok(true); } } } b"BI" | b"EI" => { if let Some(Object::Stream(img)) = last - && Self::inline_image(&img, res) + && Self::inline_image(&img, res)? { - return true; + return Ok(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) + && let Some((font, font_id)) = find_font_with_id(res, &font_name) && font.get::>(b"Subtype").as_deref() == Some(b"Type3") - && self.type3(&font, res, depth) + && self.type3(&font, font_id, res, depth, hit_cycle)? { - return true; + return Ok(true); } } _ => {} } } - false + Ok(false) } - fn image(img: &Stream<'_>) -> bool { + fn image(img: &Stream<'_>, res: &Resources<'_>) -> Result { if img.dict().get::(b"ImageMask") == Some(true) { - return false; + return Ok(false); + } + match img.dict().get::>(b"ColorSpace") { + Some(cs) => is_device_cmyk(&cs, res, 0), + None => Ok(false), } - img.dict().get::>(b"ColorSpace").is_some_and(|cs| is_device_cmyk(&cs)) } - fn inline_image(img: &Stream<'_>, res: &Resources<'_>) -> bool { + fn inline_image(img: &Stream<'_>, res: &Resources<'_>) -> Result { if img.dict().get::(b"IM") == Some(true) || img.dict().get::(b"ImageMask") == Some(true) { - return false; + return Ok(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)) + match cs { + Some(cs) => is_device_cmyk(&cs, res, 0), + None => Ok(false), + } } - fn shading(sh: Object<'_>) -> bool { + fn shading(sh: Object<'_>, res: &Resources<'_>) -> Result { let dict = match sh { Object::Dict(d) => d, Object::Stream(s) => s.dict().clone(), - _ => return false, + _ => return Ok(false), }; - dict.get::>(b"ColorSpace").is_some_and(|cs| is_device_cmyk(&cs)) + match dict.get::>(b"ColorSpace") { + Some(cs) => is_device_cmyk(&cs, res, 0), + None => Ok(false), + } } - fn pattern(&mut self, pat: Object<'_>, res: &Resources<'_>, depth: usize) -> bool { + fn pattern(&mut self, pat: Object<'a>, res: &Resources<'a>, depth: usize, hit_cycle: &mut bool) -> Result { match pat { - Object::Dict(d) => d.get::>(b"Shading").is_some_and(Self::shading), - Object::Stream(s) => self.form(&s, res, depth), - _ => false, + 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 form(&mut self, form: &Stream<'_>, parent: &Resources<'_>, depth: usize) -> bool { - if depth >= MAX_DEPTH || !self.seen_streams.insert(form.obj_id()) { - return false; + fn scan_stream(&mut self, stream: &Stream<'a>, res: Resources<'a>, depth: usize, hit_cycle: &mut bool, check_group: bool) -> Result { + let id = stream.obj_id(); + if self.active_streams.contains(&id) { + *hit_cycle = true; + return Ok(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; + return Err(depth_limit()); } - if let Some(id) = font.obj_id() - && !self.seen_fonts.insert(id) + 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 false; + return Ok(true); } - let res = match font.get::>(b"Resources") { - Some(own) => Resources::from_parent(own, parent.clone()), - None => parent.clone(), + 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); }; - 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; + 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; + } + } } } - false + 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) } } @@ -628,24 +771,38 @@ mod tests { #[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 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] @@ -675,25 +832,35 @@ mod tests { ) .is_err() ); - assert!(!uses_device_cmyk(b"not a pdf")); + assert!(uses_device_cmyk(b"not a pdf").is_err()); } #[test] fn uses_device_cmyk_distinguishes_pure_rgb_from_cmyk() { - let make = |content: &[u8]| { + let make = |cs_dict: &str, content: &[u8]| { build_pdf( &[ b"<>".to_vec(), 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"))); - 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"))); + 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] @@ -723,7 +890,7 @@ mod tests { ], "", ); - assert!(uses_device_cmyk(&pdf_bytes), "Type3 glyph CMYK usage should be detected"); + 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(); @@ -746,4 +913,71 @@ mod tests { 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" + ); + } } diff --git a/crates/pdf/src/print_tests.rs b/crates/pdf/src/print_tests.rs index 456c13d9c..c7e4875a3 100644 --- a/crates/pdf/src/print_tests.rs +++ b/crates/pdf/src/print_tests.rs @@ -477,3 +477,27 @@ fn rgb_blend_document_attaches_default_cmyk_only_when_cmyk_is_present() { 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); +} From 378c8c7ba844cf75f561c50ac6ea756f33628a3f Mon Sep 17 00:00:00 2001 From: AlPaka1234 <356241429+AlPaka1234@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:24:13 +0100 Subject: [PATCH 3/6] PDF export: bound CmykScan work on self-referencing Form graphs (#2743) --- crates/pdf/src/default_cmyk.rs | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/pdf/src/default_cmyk.rs b/crates/pdf/src/default_cmyk.rs index 17c64ca7d..64e2ebbb1 100644 --- a/crates/pdf/src/default_cmyk.rs +++ b/crates/pdf/src/default_cmyk.rs @@ -25,6 +25,9 @@ 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()) } @@ -37,6 +40,10 @@ 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`. /// @@ -428,6 +435,7 @@ struct CmykScan<'a> { active_fonts: HashSet, completed_false_streams: HashSet>, completed_false_fonts: HashSet>, + scan_calls: usize, } impl<'a> CmykScan<'a> { @@ -574,6 +582,10 @@ impl<'a> CmykScan<'a> { } 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; @@ -980,4 +992,38 @@ mod tests { "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()); + } } From 11bd7d9c8d8f7af05e28f38ef363e570ee164852 Mon Sep 17 00:00:00 2001 From: FredApps Date: Sat, 14 Nov 2026 13:27:46 +0100 Subject: [PATCH 4/6] 2743: correct integrated imports and CI formatting Remove duplicate flate2 imports introduced during integration and apply the three exact rustfmt corrections reported in main migration files. Hosted CI will validate per owner direction. --- crates/idml/src/export/item.rs | 4 +--- crates/pdf/src/default_cmyk.rs | 2 -- crates/textimport/src/docx.rs | 4 +--- crates/textimport/src/docx/tests.rs | 3 ++- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/crates/idml/src/export/item.rs b/crates/idml/src/export/item.rs index bfcc0619c..fd5731133 100644 --- a/crates/idml/src/export/item.rs +++ b/crates/idml/src/export/item.rs @@ -1,7 +1,5 @@ use super::common::{affine_s, bool_s, pct, uid, with_props}; -use super::{ - Affine, ColumnsKind, Content, El, Ex, Item, Node, OBJECT_BUILTINS, PathData, Shape, TextFrameOptions, base64_encode, names, num, pt, -}; +use super::{Affine, ColumnsKind, Content, El, Ex, Item, Node, OBJECT_BUILTINS, PathData, Shape, TextFrameOptions, base64_encode, names, num, pt}; impl<'a> Ex<'a> { pub(super) fn item_el(&mut self, it: &Item, xf: Affine) -> El { diff --git a/crates/pdf/src/default_cmyk.rs b/crates/pdf/src/default_cmyk.rs index 2b2d6f981..972eae853 100644 --- a/crates/pdf/src/default_cmyk.rs +++ b/crates/pdf/src/default_cmyk.rs @@ -8,8 +8,6 @@ use std::collections::{BTreeMap, HashSet}; use std::io::Write; -use flate2::Compression; -use flate2::write::ZlibEncoder; use craft_pdf::syntax::Pdf; use craft_pdf::syntax::content::UntypedIter; use craft_pdf::syntax::object::{Dict, Name, Object, ObjectIdentifier, Stream}; diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index b59426f25..b7bd274df 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -6,9 +6,7 @@ use std::collections::{HashMap, HashSet}; use craft_wordmodel::Block; use craft_wordmodel::numbering::LevelSuffix; use craft_wordmodel::para::{InlineObject, NoteKind, Paragraph}; -use craft_wordmodel::props::{ - Align as WordAlign, CharProps as WordCharProps, NumRef, ParaProps as WordParaProps, Underline, VertAlign, -}; +use craft_wordmodel::props::{Align as WordAlign, CharProps as WordCharProps, NumRef, ParaProps as WordParaProps, Underline, VertAlign}; use craft_wordmodel::section::NumFormat; use craft_wordmodel::styles::{StyleKind, StyleSheet}; use designcraft_doc::story::{BASIC_PARAGRAPH, NO_CHAR_STYLE}; diff --git a/crates/textimport/src/docx/tests.rs b/crates/textimport/src/docx/tests.rs index c7fb3ff28..d49b2a2cc 100644 --- a/crates/textimport/src/docx/tests.rs +++ b/crates/textimport/src/docx/tests.rs @@ -60,7 +60,8 @@ fn docx_parts(parts: &[(&str, &str)]) -> Vec { } if !rels.is_empty() { w.start_file("word/_rels/document.xml.rels", o).unwrap(); - let rels_doc = format!(r#"{}"#, rels.join("")); + let rels_doc = + format!(r#"{}"#, rels.join("")); w.write_all(rels_doc.as_bytes()).unwrap(); } w.finish().unwrap().into_inner() From 447d4749bc415418f239e9a2506babcf93413166 Mon Sep 17 00:00:00 2001 From: FredApps Date: Sat, 14 Nov 2026 14:13:12 +0100 Subject: [PATCH 5/6] 2743: collapse image tone guard for strict clippy --- crates/images/src/tone.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/images/src/tone.rs b/crates/images/src/tone.rs index b5db792bb..0ec3f4da8 100644 --- a/crates/images/src/tone.rs +++ b/crates/images/src/tone.rs @@ -13,10 +13,10 @@ pub enum Tone { /// The [`Tone`] of an image file, `None` when a swatch doesn't colour it. pub fn tone(bytes: &[u8]) -> Option { - if let Some(info) = craft_image_codecs::inspect_raster(bytes).ok().flatten() { - if info.channels > 1 { - return None; - } + if let Some(info) = craft_image_codecs::inspect_raster(bytes).ok().flatten() + && info.channels > 1 + { + return None; } match craft_image_codecs::classify_tone(bytes)? { craft_image_codecs::ToneClass::Bitmap => Some(Tone::Bitmap), From cef0612cce47dd7c1325ca8ed5d273f7028e6a84 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:28:46 -0500 Subject: [PATCH 6/6] Pin craft-psd to storytold/craft-libs (#2791) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93af4350a..9d0456ece 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 aa498ab1e..66ab17b25 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" }