diff --git a/ROADMAP.md b/ROADMAP.md index 844fef4..f38a51b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,7 +49,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) — stored; compositing is still RGB on screen and in PDF. +- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space; on-screen compositing is still RGB. - 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. diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 6bc6ba0..3717919 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -247,11 +247,18 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { let doc: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0); let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; let mut r = r; + // Transparency Blend Space: the page group of pages with transparency. + let spreads = designcraft_pdf::sheet_spreads(doc, &opts); + let blended: Vec = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect(); + if blended.iter().any(|b| *b) { + match designcraft_pdf::add_blend_space(&r.bytes, &blended, doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk) { + Some(b) => r.bytes = b, + None => r.warnings.push("the transparency blend space couldn't be set".into()), + } + } // Page transitions (each spread's first page holds them). - let trans: Vec> = designcraft_pdf::sheet_spreads(doc, &opts) - .into_iter() - .map(|si| doc.spreads.get(si).and_then(|sp| sp.pages.first()).and_then(|pg| pg.transition)) - .collect(); + let trans: Vec> = + spreads.into_iter().map(|si| doc.spreads.get(si).and_then(|sp| sp.pages.first()).and_then(|pg| pg.transition)).collect(); if trans.iter().any(Option::is_some) { match designcraft_pdf::add_transitions(&r.bytes, &trans) { Some(b) => r.bytes = b, @@ -305,6 +312,10 @@ mod text_tests { // The document itself is untouched. assert!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).is_some()); assert!(s.execute("file.exportPdf", &json!({"flatten": 5})).is_err()); + // Unflattened, the page blends in the document's blend space (CMYK for print). + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + let text = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string(); + assert_eq!(text.matches("/S/Transparency/CS/DeviceCMYK").count(), 1, "only the page with transparency"); } #[test] diff --git a/crates/engine/src/cmd/transitions.rs b/crates/engine/src/cmd/transitions.rs index 6cbbdce..4d35e05 100644 --- a/crates/engine/src/cmd/transitions.rs +++ b/crates/engine/src/cmd/transitions.rs @@ -99,6 +99,17 @@ mod tests { let r = s.execute("file.exportPdf", &json!({"spreads": true})).unwrap(); let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); assert_eq!(String::from_utf8_lossy(&bytes).matches("/Trans<<").count(), 1); + // With transparency too, the page keeps both its group and its transition. + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone(); + s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap(); + let r = s.execute("file.exportPdf", &json!({"pages": [1]})).unwrap(); + let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(1)); + s.execute("page.transition", &json!({"pages": [1], "kind": "fade"})).unwrap(); + let r = s.execute("file.exportPdf", &json!({"pages": [1]})).unwrap(); + let text = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string(); + let last = &text[text.rfind("/Type/Page/").unwrap()..]; + assert!(last.contains("/S/Fade") && last[..last.find("endobj").unwrap()].contains("/S/Transparency"), "{last}"); s.execute("page.transition", &json!({"all": true, "kind": "none"})).unwrap(); assert!(s.execute("page.transitions", &json!({})).unwrap().as_array().unwrap().is_empty()); } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index d0ff6df..e0c0ded 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -34,7 +34,7 @@ mod pdfx; mod transitions; pub use export::{BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs, sheet_spreads}; pub use pdfx::{check_pdfx4, make_pdfx4}; -pub use transitions::add_transitions; +pub use transitions::{add_blend_space, add_transitions}; /// PDF standard to target. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/pdf/src/transitions.rs b/crates/pdf/src/transitions.rs index e774074..d8c7c45 100644 --- a/crates/pdf/src/transitions.rs +++ b/crates/pdf/src/transitions.rs @@ -44,8 +44,9 @@ pub(crate) fn int_after(s: &str, key: &str) -> Option { /// Byte range of object `id`'s body (between `obj` and `endobj`). pub(crate) fn object(pdf: &[u8], id: usize) -> Option> { + // The latest copy (incremental updates re-state objects further on). let head = format!("\n{id} 0 obj"); - let start = find(pdf, head.as_bytes(), 0)? + head.len(); + let start = rfind(pdf, head.as_bytes())? + head.len(); let end = find(pdf, b"endobj", start)?; Some(start..end) } @@ -53,7 +54,19 @@ pub(crate) fn object(pdf: &[u8], id: usize) -> Option> { /// Add transitions to the pages of `pdf` (index = PDF page). `None` when the file's structure /// isn't the simple kind this understands (classic xref table, a flat page tree). pub fn add_transitions(pdf: &[u8], trans: &[Option]) -> Option> { - if trans.iter().all(Option::is_none) { + add_page_entries(pdf, &trans.iter().map(|t| t.as_ref().map(trans_dict)).collect::>()) +} + +/// Transparency blend space: pages with transparency get a page group in DeviceCMYK or DeviceRGB +/// (`pages[i]` true = PDF page i). +pub fn add_blend_space(pdf: &[u8], pages: &[bool], cmyk: bool) -> Option> { + let cs = if cmyk { "DeviceCMYK" } else { "DeviceRGB" }; + add_page_entries(pdf, &pages.iter().map(|on| on.then(|| format!("/Group<>"))).collect::>()) +} + +/// Append dictionary entries to pages (index = PDF page) as an incremental update. +pub fn add_page_entries(pdf: &[u8], extra: &[Option]) -> Option> { + if extra.iter().all(Option::is_none) { return Some(pdf.to_vec()); } let t_at = rfind(pdf, b"trailer")?; @@ -76,14 +89,14 @@ pub fn add_transitions(pdf: &[u8], trans: &[Option]) -> Option = Vec::new(); - for (i, t) in trans.iter().enumerate() { + for (i, t) in extra.iter().enumerate() { let (Some(t), Some(&kid)) = (t, kids.get(i)) else { continue }; let body = String::from_utf8_lossy(&pdf[object(pdf, kid)?]).to_string(); if !body.contains("/Type/Page") && !body.contains("/Type /Page") { return None; } let close = body.rfind(">>")?; - let new_body = format!("{}{}{}", &body[..close], trans_dict(t), &body[close..]); + let new_body = format!("{}{}{}", &body[..close], t, &body[close..]); entries.push((kid, out.len())); out.extend_from_slice(format!("{kid} 0 obj{new_body}endobj\n").as_bytes()); }