mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 18:54:55 +00:00
Merge remote-tracking branch 'origin/main' into fix/ruler-double-click-guide
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -170,6 +170,28 @@ fn transparency_is_flattened_with_the_flattener_preset() {
|
||||
assert!(e.contains("preset"), "{e}");
|
||||
}
|
||||
|
||||
/// A picture cropped by a clipping path, in a clip group at 80%, over a square that only the
|
||||
/// hidden part of the picture covers: the file prints the square, and its image is as large as
|
||||
/// the crop, with the default preset (a rectangle of image) and with High Resolution (an image
|
||||
/// clipped to its regions).
|
||||
#[test]
|
||||
fn flattening_cropped_art_keeps_the_art_under_its_hidden_part() {
|
||||
use super::tests_pdfx::{Art, CROP, cropped, drawn, lost, shown_image_sizes};
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for preset in ["medium", "high"] {
|
||||
let (mut s, _) = cropped("rgb", 400.0, Art::Image { see_through: false });
|
||||
s.execute("transparency.set", &json!({"opacity": 80})).unwrap();
|
||||
let canvas = drawn(&s.doc().unwrap().doc);
|
||||
let opts = json!({"artboards": [0], "flattenerPreset": preset, "previewFormat": "none", "thumbnails": false});
|
||||
let r = s.execute("document.exportEps", &opts).unwrap();
|
||||
// What the file prints, not the document it carries.
|
||||
let back = vectorcraft_eps::import_with(&b64(&r), false).unwrap().document;
|
||||
assert_eq!(lost(&canvas, &drawn(&back)), 0, "{preset}: art went missing");
|
||||
let sizes = shown_image_sizes(&back);
|
||||
assert!(!sizes.is_empty() && sizes.iter().all(|(w, h)| *w <= CROP[2] + 1.0 && *h <= CROP[3] + 1.0), "{preset}: the images show {sizes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overprints_are_kept_or_discarded() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
|
||||
@@ -186,3 +186,516 @@ fn pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document() {
|
||||
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:?}");
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// The art a clip group crops in [`cropped`].
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum Art {
|
||||
/// A 240 × 160 image, with see-through pixels or without.
|
||||
Image { see_through: bool },
|
||||
/// A rectangle with a gradient fill.
|
||||
Gradient,
|
||||
/// A rectangle with a drop shadow.
|
||||
Shadow,
|
||||
}
|
||||
|
||||
/// Where [`cropped`] puts the cropped art, [x, y, width, height].
|
||||
const FRAME: [f64; 4] = [40.0, 40.0, 240.0, 160.0];
|
||||
/// The part of [`FRAME`] the clipping path of [`cropped`] shows.
|
||||
pub(super) const CROP: [f64; 4] = [50.0, 50.0, 80.0, 60.0];
|
||||
|
||||
/// A `mode` document whose first artboard is `width` wide, with a dark square and, over it, `art`
|
||||
/// filling [`FRAME`] in a clip group that shows [`CROP`] of it. The square lies inside the frame
|
||||
/// and outside the crop: only the hidden part of the art covers it. → the session with the clip
|
||||
/// group selected, and the group's id.
|
||||
pub(super) fn cropped(mode: &str, width: f64, art: Art) -> (Session, u64) {
|
||||
cropped_by(mode, width, art, false)
|
||||
}
|
||||
|
||||
/// [`cropped`], with the art and its clipping path in a clipping layer above the square's layer
|
||||
/// when `layer`. → the session and the clip group's or the layer's id.
|
||||
fn cropped_by(mode: &str, width: f64, art: Art, layer: bool) -> (Session, u64) {
|
||||
let [x, y, w, h] = FRAME;
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": width, "height": 300, "colorMode": mode}));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 180, "y": 90, "width": 60, "height": 60}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#203040"}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
let layer = layer.then(|| run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap());
|
||||
let art = match art {
|
||||
Art::Image { see_through } => {
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_fn(240, 160, |x, y| {
|
||||
image::Rgba([200, (x % 256) as u8, (y % 256) as u8, if see_through && x < 60 { 128 } else { 255 }])
|
||||
})
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let placed =
|
||||
run(&mut s, "file.place", json!({"name": "photo.png", "dataBase64": vectorcraft_format::base64_encode(&png), "rect": FRAME}));
|
||||
placed["ids"][0].clone()
|
||||
}
|
||||
Art::Gradient | Art::Shadow => {
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone();
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
if matches!(art, Art::Gradient) {
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
||||
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}}));
|
||||
} else {
|
||||
run(&mut s, "paint.setFill", json!({"color": "#c04020"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id]}));
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
let [x, y, w, h] = CROP;
|
||||
let clip = run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone();
|
||||
if let Some(layer) = layer {
|
||||
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer}));
|
||||
run(&mut s, "select.set", json!({"ids": [art]}));
|
||||
return (s, layer);
|
||||
}
|
||||
run(&mut s, "select.set", json!({"ids": [art, clip]}));
|
||||
let group = run(&mut s, "object.clippingMask.make", json!({}))["id"].as_u64().unwrap();
|
||||
(s, group)
|
||||
}
|
||||
|
||||
/// `document.exportPdf` with `p`, read back.
|
||||
fn read_back(s: &mut Session, p: &Value) -> vectorcraft_doc::Document {
|
||||
let v = run(s, "document.exportPdf", p.clone());
|
||||
vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// `doc`'s artboards drawn on white at 72 ppi.
|
||||
pub(super) fn drawn(doc: &vectorcraft_doc::Document) -> vectorcraft_render::Rendered {
|
||||
let pages = doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap();
|
||||
vectorcraft_render::Renderer::new().render_region(doc, pages, 1.0, true)
|
||||
}
|
||||
|
||||
/// How many pixels show art in `reference` and are blank in `variant`.
|
||||
pub(super) fn lost(reference: &vectorcraft_render::Rendered, variant: &vectorcraft_render::Rendered) -> usize {
|
||||
assert_eq!((reference.width, reference.height), (variant.width, variant.height));
|
||||
let (reference, variant) = (reference.pixels.as_chunks::<4>().0, variant.pixels.as_chunks::<4>().0);
|
||||
reference.iter().zip(variant).filter(|(r, v)| r[..3].iter().any(|c| *c < 200) && v[..3].iter().all(|c| *c >= 250)).count()
|
||||
}
|
||||
|
||||
/// How many pixels of `a` and `b` differ by more than 24 in a channel.
|
||||
fn differ(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered) -> usize {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
let (a, b) = (a.pixels.as_chunks::<4>().0, b.pixels.as_chunks::<4>().0);
|
||||
a.iter().zip(b).filter(|(p, q)| p[..3].iter().zip(&q[..3]).any(|(x, y)| x.abs_diff(*y) > 24)).count()
|
||||
}
|
||||
|
||||
/// The width and height of what each of `doc`'s images shows: its frame inside the clipping
|
||||
/// paths around it.
|
||||
pub(super) fn shown_image_sizes(doc: &vectorcraft_doc::Document) -> Vec<(f64, f64)> {
|
||||
use vectorcraft_geom::Rect;
|
||||
fn visit(n: &vectorcraft_doc::Node, clip: Option<Rect>, out: &mut Vec<(f64, f64)>) {
|
||||
let within = |r: Rect| clip.map_or(r, |c| c.intersect(r));
|
||||
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||
let frame = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64));
|
||||
let shown = within(frame);
|
||||
out.push((shown.width(), shown.height()));
|
||||
}
|
||||
let clip = if n.clips() { n.visual_bounds().map(within) } else { clip };
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, clip, out);
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
for l in &doc.layers {
|
||||
visit(l, None, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Checks that the PDF/X-1a, PDF/X-3 and PDF 1.3 files of `s` (with `extra` options) show all the
|
||||
/// art the file with transparency shows, and that no image in them shows more than [`CROP`]. Adds
|
||||
/// what is wrong to `wrong`.
|
||||
fn check_flat_files(s: &mut Session, extra: &Value, what: &str, wrong: &mut Vec<String>) {
|
||||
let reference = drawn(&read_back(s, extra));
|
||||
for flat in [json!({"standard": "pdfX1a"}), json!({"standard": "pdfX3"}), json!({"compatibility": "1.3", "preserveEditing": false})] {
|
||||
let mut p = flat.clone();
|
||||
super::pdf::merge(&mut p, extra);
|
||||
let back = read_back(s, &p);
|
||||
let lost = lost(&reference, &drawn(&back));
|
||||
if lost > 0 {
|
||||
wrong.push(format!("{what}, {flat}: {lost} pixels of art went missing"));
|
||||
}
|
||||
let sizes = shown_image_sizes(&back);
|
||||
// An image reaches a pixel past its regions, and a group rasterized whole a point past
|
||||
// its visible bounds.
|
||||
if sizes.is_empty() || sizes.iter().any(|(w, h)| *w > CROP[2] + 3.0 || *h > CROP[3] + 3.0) {
|
||||
wrong.push(format!("{what}, {flat}: the images show {sizes:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Art cropped by a clipping path, in a PDF file without transparency: the part of the art the
|
||||
/// clipping path hides is not rasterized, so art that lies under the hidden part alone still
|
||||
/// shows. Each case is one way the cropped art comes to be flattened.
|
||||
#[test]
|
||||
fn flattening_cropped_art_for_pdfx_keeps_the_art_under_its_hidden_part() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let opaque = Art::Image { see_through: false };
|
||||
type Change = fn(&mut Session, u64);
|
||||
let cases: [(&str, Art, Change); 9] = [
|
||||
("an image with see-through pixels", Art::Image { see_through: true }, |_, _| {}),
|
||||
("a clip group with opacity", opaque, |s, _| {
|
||||
run(s, "transparency.set", json!({"opacity": 80}));
|
||||
}),
|
||||
("a clip group with a blend mode", opaque, |s, _| {
|
||||
run(s, "transparency.set", json!({"blendMode": "multiply"}));
|
||||
}),
|
||||
("a see-through object over the crop", opaque, |s, _| {
|
||||
run(s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 40, "height": 30}));
|
||||
run(s, "paint.setFill", json!({"color": "#ffffff"}));
|
||||
run(s, "transparency.set", json!({"opacity": 60}));
|
||||
}),
|
||||
("a gradient", Art::Gradient, |s, _| {
|
||||
run(s, "transparency.set", json!({"opacity": 80}));
|
||||
}),
|
||||
("a raster effect", Art::Shadow, |_, _| {}),
|
||||
("a clip group inside a clip group", Art::Image { see_through: true }, |s, group| {
|
||||
let outer = run(s, "shape.rectangle", json!({"x": 45, "y": 45, "width": 85, "height": 65}))["id"].clone();
|
||||
run(s, "select.set", json!({"ids": [group, outer]}));
|
||||
run(s, "object.clippingMask.make", json!({}));
|
||||
}),
|
||||
("a clip group inside a group with opacity", opaque, |s, _| {
|
||||
run(s, "object.group", json!({}));
|
||||
run(s, "transparency.set", json!({"opacity": 80}));
|
||||
}),
|
||||
("an even-odd compound clipping path", opaque, |s, group| {
|
||||
run(s, "transparency.set", json!({"opacity": 80}));
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let mut d = (*doc.doc).clone();
|
||||
let clip = d.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[0].id;
|
||||
let mut ring = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(50.0, 50.0, 130.0, 110.0));
|
||||
ring.subpaths.extend(vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(70.0, 65.0, 110.0, 95.0)).subpaths);
|
||||
if let vectorcraft_doc::NodeKind::Path { path, rule, .. } = &mut d.node_mut(clip).unwrap().kind {
|
||||
(*path, *rule) = (ring, vectorcraft_geom::FillRule::EvenOdd);
|
||||
}
|
||||
doc.doc = d.into();
|
||||
}),
|
||||
];
|
||||
let mut wrong = vec![];
|
||||
for mode in ["cmyk", "rgb"] {
|
||||
for (what, art, change) in cases {
|
||||
let (mut s, group) = cropped(mode, 400.0, art);
|
||||
change(&mut s, group);
|
||||
check_flat_files(&mut s, &json!({}), &format!("{mode}: {what}"), &mut wrong);
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// The same with the Medium and Low Resolution presets, and with a balance of 0, which rasterizes
|
||||
/// the whole group of the cropped art over the group's visible bounds.
|
||||
#[test]
|
||||
fn flattening_cropped_art_for_pdfx_keeps_the_art_under_its_hidden_part_with_each_preset() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut wrong = vec![];
|
||||
for extra in [json!({"flattenerPreset": "medium"}), json!({"flattenerPreset": "low"}), json!({"flattener": {"balance": 0}})] {
|
||||
for (art, name) in [(Art::Image { see_through: true }, "image"), (Art::Gradient, "gradient")] {
|
||||
let (mut s, _) = cropped("cmyk", 400.0, art);
|
||||
run(&mut s, "transparency.set", json!({"opacity": 80}));
|
||||
check_flat_files(&mut s, &extra, &format!("{name}, {extra}"), &mut wrong);
|
||||
}
|
||||
}
|
||||
// A cropped rectangle with a solid fill, rasterized whole.
|
||||
let (mut s, group) = cropped("cmyk", 400.0, Art::Gradient);
|
||||
let art = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[1].id.0;
|
||||
run(&mut s, "paint.setFill", json!({"ids": [art], "color": "#c04020"}));
|
||||
run(&mut s, "transparency.set", json!({"ids": [group], "opacity": 80}));
|
||||
check_flat_files(&mut s, &json!({"flattener": {"balance": 0}}), "solid fill, balance 0", &mut wrong);
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// The hidden part of cropped art on one artboard lies over the next artboard: that page keeps
|
||||
/// its art.
|
||||
#[test]
|
||||
fn flattening_cropped_art_for_pdfx_keeps_the_next_page_under_its_hidden_part() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// The crop is on the first artboard, and the square on the second.
|
||||
let (mut s, _) = cropped("cmyk", 150.0, Art::Image { see_through: true });
|
||||
run(&mut s, "artboard.new", json!({"x": 170, "y": 0, "width": 230, "height": 300}));
|
||||
assert_eq!(read_back(&mut s, &json!({"standard": "pdfX1a"})).artboards.len(), 2);
|
||||
let mut wrong = vec![];
|
||||
check_flat_files(&mut s, &json!({}), "two artboards", &mut wrong);
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// Art cropped by a clipping layer, over a square in the layer below: the square keeps showing,
|
||||
/// also with a second square in that layer under both the crop and the hidden part, or with a
|
||||
/// see-through rectangle over both in a layer above.
|
||||
#[test]
|
||||
fn flattening_art_of_a_clipping_layer_for_pdfx_keeps_the_art_under_its_hidden_part() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
type Change = fn(&mut Session, u64);
|
||||
let cases: [(&str, Change); 3] = [
|
||||
("alone", |_, _| {}),
|
||||
("over a square under the crop's edge", |s, _| {
|
||||
let below = s.doc().unwrap().doc.layers[0].id.0;
|
||||
run(s, "layer.setCurrent", json!({"id": below}));
|
||||
run(s, "shape.rectangle", json!({"x": 110, "y": 60, "width": 50, "height": 30}));
|
||||
run(s, "paint.setFill", json!({"color": "#406020"}));
|
||||
}),
|
||||
("under a see-through rectangle over the crop's edge", |s, _| {
|
||||
run(s, "layer.new", json!({}));
|
||||
run(s, "shape.rectangle", json!({"x": 110, "y": 60, "width": 50, "height": 30}));
|
||||
run(s, "paint.setFill", json!({"color": "#406020"}));
|
||||
run(s, "transparency.set", json!({"opacity": 60}));
|
||||
}),
|
||||
];
|
||||
let mut wrong = vec![];
|
||||
for mode in ["cmyk", "rgb"] {
|
||||
for art in [Art::Image { see_through: true }, Art::Gradient] {
|
||||
for (what, change) in cases {
|
||||
let (mut s, layer) = cropped_by(mode, 400.0, art, true);
|
||||
run(&mut s, "transparency.set", json!({"opacity": 80}));
|
||||
change(&mut s, layer);
|
||||
check_flat_files(&mut s, &json!({}), &format!("{mode}: a clipping layer {what}"), &mut wrong);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// Documents in which no clip group hides art export the same bytes as they did when the
|
||||
/// flattener rasterized hidden art too.
|
||||
#[test]
|
||||
fn flat_pdf_files_of_art_that_no_clip_group_hides_are_unchanged() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
use crate::cmd::flatten::with_clips_ignored as before;
|
||||
// Written at one time, so two files differ only by what they draw.
|
||||
let bytes = |s: &mut Session, p: &Value| super::pdf::encode_at("document.exportPdf", &s.doc().unwrap().doc, p, Some(1_700_000_000)).unwrap().0;
|
||||
let files = [
|
||||
json!({"standard": "pdfX1a"}),
|
||||
json!({"standard": "pdfX3"}),
|
||||
json!({"compatibility": "1.3", "preserveEditing": false}),
|
||||
json!({"standard": "pdfX1a", "flattener": {"balance": 0}}),
|
||||
json!({"standard": "pdfX1a", "flattenerPreset": "low"}),
|
||||
];
|
||||
// Transparency without a clip group.
|
||||
let mut plain = session();
|
||||
// A clip group at 80% whose clipping path is as large as its art, over the edge of a square.
|
||||
let mut fitted = Session::new();
|
||||
run(&mut fitted, "file.new", json!({"width": 400, "height": 300, "colorMode": "cmyk"}));
|
||||
run(&mut fitted, "shape.rectangle", json!({"x": 180, "y": 90, "width": 160, "height": 160}));
|
||||
run(&mut fitted, "paint.setFill", json!({"color": "#203040"}));
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_fn(240, 160, |x, y| image::Rgba([200, x as u8, y as u8, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let photo =
|
||||
run(&mut fitted, "file.place", json!({"name": "photo.png", "dataBase64": vectorcraft_format::base64_encode(&png), "rect": FRAME}))["ids"][0]
|
||||
.clone();
|
||||
let [x, y, w, h] = FRAME;
|
||||
let clip = run(&mut fitted, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone();
|
||||
run(&mut fitted, "select.set", json!({"ids": [photo, clip]}));
|
||||
run(&mut fitted, "object.clippingMask.make", json!({}));
|
||||
run(&mut fitted, "transparency.set", json!({"opacity": 80}));
|
||||
// Cropped art that is not flattened: nothing on it is see-through.
|
||||
let (mut opaque, _) = cropped("cmyk", 400.0, Art::Image { see_through: false });
|
||||
for (what, s) in [("no clip group", &mut plain), ("a clipping path as large as its art", &mut fitted), ("opaque cropped art", &mut opaque)] {
|
||||
for p in &files {
|
||||
assert!(bytes(s, p) == before(|| bytes(s, p)), "{what}, {p}");
|
||||
}
|
||||
}
|
||||
// Art that a clip group hides is what changes.
|
||||
let (mut s, _) = cropped("cmyk", 400.0, Art::Image { see_through: true });
|
||||
assert!(bytes(&mut s, &files[0]) != before(|| bytes(&mut s, &files[0])));
|
||||
}
|
||||
|
||||
/// The options of the PDF files without transparency.
|
||||
const FLAT_FILES: [&str; 3] = [r#"{"standard": "pdfX1a"}"#, r#"{"standard": "pdfX3"}"#, r#"{"compatibility": "1.3", "preserveEditing": false}"#];
|
||||
|
||||
/// [`cropped_by`]'s clipping layer of a gradient at 80%, with a rectangle in the layer below that
|
||||
/// crosses the crop's right edge. → the session and the clipping layer's id.
|
||||
fn clipping_layer_over_a_rectangle(mode: &str) -> (Session, u64) {
|
||||
let (mut s, layer) = cropped_by(mode, 400.0, Art::Gradient, true);
|
||||
run(&mut s, "transparency.set", json!({"opacity": 80}));
|
||||
let below = s.doc().unwrap().doc.layers[0].id.0;
|
||||
run(&mut s, "layer.setCurrent", json!({"id": below}));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 100, "y": 60, "width": 120, "height": 40}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#406020"}));
|
||||
(s, layer)
|
||||
}
|
||||
|
||||
/// A clipping layer over a layer that doesn't print, and a clipping layer that doesn't print
|
||||
/// over one that does: the files without transparency print what the file with transparency
|
||||
/// prints, and nothing of the layer that doesn't print.
|
||||
#[test]
|
||||
fn flattening_for_pdfx_leaves_out_the_layers_that_do_not_print() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut wrong = vec![];
|
||||
for mode in ["cmyk", "rgb"] {
|
||||
for clipping_layer_prints in [true, false] {
|
||||
let (mut s, layer) = clipping_layer_over_a_rectangle(mode);
|
||||
let below = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let off = if clipping_layer_prints { below } else { layer };
|
||||
run(&mut s, "layer.setProps", json!({"id": off, "printable": false}));
|
||||
let reference = drawn(&read_back(&mut s, &json!({})));
|
||||
for flat in FLAT_FILES {
|
||||
let back = drawn(&read_back(&mut s, &serde_json::from_str(flat).unwrap()));
|
||||
let (lost, gained) = (lost(&reference, &back), lost(&back, &reference));
|
||||
if lost + gained > 0 {
|
||||
wrong.push(format!("{mode}, clipping layer prints: {clipping_layer_prints}, {flat}: {lost} pixels lost, {gained} gained"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// A clipping layer whose clipping path has a fill: the files without transparency show the art
|
||||
/// over the fill, not the fill over the art.
|
||||
#[test]
|
||||
fn flattening_for_pdfx_keeps_the_art_of_a_clipping_layer_over_its_clipping_path_fill() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut wrong = vec![];
|
||||
for mode in ["cmyk", "rgb"] {
|
||||
let (mut s, layer) = clipping_layer_over_a_rectangle(mode);
|
||||
let clip = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(layer)).unwrap().children().unwrap()[0].id.0;
|
||||
run(&mut s, "paint.setFill", json!({"ids": [clip], "color": "#ffdd00"}));
|
||||
let reference = drawn(&read_back(&mut s, &json!({})));
|
||||
for flat in FLAT_FILES {
|
||||
let back = drawn(&read_back(&mut s, &serde_json::from_str(flat).unwrap()));
|
||||
// PDF/X-1a converts an RGB document's colors to CMYK.
|
||||
let converted = mode == "rgb" && flat.contains("pdfX1a");
|
||||
let (lost, differ) = (lost(&reference, &back), if converted { 0 } else { differ(&reference, &back) });
|
||||
if lost > 0 || differ > 100 {
|
||||
wrong.push(format!("{mode}, {flat}: {lost} pixels of art lost, {differ} pixels differ"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// Transparent art in a clipping layer whose clipping path is area type holding only a space,
|
||||
/// which hides it: the files without transparency are written, without the art.
|
||||
#[test]
|
||||
fn flattening_for_pdfx_removes_transparent_art_that_a_clipping_layer_hides_entirely() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let [x, y, w, h] = FRAME;
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 400, "height": 300, "colorMode": "cmyk"}));
|
||||
let layer = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}));
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
||||
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}}));
|
||||
run(&mut s, "transparency.set", json!({"opacity": 80}));
|
||||
run(&mut s, "text.create", json!({"x": 50, "y": 50, "text": " ", "area": {"width": 80, "height": 60}}));
|
||||
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer}));
|
||||
let reference = drawn(&read_back(&mut s, &json!({})));
|
||||
for flat in FLAT_FILES {
|
||||
let p: Value = serde_json::from_str(flat).unwrap();
|
||||
let back = match s.execute("document.exportPdf", &p) {
|
||||
Ok(v) => vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap(),
|
||||
Err(e) => panic!("{flat}: {e}"),
|
||||
};
|
||||
assert_eq!(lost(&drawn(&back), &reference), 0, "{flat}: the hidden art prints");
|
||||
}
|
||||
}
|
||||
|
||||
/// Object › Flatten Transparency of art in a clipping layer and art in the layer below that
|
||||
/// reaches past the clipping path: when the layer below doesn't print, the clipping layer has
|
||||
/// opacity, or the clipping path has a fill (see-through too, flattened keeping alpha, in a layer
|
||||
/// with opacity or Multiply mode, or
|
||||
/// over a layer with opacity, overlapping art of a layer with opacity or art an opacity mask
|
||||
/// hides), the group stays in the clipping layer, the art below stays in its layer, and the page
|
||||
/// looks the same.
|
||||
#[test]
|
||||
fn flattening_keeps_the_group_in_the_clipping_layer_when_the_layer_below_differs() {
|
||||
let mut wrong = vec![];
|
||||
let cases = [
|
||||
"the layer below doesn't print",
|
||||
"the clipping path has a fill",
|
||||
"the clipping path has a see-through fill, keeping alpha",
|
||||
"the clipping path has a see-through fill, in a layer with opacity",
|
||||
"the clipping path has a see-through fill, in a layer in Multiply mode",
|
||||
"the clipping path has a see-through fill, over a layer with opacity",
|
||||
"the clipping path has a see-through fill, over art that an opacity mask hides",
|
||||
"the clipping path has a see-through fill, over overlapping art of a layer at 50%",
|
||||
"the clipping path has no fill, in a layer with opacity",
|
||||
];
|
||||
for case in cases {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 400, "height": 300, "colorMode": "rgb"}));
|
||||
let below = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let orange = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 200, "height": 150}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff8000"}));
|
||||
let upper = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap();
|
||||
let blue = run(&mut s, "shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 150}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#2040c0"}));
|
||||
run(&mut s, "transparency.set", json!({"opacity": 50}));
|
||||
let clip = run(&mut s, "shape.rectangle", json!({"x": 60, "y": 40, "width": 300, "height": 220}))["id"].clone();
|
||||
run(&mut s, "layer.clippingMask.toggle", json!({"id": upper}));
|
||||
if case.starts_with("the layer below") {
|
||||
run(&mut s, "layer.setProps", json!({"id": below, "printable": false}));
|
||||
} else if !case.contains("no fill") {
|
||||
run(&mut s, "paint.setFill", json!({"ids": [clip], "color": "#88ffcc"}));
|
||||
}
|
||||
let keeping_alpha = case.ends_with("keeping alpha");
|
||||
if case.contains("see-through fill") {
|
||||
run(&mut s, "transparency.set", json!({"ids": [clip], "opacity": 50}));
|
||||
}
|
||||
if case.ends_with("in a layer with opacity") {
|
||||
run(&mut s, "transparency.set", json!({"ids": [upper], "opacity": 80}));
|
||||
}
|
||||
if case.ends_with("over a layer with opacity") {
|
||||
run(&mut s, "transparency.set", json!({"ids": [below], "opacity": 50}));
|
||||
}
|
||||
if case.ends_with("an opacity mask hides") {
|
||||
run(&mut s, "layer.setCurrent", json!({"id": below}));
|
||||
run(&mut s, "select.set", json!({"ids": [orange]}));
|
||||
let group = run(&mut s, "object.group", json!({}))["id"].clone();
|
||||
let mask = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 200, "height": 150}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#000000"}));
|
||||
run(&mut s, "select.set", json!({"ids": [group, mask]}));
|
||||
run(&mut s, "transparency.makeOpacityMask", json!({}));
|
||||
}
|
||||
if case.ends_with("Multiply mode") {
|
||||
run(&mut s, "transparency.set", json!({"ids": [upper], "blendMode": "multiply"}));
|
||||
}
|
||||
let mut art = vec![orange.clone()];
|
||||
if case.ends_with("a layer at 50%") {
|
||||
run(&mut s, "layer.setCurrent", json!({"id": below}));
|
||||
art.push(run(&mut s, "shape.rectangle", json!({"x": 80, "y": 60, "width": 120, "height": 120}))["id"].clone());
|
||||
run(&mut s, "paint.setFill", json!({"color": "#20a040"}));
|
||||
run(&mut s, "transparency.set", json!({"ids": [below], "opacity": 50}));
|
||||
}
|
||||
art.push(blue.clone());
|
||||
// The page as it prints.
|
||||
let printed = |d: &vectorcraft_doc::Document| {
|
||||
let mut d = d.clone();
|
||||
if case.starts_with("the layer below") {
|
||||
let mut layer = (*d.layers[0]).clone();
|
||||
layer.visible = false;
|
||||
d.layers[0] = layer.into();
|
||||
}
|
||||
drawn(&d)
|
||||
};
|
||||
let before = printed(&s.doc().unwrap().doc);
|
||||
run(&mut s, "select.set", json!({"ids": art}));
|
||||
run(&mut s, "object.flattenTransparency", json!({"preserveAlpha": keeping_alpha}));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let layer_of = |id: &Value| id.as_u64().and_then(|id| d.ancestry(vectorcraft_doc::NodeId(id))).map(|a| a[0].0);
|
||||
if layer_of(&orange) != Some(below) {
|
||||
wrong.push(format!("{case}: the art below is in layer {:?}", layer_of(&orange)));
|
||||
}
|
||||
let upper_art = d.node(vectorcraft_doc::NodeId(upper)).and_then(|l| l.children()).map_or(0, Vec::len);
|
||||
if d.node(vectorcraft_doc::NodeId(blue.as_u64().unwrap())).is_some() || upper_art != 2 {
|
||||
wrong.push(format!("{case}: the clipping layer holds {upper_art} objects"));
|
||||
}
|
||||
let after = printed(d);
|
||||
// Flat regions differ from see-through art along their antialiased edges.
|
||||
let (lost, differ) = (lost(&before, &after), differ(&before, &after));
|
||||
if lost > 0 || differ > 1200 {
|
||||
wrong.push(format!("{case}: {lost} pixels of art lost, {differ} pixels differ"));
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ mod presets;
|
||||
mod report;
|
||||
|
||||
pub(crate) use analysis::{outlined_text, see_through_images};
|
||||
#[cfg(test)]
|
||||
pub(crate) use apply::with_every_region;
|
||||
pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images};
|
||||
#[cfg(test)]
|
||||
pub(crate) use apply::{with_clips_ignored, with_every_region};
|
||||
pub use commands::specs;
|
||||
pub use options::FlattenOptions;
|
||||
pub use presets::{FlattenerPreset, PRESET_EXTS, PRESET_FORMAT};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::analysis::{Scratch, outlined_text, overlapping, reach};
|
||||
use super::analysis::{Scratch, has_effects, masked, outlined_text, overlapping, reach};
|
||||
use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE};
|
||||
use super::options::FlattenOptions;
|
||||
use super::{
|
||||
@@ -107,27 +107,83 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render:
|
||||
let art: Vec<Node> = baked.layers[0].children().into_iter().flatten().map(|a| (**a).clone()).collect();
|
||||
let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() };
|
||||
let plain: Vec<Node> = art.iter().map(|n| sc.plain(n)).collect();
|
||||
let reaches: Vec<Option<Rect>> = plain.iter().map(reach).collect();
|
||||
// Each object as its clip groups and clipping layers show it.
|
||||
let clipped: Vec<Node> = plain.iter().zip(roots).map(|(n, id)| clipped_by_ancestors(src, *id, n, None)).collect();
|
||||
let reaches: Vec<Option<Rect>> = plain.iter().zip(&clipped).map(|(n, c)| reach(n).zip(reach(c)).map(|(a, b)| a.intersect(b))).collect();
|
||||
let see_through: Vec<bool> = plain.iter().map(|n| o.shows_transparency(&baked, n) || o.draws_see_through_image(n)).collect();
|
||||
let cmyk = src.color_mode == ColorMode::Cmyk;
|
||||
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
|
||||
// Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing).
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
for g in overlapping(&reaches)? {
|
||||
let mut queue = overlapping(&reaches)?;
|
||||
queue.reverse();
|
||||
while let Some(g) = queue.pop() {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
// A group that stays inside the clip groups and clipping layers of its topmost object
|
||||
// replaces only the objects inside them, over the others, which are flattened on their own.
|
||||
let (g, under) = match inside_and_under(src, roots, &g) {
|
||||
Some((inside, under)) => {
|
||||
queue.push(under.clone());
|
||||
(inside, under)
|
||||
}
|
||||
None => (g, vec![]),
|
||||
};
|
||||
// Composited over white, the group covers what it is drawn over, so it is composited over
|
||||
// the fills of the clipping paths around it and the objects under it in the same kind of
|
||||
// layer. Composited over nothing, it keeps its alpha and they show through.
|
||||
let backdrop = !o.preserve_alpha;
|
||||
let state = g.last().map(|&i| layer_state(src, roots[i]));
|
||||
let top = g.last().map(|&i| roots[i]);
|
||||
// A blend mode of a layer or group around the group alone would blend the art under it
|
||||
// twice.
|
||||
let blends = |i: usize| top.is_some_and(|t| own_layers(src, t, Some(roots[i])).iter().any(|a| a.blend != BlendMode::Normal));
|
||||
// Art under an opacity mask, effects or a knockout group is left out: the group can't draw it
|
||||
// as those do.
|
||||
let drawn_plainly = |i: usize| {
|
||||
own_layers(src, roots[i], top).iter().all(|a| !masked(a) && !has_effects(a) && !matches!(a.knockout, vectorcraft_doc::Knockout::On))
|
||||
};
|
||||
let under_shown = under.iter().copied().filter(|&i| backdrop && Some(layer_state(src, roots[i])) == state && !blends(i) && drawn_plainly(i));
|
||||
let composited: Vec<usize> = under_shown.chain(g.iter().copied()).collect();
|
||||
let below = composited.len() - g.len();
|
||||
let extent = composited.iter().filter_map(|&i| reaches[i]).reduce(|a, b| a.union(b)).unwrap_or_default();
|
||||
let shown = |nodes: &[Node]| -> Vec<Node> {
|
||||
let mut seen = vec![];
|
||||
let (mut under, mut out) = (vec![], vec![]);
|
||||
for (k, &i) in composited.iter().enumerate() {
|
||||
let n = clipped_by_ancestors(src, roots[i], &nodes[i], backdrop.then_some((&mut seen, extent)));
|
||||
if k < below {
|
||||
let groups = own_layers(src, roots[i], top).into_iter().filter(|a| !draws_as_is(a)).collect();
|
||||
under.push((groups, n));
|
||||
} else {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
let mut all = in_groups(under, 0);
|
||||
all.extend(out);
|
||||
all
|
||||
};
|
||||
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
|
||||
let group = g.iter().any(|&i| see_through[i]).then(|| {
|
||||
let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect();
|
||||
flatten_group(&plain, o, cmyk)
|
||||
let plain = shown(&plain);
|
||||
flatten_group(&plain.iter().collect::<Vec<_>>(), o, cmyk)
|
||||
});
|
||||
let Some((regions, area)) = group.flatten() else {
|
||||
out.kept.extend(ids);
|
||||
// Transparent art that its clip groups or clipping layers hide entirely draws nothing:
|
||||
// it is removed.
|
||||
let hides = |i: usize| see_through[i] && reach(&plain[i]).is_some() && reach(&clipped[i]).is_none();
|
||||
let (hidden, left): (Vec<usize>, Vec<usize>) = g.iter().partition(|&&i| hides(i));
|
||||
if !hidden.is_empty() {
|
||||
let roots = hidden.iter().map(|&i| roots[i]).collect();
|
||||
out.flat.push(Flat { roots, regions: vec![], area: None, raster: None, lines: None });
|
||||
}
|
||||
out.kept.extend(left.iter().map(|&i| roots[i]));
|
||||
continue;
|
||||
};
|
||||
let raster = match area.as_ref().filter(|_| render) {
|
||||
Some(a) => {
|
||||
let art: Vec<Node> = g.iter().map(|&i| art[i].clone()).collect();
|
||||
Some(render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o)?)
|
||||
// Drawn as planned: the image can land outside the clip groups and clipping layers
|
||||
// around its art.
|
||||
Some(render_raster(&baked, &shown(&art), a.rect, a.ppi, a.clip.clone(), o)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -140,6 +196,134 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render:
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `plain` (object `id` of `src` as plain art) inside a clip group for each clip group and
|
||||
/// clipping layer around `id`, innermost first, each clipped by that group's or layer's clipping
|
||||
/// path. `plain` itself when nothing around it clips. With `fills` (clipping paths, and an area),
|
||||
/// the fill of each clipping path not in them yet goes behind the art, inside the area, and the
|
||||
/// path is added to them.
|
||||
fn clipped_by_ancestors(src: &Document, id: NodeId, plain: &Node, mut fills: Option<(&mut Vec<NodeId>, Rect)>) -> Node {
|
||||
let mut out = plain.clone();
|
||||
if !clips_count() {
|
||||
return out;
|
||||
}
|
||||
let ancestry = src.ancestry(id).unwrap_or_default();
|
||||
for pair in ancestry.windows(2).rev() {
|
||||
let Some(above) = src.node(pair[0]).filter(|n| n.clips()) else { continue };
|
||||
let Some(clip) = above.children().and_then(|c| c.first()) else { continue };
|
||||
// The clipping path itself is not clipped art.
|
||||
if clip.id == pair[1] {
|
||||
continue;
|
||||
}
|
||||
// Its outline, unpainted: the group or layer around the object still paints the path.
|
||||
// Nothing to clip by hides the clipped art, as in the compositor.
|
||||
let Some((outline, rule)) = effects::clip_outline(clip) else {
|
||||
out.visible = false;
|
||||
return out;
|
||||
};
|
||||
let mut path = Node::path(clip.id, PathData::from_bezpath(&outline), Appearance::default());
|
||||
if let NodeKind::Path { rule: r, .. } = &mut path.kind {
|
||||
*r = rule;
|
||||
}
|
||||
let mut children = vec![Arc::new(path)];
|
||||
// The renderer paints the fill behind the clipped art.
|
||||
if let Some((seen, r)) = fills.as_mut()
|
||||
&& !seen.contains(&clip.id)
|
||||
&& let Some(fill) = clip.clip_paint().fill
|
||||
{
|
||||
let r = *r;
|
||||
seen.push(clip.id);
|
||||
let mut at = Node::group(above.id, vec![Arc::new(Node::path(clip.id, shapes::rectangle(r), Appearance::default())), Arc::new(fill)]);
|
||||
at.set_clips(true);
|
||||
children.push(Arc::new(at));
|
||||
}
|
||||
children.push(Arc::new(out));
|
||||
let mut g = Node::group(above.id, children);
|
||||
g.set_clips(true);
|
||||
out = g;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether layer or group `n` draws what it holds as it is: opaque, in Normal mode, not isolated.
|
||||
fn draws_as_is(n: &Node) -> bool {
|
||||
n.opacity >= 1.0 && n.blend == BlendMode::Normal && !n.isolate
|
||||
}
|
||||
|
||||
/// `items` (in paint order, each an object and the layers and groups around it, outermost first,
|
||||
/// that don't draw it as it is) with the objects of each such layer or group in one group with its
|
||||
/// opacity, blend mode and isolation, from the `depth`th around them on: they then composite
|
||||
/// together as that layer or group draws them.
|
||||
fn in_groups(items: Vec<(Vec<&Node>, Node)>, depth: usize) -> Vec<Node> {
|
||||
let mut out = vec![];
|
||||
let mut items = items.into_iter().peekable();
|
||||
while let Some((around, n)) = items.next() {
|
||||
let Some(a) = around.get(depth).copied() else {
|
||||
out.push(n);
|
||||
continue;
|
||||
};
|
||||
let mut run = vec![(around, n)];
|
||||
while let Some(next) = items.next_if(|(around, _)| around.get(depth).is_some_and(|b| b.id == a.id)) {
|
||||
run.push(next);
|
||||
}
|
||||
let mut g = Node::group(a.id, in_groups(run, depth + 1).into_iter().map(Arc::new).collect());
|
||||
(g.opacity, g.blend, g.isolate) = (a.opacity, a.blend, a.isolate);
|
||||
out.push(g);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The layers and groups around object `id` of `src` that are not also around `other`, outermost
|
||||
/// first.
|
||||
fn own_layers(src: &Document, id: NodeId, other: Option<NodeId>) -> Vec<&Node> {
|
||||
let mine = src.ancestry(id).unwrap_or_default();
|
||||
let theirs = other.and_then(|t| src.ancestry(t)).unwrap_or_default();
|
||||
let shared = mine.iter().zip(&theirs).take_while(|(a, b)| a == b).count();
|
||||
let own = mine.get(shared..mine.len().saturating_sub(1)).unwrap_or_default();
|
||||
own.iter().filter_map(|a| src.node(*a)).collect()
|
||||
}
|
||||
|
||||
/// The clip groups and clipping layers around object `id` of `d`, innermost first.
|
||||
fn clip_ancestors(d: &Document, id: NodeId) -> Vec<NodeId> {
|
||||
let mut a = d.ancestry(id).unwrap_or_default();
|
||||
a.pop();
|
||||
a.into_iter().rev().filter(|a| d.node(*a).is_some_and(Node::clips)).collect()
|
||||
}
|
||||
|
||||
/// Whether the layers around object `id` of `d` all print, all show, and whether one is a
|
||||
/// template.
|
||||
fn layer_state(d: &Document, id: NodeId) -> (bool, bool, bool) {
|
||||
let mut a = d.ancestry(id).unwrap_or_default();
|
||||
a.pop();
|
||||
a.iter().filter_map(|a| d.node(*a)).fold((true, true, false), |(print, show, template), n| match &n.kind {
|
||||
NodeKind::Layer { printable, template: t, .. } => (print && *printable, show && n.visible, template || *t),
|
||||
_ => (print, show, template),
|
||||
})
|
||||
}
|
||||
|
||||
/// For group `g` (indices of `roots` of `src`, in paint order) whose flattened group can't leave
|
||||
/// the clip groups and clipping layers of its topmost object ([`place_of`]): the objects inside
|
||||
/// them and the objects under them. `None` when the group can leave them, or every object is
|
||||
/// inside them.
|
||||
fn inside_and_under(src: &Document, roots: &[NodeId], g: &[usize]) -> Option<(Vec<usize>, Vec<usize>)> {
|
||||
if !clips_count() || g.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
|
||||
let top = clip_ancestors(src, *ids.last()?);
|
||||
if top.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let inside = |id: NodeId| {
|
||||
let mine = clip_ancestors(src, id);
|
||||
top.iter().all(|c| mine.contains(c))
|
||||
};
|
||||
if !inside(place_of(src, &ids)?) {
|
||||
return None;
|
||||
}
|
||||
let (inside, under): (Vec<usize>, Vec<usize>) = g.iter().partition(|&&i| inside(roots[i]));
|
||||
(!under.is_empty()).then_some((inside, under))
|
||||
}
|
||||
|
||||
/// The regions and the image area replacing one group of overlapping objects (`plain`: the art as
|
||||
/// composited; `cmyk`: in a CMYK document); `None` when it paints nothing.
|
||||
fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Regions, Option<Area>)> {
|
||||
@@ -163,8 +347,10 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg
|
||||
let mut complex: Vec<usize> = vec![];
|
||||
let mut gradients_only = !all;
|
||||
for (k, r) in regions.iter().enumerate() {
|
||||
if all || r.sources.iter().any(|&i| b.kinds[i] != Kind::Vector) {
|
||||
gradients_only &= r.sources.iter().all(|&i| b.kinds[i] == Kind::Gradient);
|
||||
// Art outside the clipping path of its clip group draws nothing there.
|
||||
let shown = |i: &&usize| b.kinds[**i] == Kind::Vector || !(clips_count() && b.hidden(**i, &r.sources));
|
||||
if all || r.sources.iter().filter(shown).any(|&i| b.kinds[i] != Kind::Vector) {
|
||||
gradients_only &= r.sources.iter().filter(shown).all(|&i| b.kinds[i] == Kind::Gradient);
|
||||
complex.push(k);
|
||||
} else if let Some(fill) = region_fill(&elems, &r.sources, o, cmyk) {
|
||||
vector.push((k, fill));
|
||||
@@ -227,6 +413,35 @@ pub(crate) fn with_every_region<T>(f: impl FnOnce() -> T) -> T {
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Groups flattened on this thread rasterize art where its clip group or clipping layer hides
|
||||
/// it too ([`with_clips_ignored`]).
|
||||
static CLIPS_IGNORED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Whether a region outside the clipping path of a clip group or clipping layer leaves out the
|
||||
/// art it hides there.
|
||||
#[cfg(test)]
|
||||
fn clips_count() -> bool {
|
||||
!CLIPS_IGNORED.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn clips_count() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// `f` with every group it flattens rasterizing art where its clip group or clipping layer hides
|
||||
/// it too.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_clips_ignored<T>(f: impl FnOnce() -> T) -> T {
|
||||
CLIPS_IGNORED.with(|e| e.set(true));
|
||||
let out = f();
|
||||
CLIPS_IGNORED.with(|e| e.set(false));
|
||||
out
|
||||
}
|
||||
|
||||
/// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where
|
||||
/// nothing paints it.
|
||||
fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option<RegionFill> {
|
||||
@@ -424,7 +639,18 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan
|
||||
let one_pass = separate(d, &plan.flat.iter().flat_map(|f| f.roots.iter().copied()).collect::<Vec<_>>());
|
||||
for f in plan.flat {
|
||||
// A group always has objects.
|
||||
let Some(&top) = f.roots.last() else { continue };
|
||||
let Some(top) = place_of(d, &f.roots) else { continue };
|
||||
// Art hidden entirely is removed.
|
||||
if f.regions.is_empty() && f.area.is_none() {
|
||||
if one_pass {
|
||||
flat.extend(f.roots.iter().map(|r| (*r, vec![])));
|
||||
} else {
|
||||
for r in &f.roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut children = vec![];
|
||||
// The image goes under the regions, reaching a pixel past its own: their edges then fall on
|
||||
// matching pixels instead of on what is behind the group.
|
||||
@@ -494,6 +720,30 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan
|
||||
Ok((ids, vector, rasterized))
|
||||
}
|
||||
|
||||
/// The object of `roots` (in paint order) whose place their flattened group takes: the topmost one
|
||||
/// that no clip group or clipping layer clips unless it clips every object of `roots`, else the
|
||||
/// topmost one. The group stays with the topmost object rather than move to a layer that prints,
|
||||
/// shows or is a template where the topmost object's layer differs, out from a clip group or
|
||||
/// clipping layer whose clipping path has a fill, which would then cover it, or out from a layer
|
||||
/// or group with opacity, a blend mode or isolation.
|
||||
fn place_of(d: &Document, roots: &[NodeId]) -> Option<NodeId> {
|
||||
let top = roots.last().copied()?;
|
||||
if !clips_count() || roots.len() < 2 {
|
||||
return Some(top);
|
||||
}
|
||||
let fills = |clip: &NodeId| d.node(*clip).and_then(|n| n.children()?.first().map(|c| c.clip_paint().fill.is_some())).unwrap_or(false);
|
||||
let all: Vec<Vec<NodeId>> = roots.iter().map(|r| clip_ancestors(d, *r)).collect();
|
||||
let above = all.last().map_or(&[][..], Vec::as_slice);
|
||||
let shared = |mine: &Vec<NodeId>| mine.iter().all(|c| all.iter().all(|theirs| theirs.contains(c)));
|
||||
let leaves_fill = |mine: &Vec<NodeId>| above.iter().any(|c| !mine.contains(c) && fills(c));
|
||||
// Out of a layer or group with opacity, a blend mode or isolation, the group would lose it.
|
||||
let leaves_compositing = |r: NodeId| own_layers(d, top, Some(r)).iter().any(|a| !draws_as_is(a));
|
||||
let state = layer_state(d, top);
|
||||
let place =
|
||||
roots.iter().zip(&all).rev().find(|(r, mine)| shared(mine) && !leaves_fill(mine) && layer_state(d, **r) == state && !leaves_compositing(**r));
|
||||
Some(place.map_or(top, |(r, _)| *r))
|
||||
}
|
||||
|
||||
/// 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");
|
||||
|
||||
@@ -10,7 +10,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Flatten Transparency…",
|
||||
["Object"],
|
||||
None,
|
||||
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
has_doc,
|
||||
flatten
|
||||
),
|
||||
|
||||
@@ -53,6 +53,11 @@ enum Body {
|
||||
pub(super) struct Builder {
|
||||
pub(super) shapes: Vec<po::Shape>,
|
||||
pub(super) kinds: Vec<Kind>,
|
||||
/// Per shape, the clipping path of the innermost clip group it is in (a clipping path's own
|
||||
/// entry is the clip group around its group).
|
||||
pub(super) clip_of: Vec<Option<usize>>,
|
||||
/// The clipping path of the clip group being built.
|
||||
pub(super) clip: Option<usize>,
|
||||
/// Building a CMYK document's art.
|
||||
pub(super) cmyk: bool,
|
||||
}
|
||||
@@ -72,9 +77,24 @@ impl Builder {
|
||||
path.bounds()?;
|
||||
self.shapes.push(po::Shape::new(path, rule, self.shapes.len() as u64));
|
||||
self.kinds.push(kind);
|
||||
self.clip_of.push(self.clip);
|
||||
Some(self.shapes.len() - 1)
|
||||
}
|
||||
|
||||
/// Whether a clip group hides shape `shape` in the region `sources` cover (ascending): the
|
||||
/// region lies outside one of the clipping paths around the shape.
|
||||
pub(super) fn hidden(&self, shape: usize, sources: &[usize]) -> bool {
|
||||
let around = |shape: usize| self.clip_of.get(shape).copied().flatten();
|
||||
let mut clip = around(shape);
|
||||
while let Some(c) = clip {
|
||||
if sources.binary_search(&c).is_err() {
|
||||
return true;
|
||||
}
|
||||
clip = around(c);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn elem(&mut self, n: &Node) -> Option<Elem> {
|
||||
if !n.visible {
|
||||
return None;
|
||||
@@ -93,7 +113,11 @@ impl Builder {
|
||||
} else {
|
||||
(None, &children[..])
|
||||
};
|
||||
Body::Group { clip, children: rest.iter().filter_map(|c| self.elem(c)).collect() }
|
||||
let outer = self.clip;
|
||||
self.clip = clip.or(outer);
|
||||
let children = rest.iter().filter_map(|c| self.elem(c)).collect();
|
||||
self.clip = outer;
|
||||
Body::Group { clip, children }
|
||||
}
|
||||
NodeKind::Path { guide: true, .. } => return None,
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
|
||||
|
||||
+9
-1
@@ -836,7 +836,8 @@ page with hint tables saying where every other page's objects are), which stays
|
||||
`compatibility: "1.3"` writes a PDF 1.3 file, which has no transparency: a copy of the document is flattened first
|
||||
with `flattenerPreset` (empty: High Resolution; `high`, `medium`, `low` or a saved preset, `flattener.presets.list`)
|
||||
and `flattener` options over it (as `object.flattenTransparency` takes them); images with see-through pixels count as
|
||||
transparency and rasterized areas are clipped to their regions. A file that would still have transparency fails the
|
||||
transparency and rasterized areas are clipped to their regions. Layers the file leaves out (non-printing ones, unless
|
||||
`includeNonPrinting` or `createLayers` is set) are left out of the flattening. A file that would still have transparency fails the
|
||||
export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too.
|
||||
A page with a side longer than 14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400,
|
||||
rounded up to the next thousandth, with the page's sizes and coordinates in those units, so that no side is longer than
|
||||
@@ -3196,6 +3197,13 @@ one undo step. The targets split into groups of overlapping objects; groups with
|
||||
Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over
|
||||
white, which blend modes do not read; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images,
|
||||
opacity masks or raster effects reach (clipped to those regions with `clipComplexRegions`, a rectangle without).
|
||||
The image leaves out the part of such art that the clipping path of a clip group or clipping layer hides, so art under
|
||||
the hidden part of a cropped picture stays as it is. Transparent art that such a clipping path hides entirely is
|
||||
removed. A group that holds art from inside and outside a clipping layer goes in the place of the topmost art outside
|
||||
it. When that layer prints, shows or is a template where the clipping layer differs, the clipping layer or a group
|
||||
around the art has opacity, a blend mode or isolation, or the clipping path has a fill,
|
||||
the group holds only the art inside the clipping layer, drawn over the fill and over the art below in the same kind of
|
||||
layer as its own layers draw it (over nothing with `preserveAlpha`), and the art outside is flattened on its own.
|
||||
`preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`,
|
||||
`textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`)
|
||||
override it, at the top level or in `options`. `balance: 0` rasterizes everything; lower balances rasterize groups
|
||||
|
||||
@@ -1429,6 +1429,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`).
|
||||
PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382).
|
||||
PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked.
|
||||
PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one. Layers the file leaves out are left out of the flattening, and transparent art that a clipping path hides entirely is removed (`crates/engine/src/cmd/fileio/tests_pdfx.rs`).
|
||||
Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392).
|
||||
Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392).
|
||||
A placed PDF or `.ai` file in a `.ai`'s editing data comes in as a placed document instead of a linked image: it shows the page and Crop to box its dictionary names, and the preview the file keeps until its file is found, then the page as vectors. A placed file in opacity-mask art keeps showing its preview (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/placed_document.rs`).
|
||||
|
||||
Reference in New Issue
Block a user