Merge pull request #3524 from odgrim/fix/2160-x1a-flatten-keeps-cmyk-inks

PDF/X-1a and X-3: flattening keeps a CMYK document's inks where it rasterizes (#2160)
This commit is contained in:
BFlat
2026-10-22 12:44:27 +07:00
committed by GitHub
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() })),
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long