Files
vectorcraft/crates/pdf/src/tests_blend.rs
T
bflatasticandClaude Opus 5.5 54427d7a92 M3.74: blending inside a group reaches the art below unless the group isolates it
On screen, a group (or object) with opacity, a blend mode, an opacity mask or
knockout whose content blends is drawn offscreen over a copy of its backdrop
and mixed back in by its opacity, mask and clip (a group blend mode then
applies to the backdrop-free result, as PDF defines it). Isolate Blending keeps
the content starting from transparency. Clip groups no longer isolate their
children (blending content is clipped per drawing), and non-isolated knockout
groups composite their elements against the art below the group. Groups
without blending inside still render as plain layers.

In PDF, such groups are written as non-isolated transparency groups (a
constant-alpha soft mask instead of the writer's isolated opacity group),
knockout groups included.

doc: Node::blends_through / children_blend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:27:31 +07:00

100 lines
3.8 KiB
Rust

//! Blend modes and transparency groups in PDF export.
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId};
use vectorcraft_geom::{Rect, shapes};
use crate::*;
fn rect(r: Rect, rgb: (f32, f32, f32)) -> Node {
Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(rgb.0, rgb.1, rgb.2)), Paint::None, 0.0))
}
fn add(d: &mut Document, mut n: Node) {
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, n).unwrap();
}
/// The file's text with spaces removed (content streams uncompressed).
fn text(d: &Document) -> String {
let bytes = export(d, &PdfOptions { compress: false, ..Default::default() }).unwrap();
String::from_utf8_lossy(&bytes).replace(' ', "")
}
#[test]
fn every_blend_mode_is_written_under_its_pdf_name_and_read_back() {
for mode in BlendMode::ALL.into_iter().filter(|m| *m != BlendMode::Normal) {
let mut d = Document::new(100.0, 100.0);
add(&mut d, rect(Rect::new(0.0, 0.0, 100.0, 100.0), (0.2, 0.5, 0.9)));
let mut top = rect(Rect::new(20.0, 20.0, 80.0, 80.0), (0.6, 0.25, 0.5));
top.blend = mode;
add(&mut d, top);
let name = mode.label().replace(' ', "");
assert!(text(&d).contains(&format!("/BM/{name}")), "{mode:?}");
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
let mut found = BlendMode::Normal;
back.walk(|n| {
if n.blend != BlendMode::Normal {
found = n.blend;
}
});
assert_eq!(found, mode);
}
}
/// The backdrop and a 50% group (isolated or not) holding a Multiply square.
fn half_group(isolate: bool) -> Document {
let mut d = Document::new(100.0, 100.0);
add(&mut d, rect(Rect::new(0.0, 0.0, 100.0, 100.0), (0.8, 0.6, 0.2)));
let mut m = rect(Rect::new(20.0, 20.0, 80.0, 80.0), (0.5, 0.5, 1.0));
(m.id, m.blend) = (d.alloc_id(), BlendMode::Multiply);
let mut g = Node::group(NodeId(0), vec![std::sync::Arc::new(m)]);
(g.opacity, g.isolate) = (0.5, isolate);
add(&mut d, g);
d
}
#[test]
fn a_group_is_written_non_isolated_unless_it_isolates_blending() {
let open = text(&half_group(false));
// A transparency group without /I, its 50% carried by a constant alpha soft mask.
assert!(!open.contains("/Itrue"), "{open}");
assert!(open.contains("/SMask") && open.contains("/Alpha") && open.contains("/BM/Multiply"), "{open}");
let isolated = text(&half_group(true));
assert!(isolated.contains("/Itrue") && isolated.contains("/ca0.5"), "{isolated}");
assert!(!isolated.contains("/Alpha"), "{isolated}");
// Either way the child's blend mode reads back.
for d in [half_group(false), half_group(true)] {
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
let mut bm = BlendMode::Normal;
back.walk(|n| {
if n.blend != BlendMode::Normal {
bm = n.blend;
}
});
assert_eq!(bm, BlendMode::Multiply);
}
}
#[test]
fn groups_without_blending_inside_stay_isolated_groups() {
let mut d = half_group(false);
let g = d.layers[0].children().unwrap()[1].id;
let m = d.node(g).unwrap().children().unwrap()[0].id;
d.node_mut(m).unwrap().blend = BlendMode::Normal;
let t = text(&d);
assert!(t.contains("/Itrue") && t.contains("/ca0.5") && !t.contains("/SMask"), "{t}");
}
#[test]
fn a_knockout_group_with_blending_elements_is_non_isolated_unless_isolated() {
for isolate in [false, true] {
let mut d = half_group(isolate);
let g = d.layers[0].children().unwrap()[1].id;
let n = d.node_mut(g).unwrap();
(n.opacity, n.knockout) = (1.0, vectorcraft_doc::Knockout::On);
assert_eq!(text(&d).contains("/Itrue"), isolate, "isolate {isolate}");
}
}