PDF/X-1a and X-3: flattening keeps a CMYK document's inks where it rasterizes (#2160)

Exporting a CMYK document to PDF/X-1a or PDF/X-3 flattens transparency
first. Where the flattener has no single colour (an image with see-through
pixels over a flat fill, gradients, masks, raster effects) it renders the
area into an image, and that image was always an RGB PNG. The PDF writer
then separated it back to CMYK, so CMYK art behind the image came back with
other inks: a C100 M6 Y41 K3 panel printed C75 M14 Y35 K5 under a
transparent logo, with a hard edge against the rest of the panel, and K
moved into C, M and Y.

For files without transparency the flattener's images are opaque and
clipped to their regions anyway, so in a CMYK document it now draws them
as ink amounts over the paper (Renderer::render_region_inks) and stores a
CMYK TIFF, which the PDF writer keeps as DeviceCMYK samples. An area too
large for a CMYK image falls back to the PNG it made before. In-app
Flatten Transparency and other flattening, whose images keep alpha, are
unchanged.

Test: tests_pdfx::pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document
exports a CMYK panel under a half see-through PNG to PDF/X-1a, opens the
file again and checks the inks behind the see-through half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brian Torres
2026-10-22 12:32:05 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 5a258e4c73
commit 945fa338d0
4 changed files with 82 additions and 14 deletions
@@ -89,3 +89,51 @@ fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() {
assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}");
}
}
/// The ink amounts (0..1) `doc` shows at `p` in its images (the topmost image there).
fn image_inks_at(doc: &vectorcraft_doc::Document, p: vectorcraft_geom::Point) -> Option<[f32; 4]> {
fn visit(n: &vectorcraft_doc::Node, out: &mut Vec<vectorcraft_doc::ImageObject>) {
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
out.push((**im).clone());
}
for c in n.children().into_iter().flatten() {
visit(c, out);
}
}
let mut images = vec![];
for l in &doc.layers {
visit(l, &mut images);
}
images.iter().rev().find_map(|im| {
let q = im.xf.inverse() * p;
if q.x < 0.0 || q.y < 0.0 || q.x >= im.width as f64 || q.y >= im.height as f64 {
return None;
}
let ink = doc.images.get(&im.key)?.sample_cmyk_object(q.x, q.y, (im.width, im.height), 1)?;
Some(ink.map(|v| v as f32 / 255.0))
})
}
#[test]
fn pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document() {
// #2160: a CMYK panel under a PNG with see-through pixels. The flattener rasterizes where the
// image reaches; the panel there keeps its ink numbers instead of going through RGB.
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "colorMode": "cmyk"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 180, "height": 80})).unwrap();
let teal = [1.0, 0.06, 0.41, 0.03];
s.execute("paint.setFill", &json!({"color": {"c": teal[0], "m": teal[1], "y": teal[2], "k": teal[3]}})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let mut png = vec![];
image::RgbaImage::from_fn(40, 40, |x, _| image::Rgba([0, 120, 255, if x < 20 { 0 } else { 255 }]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
s.execute("file.place", &json!({"name": "logos.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [100, 50]})).unwrap();
let v = s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})).unwrap();
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
let back = vectorcraft_pdf::import(&bytes).unwrap();
// Behind the image's see-through half: the panel's own inks, K included.
let ink = image_inks_at(&back, vectorcraft_geom::Point::new(88.0, 50.0)).expect("a CMYK image behind the see-through pixels");
assert!(ink.iter().zip(teal).all(|(a, b)| (a - b).abs() <= 1.5 / 255.0), "{ink:?} is not {teal:?}");
}
+28 -10
View File
@@ -11,7 +11,8 @@
//! effects reach there is no single colour: those regions are rendered into one image per group,
//! clipped to them with Clip Complex Regions (else the image is a rectangle that takes in the
//! regions it overlaps). The raster/vector balance rasterizes whole groups that split into more
//! regions than it allows (all of them at 0).
//! regions than it allows (all of them at 0). For files without transparency (PDF/X-1a, PDF/X-3)
//! a CMYK document's images are opaque CMYK: its colours keep their inks there too.
//!
//! Edit → Transparency Flattener Presets: the built-in presets plus the user's, saved with the
//! preferences ([`crate::Prefs::flattener_presets`]); a `preset` named anywhere options are taken
@@ -28,6 +29,7 @@ use serde_json::{Value, json};
use vectorcraft_color::blend::{cmyk_planes, composite, planes_cmyk};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
use vectorcraft_doc::cmyk::Inks;
use vectorcraft_doc::{Appearance, ColorMode, Document, Effect, ImageBlob, ImageObject, Knockout, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
use vectorcraft_pathops as po;
@@ -132,8 +134,8 @@ pub struct FlattenOptions {
pub preserve_overprints: bool,
/// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with
/// see-through pixels (these keys, [`see_through_images`]) count as transparency, and
/// rasterized areas clipped to their regions are opaque. `None` for ordinary flattening. Not a
/// preset option.
/// rasterized areas clipped to their regions are opaque (in a CMYK document, images of ink
/// amounts). `None` for ordinary flattening. Not a preset option.
#[serde(skip)]
pub no_transparency: Option<BTreeSet<String>>,
}
@@ -805,7 +807,7 @@ struct RegionFill {
/// A rendered image of complex regions, clipped to them or not.
struct Raster {
png: Vec<u8>,
blob: ImageBlob,
width: u32,
height: u32,
xf: Affine,
@@ -945,7 +947,9 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool
/// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out
/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and
/// its `clip` reach a pixel further. Fails when the image can't be encoded.
/// its `clip` reach a pixel further. An opaque image in a CMYK document holds the art's ink
/// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs.
/// Fails when the image can't be encoded.
fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option<PathData>, o: &FlattenOptions) -> Result<Raster> {
let mut scale = ppi / 72.0;
scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt());
@@ -958,11 +962,26 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
rect.x0 + (rect.width() * scale).ceil().max(1.0) / scale,
rect.y0 + (rect.height() * scale).ceil().max(1.0) / scale,
);
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let tmp = isolated_doc(doc, art.to_vec());
let mut r = vectorcraft_render::Renderer::new();
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
let no_alpha = o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha;
if no_alpha && doc.color_mode == ColorMode::Cmyk {
// Drawn as inks over the paper: as screen colours, the file would separate them again,
// and a CMYK colour comes back with other inks (K moves into C, M and Y).
let opts = vectorcraft_render::RenderOptions { background: Some([255; 4]), skip_templates: true, ..Default::default() };
let inks = r.render_region_inks(&tmp, region, scale, &opts);
vectorcraft_doc::cancel::check()?;
let (width, height) = vectorcraft_render::region_pixels(region, scale);
// Past the size of a CMYK image: drawn in screen colours, below.
if let Some(blob) = Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)) {
return Ok(Raster { blob, width, height, xf, clip });
}
}
let mut img = r.render_region(&tmp, region, scale, !o.preserve_alpha);
// The drawing stops early when cancelled: this image is not whole.
vectorcraft_doc::cancel::check()?;
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
if no_alpha {
// Without transparency the clip alone shapes the image: it is opaque, over white (its
// edge pixels too).
for p in img.pixels.as_chunks_mut::<4>().0 {
@@ -1002,9 +1021,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
}
}
}
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let png = img.to_png().map_err(|e| EngineError::Other(format!("Flatten Transparency: {e}")))?;
Ok(Raster { png, width: img.width, height: img.height, xf, clip })
Ok(Raster { blob: ImageBlob::new("image/png", png), width: img.width, height: img.height, xf, clip })
}
/// `n` at full opacity with Normal blending throughout (objects, fills and strokes).
@@ -1115,7 +1133,7 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
/// An image of `r` (in a clip group when it is clipped).
fn raster_node(d: &mut Document, r: Raster) -> Node {
let key = unique_key(d, "flattened");
d.images.insert(key.clone(), ImageBlob::new("image/png", r.png));
d.images.insert(key.clone(), r.blob);
let image = Node::new(
d.alloc_id(),
NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })),