From be56ca2836134d81305434cb87abc1e0cda55d34 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:18:33 +0000 Subject: [PATCH] Keep CMYK conversion inside PDF vector pattern tiles Fixes #426 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/draw.rs | 10 ++++-- crates/pdf/src/text.rs | 2 +- crates/pdf/tests/pattern_cmyk.rs | 60 ++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 crates/pdf/tests/pattern_cmyk.rs diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs index 8a1147d4..99f4e390 100644 --- a/crates/pdf/src/draw.rs +++ b/crates/pdf/src/draw.rs @@ -35,6 +35,8 @@ pub(crate) struct Exporter<'a> { pub links: Vec<(Rect, String)>, /// Above zero while drawing into a soft mask (nothing there is visible or clickable). pub(crate) masking: usize, + /// Above zero while drawing a pattern tile (links and backdrops belong to the page). + pub(crate) tiling: usize, /// The page's frame and the bleed its fills and clip reach out to. pub bleed_root: Option<(NodeId, f64)>, pub(crate) fonts: HashMap>, @@ -215,6 +217,7 @@ impl<'a> Exporter<'a> { page_ids: Vec::new(), links: Vec::new(), masking: 0, + tiling: 0, bleed_root: None, fonts: HashMap::new(), images: HashMap::new(), @@ -246,7 +249,8 @@ impl<'a> Exporter<'a> { let op = n.opacity.clamp(0.0, 1.0); // Background blur and glass: an image drawn from what lies under the node, under its // content (nothing in a soft mask is seen). - let back = if !as_mask && self.masking == 0 && figcraft_render::backdrop::has_effects(n) { self.backdrop(id) } else { None }; + let back = + if !as_mask && self.masking == 0 && self.tiling == 0 && figcraft_render::backdrop::has_effects(n) { self.backdrop(id) } else { None }; if fx.iter().any(|k| matches!(k, EffectKind::LayerBlur { .. } | EffectKind::Noise(_) | EffectKind::Texture(_))) { // Effects that change every pixel: the renderer draws the whole node. if let Some(b) = &back { @@ -521,14 +525,14 @@ impl<'a> Exporter<'a> { let mut sb = s.stream_builder(); let mut ps = sb.surface(); // Links and backdrops belong to the page, not to a tile. - self.masking += 1; + self.tiling += 1; let _ = figcraft_render::pattern::nested(|| { for (tx, ty) in &cell.tiles { self.node(&mut ps, src.id, Affine::translate((*tx, *ty)) * Affine::scale(cell.scale) * unrotated, 0, false); } Some(()) }); - self.masking -= 1; + self.tiling -= 1; ps.finish(); sb.finish() }; diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 87a14301..c425404d 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -41,7 +41,7 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine if !affine_ok(gxf) { return; } - if ex.masking == 0 { + if ex.masking == 0 && ex.tiling == 0 { record_links(ex, &layout, gxf); } let (w, h) = (n.width, n.height); diff --git a/crates/pdf/tests/pattern_cmyk.rs b/crates/pdf/tests/pattern_cmyk.rs new file mode 100644 index 00000000..1f98fe46 --- /dev/null +++ b/crates/pdf/tests/pattern_cmyk.rs @@ -0,0 +1,60 @@ +//! CMYK export converts the colours inside vector pattern tiles (#426). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, NodeKind, PrintSettings}; +use figcraft_pdf::{PdfOptions, export_pdf}; +use std::io::Read; + +/// Every zlib stream of `pdf`, inflated and joined. +fn streams(pdf: &[u8]) -> String { + let mut out = String::new(); + let mut rest = pdf; + while let Some(i) = rest.windows(7).position(|w| w == b"stream\n") { + rest = &rest[i + 7..]; + let end = rest.windows(9).position(|w| w == b"endstream").unwrap(); + let mut bytes = Vec::new(); + if flate2::read::ZlibDecoder::new(&rest[..end]).read_to_end(&mut bytes).is_ok() { + out.push_str(&String::from_utf8_lossy(&bytes)); + out.push('\n'); + } + rest = &rest[end + 9..]; + } + out +} + +fn export(pattern: bool, cmyk: bool) -> (String, String) { + let mut doc = Document::new("fixture"); + let page = doc.first_page().unwrap(); + let source = doc.create(page, NodeKind::Rectangle, "Source", 40.0, 0.0, 10.0, 10.0).unwrap(); + let green = Paint::solid(Color::from_hex("00FF00").unwrap()); + doc.get_mut(source).unwrap().fills = vec![green.clone()]; + let target = doc.create(page, NodeKind::Rectangle, "Target", 0.0, 0.0, 20.0, 20.0).unwrap(); + doc.get_mut(target).unwrap().fills = vec![if pattern { Paint::pattern(source.0) } else { green }]; + let opts = PdfOptions { print: PrintSettings { cmyk, ..Default::default() }, ..Default::default() }; + let pdf = export_pdf(&doc, &[target], &opts).unwrap(); + (String::from_utf8_lossy(&pdf).into_owned(), streams(&pdf)) +} + +fn has_rgb_op(content: &str) -> bool { + content.split_whitespace().any(|op| op == "rg" || op == "RG") +} + +#[test] +fn pattern_cmyk_controls() { + let (_, solid) = export(false, true); + assert!(solid.contains("1 0 1 0 k"), "{solid}"); + assert!(!has_rgb_op(&solid), "{solid}"); + let (pdf, pattern) = export(true, false); + assert!(pdf.contains("/PatternType 1")); + assert!(pattern.contains("0 1 0 rg"), "{pattern}"); +} + +#[test] +fn pattern_colors_follow_cmyk_setting() { + let (pdf, content) = export(true, true); + assert!(pdf.contains("/PatternType 1")); + assert!(!has_rgb_op(&content), "RGB operator remains in CMYK pattern: {content}"); + assert!(content.contains("1 0 1 0 k"), "green must convert to CMYK: {content}"); +}