mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
5a258e4c73
commit
945fa338d0
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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() })),
|
||||
|
||||
Reference in New Issue
Block a user