Flatten Transparency: opaque art that touches only opaque art stays vector

Grouping for flattening joined every pair of objects whose 1 pt reach
boxes touched, transitively, so a row or grid of opaque objects next to
one see-through object became one group and was flattened with it.

Now a group holds each see-through object and every object its reach
touches. An object without transparency joins only where its reach
touches a member and it lies above that member but below the group's
topmost object, whose place the group takes, so the stacking order
stays the same. In files without transparency with Clip Complex Regions
on, art under a group within one image pixel of a member joins it too,
with the pixel size taken from the image's actual resolution. Objects in
an overlap set that includes one inside a clip group or clipping layer
keep the previous grouping.

The 1 pt antialiasing margin now goes around a group's art once instead
of once per nested group (a stroked path's plain art is a group, so it
had 2 pt).

On 3,000 stroked rectangles, 60 with a drop shadow, the Flattener Preview
counts 180 affected objects where it counted 3,000, and Flatten
Transparency with the Medium preset makes 240 flat regions and 2 images
where it made one image of the whole grid.
This commit is contained in:
dotCooCoo
2026-11-11 19:17:06 -08:00
parent 7f3a046b18
commit f95c4b51c9
6 changed files with 496 additions and 38 deletions
+5 -2
View File
@@ -1,7 +1,10 @@
//! Object → Flatten Transparency: transparent art becomes opaque art that looks the same.
//!
//! The targets (`ids` or the selection) split into groups of overlapping objects. Groups without
//! transparency stay as they are (their type and strokes are outlined on request). The others are
//! The targets (`ids` or the selection) split into groups: each object with transparency with the
//! objects it overlaps, and the objects without transparency whose stacking order with a member
//! would change when the group takes its topmost object's place. Objects without transparency
//! that overlap only others without it stay as they are (their type and strokes are outlined on
//! request), as do groups without transparency. The others are
//! cut into atomic regions: the faces of the planar arrangement of every fill, outlined stroke and
//! clipping path in them ([`po::regions`]). The same paints cover each whole region, so its colour
//! is exact: those paints composited as the renderer composites them ([`Composer`], blend modes by
+346 -27
View File
@@ -99,17 +99,77 @@ pub(super) fn masked(n: &Node) -> bool {
n.extras().mask.as_ref().is_some_and(|m| !m.disabled)
}
/// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing).
pub(super) fn reach(n: &Node) -> Option<Rect> {
/// Where `n` paints: its visual bounds grown by its effects.
pub(super) fn painted(n: &Node) -> Option<Rect> {
if !n.visible {
return None;
}
let b = match n.children() {
Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?,
Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| painted(c)).reduce(|a, b| a.union(b))?,
_ => n.visual_bounds()?,
};
let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(effects::outset(&n.appearance.effects, b), f64::max);
Some(b.inflate(fx + 1.0, fx + 1.0))
Some(b.inflate(fx, fx))
}
/// Where `n` can paint: where it paints ([`painted`]) and a margin of 1 pt for antialiasing,
/// once, however deep its groups nest.
pub(super) fn reach(n: &Node) -> Option<Rect> {
painted(n).map(|b| b.inflate(1.0, 1.0))
}
type Cancelled = vectorcraft_doc::cancel::Cancelled;
/// Whether `b` has finite coordinates and isn't inside out.
pub(super) fn finite(b: &Rect) -> bool {
[b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.x0 <= b.x1 && b.y0 <= b.y1
}
/// Whether boxes `a` and `b` overlap or touch.
fn touch(a: &Rect, b: &Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
/// Indices joined into sets, each set knowing whether it holds an object with transparency.
struct Sets {
parent: Vec<usize>,
seeded: Vec<bool>,
}
impl Sets {
fn find(&mut self, mut i: usize) -> usize {
while self.parent[i] != i {
self.parent[i] = self.parent[self.parent[i]];
i = self.parent[i];
}
i
}
/// Joins the sets of `a` and `b`: → the set they are now, named by its lowest index.
fn join(&mut self, a: usize, b: usize) -> usize {
let (a, b) = (self.find(a), self.find(b));
let (lo, hi) = (a.min(b), a.max(b));
self.parent[hi] = lo;
self.seeded[lo] |= self.seeded[hi];
lo
}
/// The sets, each in index order, by their first index.
fn groups(&mut self) -> Vec<Vec<usize>> {
let mut groups: Vec<Vec<usize>> = vec![];
let mut slot: Vec<Option<usize>> = vec![None; self.parent.len()];
for i in 0..self.parent.len() {
let r = self.find(i);
match slot[r] {
Some(g) => groups[g].push(i),
None => {
slot[r] = Some(groups.len());
groups.push(vec![i]);
}
}
}
groups
}
}
/// Indices of `bounds` grouped by overlap (transitively), each group in index order. Boxes that
@@ -121,16 +181,15 @@ pub(super) fn reach(n: &Node) -> Option<Rect> {
/// horizontal extent, would otherwise compare every pair). An open box inside a later box of
/// its group, ending no further right, is closed early: whatever overlaps it overlaps that box.
/// Fails only when cancelled ([`vectorcraft_doc::cancel`]).
pub(super) fn overlapping(bounds: &[Option<Rect>]) -> Result<Vec<Vec<usize>>, vectorcraft_doc::cancel::Cancelled> {
fn find(p: &mut [usize], mut i: usize) -> usize {
while p[i] != i {
p[i] = p[p[i]];
i = p[i];
}
i
}
let mut parent: Vec<usize> = (0..bounds.len()).collect();
let finite = |b: &Rect| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.x0 <= b.x1 && b.y0 <= b.y1;
pub(super) fn overlapping(bounds: &[Option<Rect>]) -> Result<Vec<Vec<usize>>, Cancelled> {
Ok(sweep(bounds, None)?.groups())
}
/// [`overlapping`]'s sweep. With `see_through`, two boxes are joined only when one of them is
/// see-through, and an open box is closed early only inside a later see-through box.
fn sweep(bounds: &[Option<Rect>], see_through: Option<&[bool]>) -> Result<Sets, Cancelled> {
let mut sets = Sets { parent: (0..bounds.len()).collect(), seeded: (0..bounds.len()).map(|i| see_through.is_some_and(|s| s[i])).collect() };
let joins = |a: usize, b: usize| see_through.is_none_or(|s| s[a] || s[b]);
let boxes: Vec<(usize, Rect)> = bounds.iter().enumerate().filter_map(|(i, b)| b.filter(finite).map(|b| (i, b))).collect();
let n = boxes.len();
// Each box's place in the tree: by top (then index).
@@ -190,29 +249,140 @@ pub(super) fn overlapping(bounds: &[Option<Rect>]) -> Result<Vec<Vec<usize>>, ve
stack.push((2 * node + 1, mid, hi));
stack.push((2 * node, lo, mid));
}
// Whatever overlaps a box inside this one overlaps this one, and joins it when this one is
// see-through.
let shadows = see_through.is_none_or(|s| s[boxes[k].0]);
for &o in &met {
let (a, c) = (find(&mut parent, boxes[k].0), find(&mut parent, boxes[o].0));
parent[a.max(c)] = a.min(c);
if joins(boxes[k].0, boxes[o].0) {
sets.join(boxes[k].0, boxes[o].0);
}
let ob = boxes[o].1;
if ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 {
if shadows && ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 {
set(&mut low, leaf[o], f64::NEG_INFINITY);
}
}
set(&mut low, leaf[k], b.y1);
}
let mut groups: Vec<Vec<usize>> = vec![];
let mut slot: Vec<Option<usize>> = vec![None; bounds.len()];
for i in 0..bounds.len() {
let r = find(&mut parent, i);
match slot[r] {
Some(g) => groups[g].push(i),
None => {
slot[r] = Some(groups.len());
groups.push(vec![i]);
Ok(sets)
}
/// Boxes in blocks of nearby ones, each block with the box around it, to find the boxes a box
/// touches.
struct Blocks {
order: Vec<usize>,
boxes: Vec<Option<Rect>>,
around: Vec<Rect>,
}
impl Blocks {
const SIZE: usize = 32;
/// The finite boxes of `boxes`, in vertical strips by their centers and in blocks down each
/// strip.
fn new(boxes: &[Option<Rect>]) -> Self {
let boxes: Vec<Option<Rect>> = boxes.iter().map(|b| b.filter(finite)).collect();
let mut order: Vec<usize> = (0..boxes.len()).filter(|&i| boxes[i].is_some()).collect();
let center = |i: usize| boxes[i].map_or_else(Default::default, |b| b.center());
order.sort_by(|&a, &b| center(a).x.total_cmp(&center(b).x));
let strip = ((order.len() as f64 / Self::SIZE as f64).sqrt().ceil() as usize).max(1) * Self::SIZE;
for s in order.chunks_mut(strip) {
s.sort_by(|&a, &b| center(a).y.total_cmp(&center(b).y));
}
let around = order.chunks(Self::SIZE).filter_map(|c| c.iter().filter_map(|&i| boxes[i]).reduce(|a, b| a.union(b))).collect();
Self { order, boxes, around }
}
/// Puts the indices of the boxes that touch `q` in `out`. `work` counts the boxes looked at;
/// fails only when cancelled.
fn touching(&self, q: &Rect, out: &mut Vec<usize>, work: &mut usize) -> Result<(), Cancelled> {
out.clear();
for (b, around) in self.around.iter().enumerate() {
*work += 1;
if work.is_multiple_of(4096) {
vectorcraft_doc::cancel::check()?;
}
if !touch(around, q) {
continue;
}
for &i in &self.order[b * Self::SIZE..((b + 1) * Self::SIZE).min(self.order.len())] {
*work += 1;
if self.boxes[i].is_some_and(|r| touch(&r, q)) {
out.push(i);
}
}
}
Ok(())
}
Ok(groups)
}
/// Objects (in paint order) grouped for flattening, each group in paint order and the groups by
/// their first object; a group takes the place of its topmost object. `reach` is where each
/// object can paint ([`reach`]), `painted` where it paints ([`painted`]), and `see_through` whether
/// it has transparency. An object whose box is missing or not finite is a group of its own.
///
/// An object joins every see-through object its reach touches. An object without transparency
/// that touches only others without transparency stays a group of its own, unless a group would
/// change its stacking order with it: it joins (and groups join each other) where its reach
/// touches a member's and moving the members to their group's top would put the two the other way
/// round. With a `fringe` (how far a group's image can reach past
/// its art and cover what is under it), an object or group under a group's top whose painted box
/// that far around a member's touches joins it too. Fails only when cancelled
/// ([`vectorcraft_doc::cancel`]).
pub(super) fn flatten_groups(
reach: &[Option<Rect>],
painted: &[Option<Rect>],
see_through: &[bool],
fringe: f64,
) -> Result<Vec<Vec<usize>>, Cancelled> {
let n = reach.len();
let mut sets = sweep(reach, Some(see_through))?;
let mut members: Vec<Vec<usize>> = vec![vec![]; n];
for i in 0..n {
let r = sets.find(i);
members[r].push(i);
}
// The topmost object of each set (by its name).
let mut top: Vec<usize> = (0..n).map(|r| members[r].last().copied().unwrap_or(r)).collect();
let index = Blocks::new(reach);
// Each member of a set with transparency is looked at.
let mut work: Vec<usize> = vec![];
for i in 0..n {
let r = sets.find(i);
if sets.seeded[r] {
work.push(i);
}
}
let (mut near, mut looked) = (vec![], 0usize);
while let Some(i) = work.pop() {
let Some(q) = reach[i].filter(finite) else { continue };
// Painted boxes within the fringe have reaches within it too.
index.touching(&q.inflate(fringe, fringe), &mut near, &mut looked)?;
for &k in &near {
let (ri, rk) = (sets.find(i), sets.find(k));
if ri == rk {
continue;
}
let (ti, tk) = (top[ri], top[rk]);
let crossed = reach[k].is_some_and(|b| touch(&q, &b)) && (i < k) != (ti < tk);
let covered = fringe > 0.0 && tk < ti && painted[i].zip(painted[k]).is_some_and(|(a, b)| touch(&a.inflate(fringe, fringe), &b));
if !crossed && !covered {
continue;
}
// The members of a set whose top moves are looked at again, from there.
let t = ti.max(tk);
if ti != t {
work.extend_from_slice(&members[ri]);
}
if tk != t {
work.extend_from_slice(&members[rk]);
}
let r = sets.join(ri, rk);
let moved = std::mem::take(&mut members[if r == ri { rk } else { ri }]);
members[r].extend(moved);
top[r] = t;
}
}
Ok(sets.groups())
}
#[cfg(test)]
@@ -292,4 +462,153 @@ mod tests {
let odd = [Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(f64::NAN, 0.0, 10.0, 10.0)), Some(Rect::new(5.0, 5.0, 20.0, 20.0))];
assert_eq!(overlapping(&odd).unwrap(), vec![vec![0, 2], vec![1]]);
}
/// A filled rectangle, unstroked.
fn filled(id: u64, r: Rect) -> Node {
let look = vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)),
vectorcraft_color::Paint::None,
0.0,
);
Node::path(vectorcraft_doc::NodeId(id), vectorcraft_geom::shapes::rectangle(r), look)
}
/// The antialiasing margin goes around the outermost group once, not once per group.
#[test]
fn reach_adds_its_margin_once_however_deep_the_groups() {
let r = Rect::new(10.0, 20.0, 30.0, 50.0);
let inner = Node::group(vectorcraft_doc::NodeId(2), vec![Arc::new(filled(1, r))]);
let outer = Node::group(vectorcraft_doc::NodeId(3), vec![Arc::new(inner)]);
assert_eq!(painted(&outer), Some(r));
assert_eq!(reach(&outer), Some(r.inflate(1.0, 1.0)));
assert_eq!(reach(&filled(1, r)), reach(&outer));
}
/// The group of each object, the topmost object of each group and whether it is see-through.
fn sets_of(groups: &[Vec<usize>], n: usize, see_through: &[bool]) -> (Vec<usize>, Vec<usize>, Vec<bool>) {
let mut of = vec![usize::MAX; n];
for (g, ids) in groups.iter().enumerate() {
for &i in ids {
of[i] = g;
}
}
let top = groups.iter().map(|g| *g.last().unwrap()).collect();
let seeded = groups.iter().map(|g| g.iter().any(|&i| see_through[i])).collect();
(of, top, seeded)
}
/// What [`flatten_groups`] must give: every object in one group, in paint order, the groups by
/// their first object; see-through objects with all that their reach touches; groups without
/// transparency of one object; no two objects that paint over each other the other way round
/// once each group takes its top's place; nothing within `fringe` of a member of a group under
/// that group's top.
fn check(reach: &[Option<Rect>], painted: &[Option<Rect>], see: &[bool], fringe: f64, groups: &[Vec<usize>], what: &str) {
let n = reach.len();
let mut all: Vec<usize> = groups.iter().flatten().copied().collect();
all.sort_unstable();
assert_eq!(all, (0..n).collect::<Vec<_>>(), "{what}: every object once");
assert!(groups.iter().all(|g| g.is_sorted()) && groups.windows(2).all(|w| w[0][0] < w[1][0]), "{what}: in order");
let (of, top, seeded) = sets_of(groups, n, see);
for (g, ids) in groups.iter().enumerate() {
assert!(seeded[g] || ids.len() == 1, "{what}: {ids:?} has no transparency");
}
for i in 0..n {
for j in 0..n {
if i == j || of[i] == of[j] {
continue;
}
let (gi, gj) = (of[i], of[j]);
if let (Some(a), Some(b)) = (reach[i], reach[j]) {
assert!(!(touch(&a, &b) && (see[i] || see[j])), "{what}: {i} and {j} touch, one see-through");
if seeded[gi] && touch(&a, &b) {
assert_eq!(i < j, top[gi] < top[gj], "{what}: {i} and {j} change places");
}
}
let (Some(a), Some(b)) = (painted[i], painted[j]) else { continue };
if seeded[gi] && fringe > 0.0 && touch(&a.inflate(fringe, fringe), &b) {
assert!(top[gj] > top[gi], "{what}: {j} under the image of {i}'s group");
}
}
}
}
/// Boxes of many sizes, some see-through, group as they must, with and without a fringe.
#[test]
fn flatten_groups_keep_stacking_order_and_leave_opaque_art_alone() {
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
let mut next = |m: f64| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % 1000) as f64 / 1000.0 * m
};
for round in 0..60 {
let n = 4 + round;
let painted: Vec<Option<Rect>> = (0..n)
.map(|i| {
if i % 13 == 5 {
return None;
}
let (x, y) = (next(120.0).round(), next(120.0).round());
Some(Rect::new(x, y, x + next(25.0).round(), y + next(25.0).round()))
})
.collect();
let reach: Vec<Option<Rect>> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect();
let see: Vec<bool> = (0..n).map(|_| next(1.0) < 0.15).collect();
for fringe in [0.0, 0.7] {
let groups = flatten_groups(&reach, &painted, &see, fringe).unwrap();
check(&reach, &painted, &see, fringe, &groups, &format!("round {round}, fringe {fringe}"));
}
}
}
/// A row of opaque boxes whose reaches touch, with see-through ones at its ends and far from
/// it: only the boxes next to a see-through one join it.
#[test]
fn an_opaque_row_stays_apart_from_the_see_through_boxes_at_its_ends() {
let n = 200;
// 10 pt boxes 1.5 pt apart: their 1 pt reaches overlap.
let painted: Vec<Option<Rect>> = (0..n).map(|i| Some(Rect::new(11.5 * i as f64, 0.0, 11.5 * i as f64 + 10.0, 10.0))).collect();
let reach: Vec<Option<Rect>> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect();
let mut see = vec![false; n];
see[0] = true;
see[n - 1] = true;
let groups = flatten_groups(&reach, &painted, &see, 0.0).unwrap();
check(&reach, &painted, &see, 0.0, &groups, "row");
assert_eq!(groups[0], vec![0, 1]);
assert_eq!(groups.last(), Some(&vec![n - 2, n - 1]));
assert_eq!(groups.len(), n - 2);
// Without transparency every box is a group of its own.
assert_eq!(flatten_groups(&reach, &painted, &vec![false; n], 0.0).unwrap().len(), n);
}
/// An opaque box between a member and its group's top that paints over the member joins, and
/// so on along a chain; one under the member stays, but for one within the fringe.
#[test]
fn boxes_the_group_would_cross_join_it() {
// In paint order: D under A, A, B over A, C over B, then the see-through T over A.
let d = Rect::new(40.0, 80.0, 65.0, 120.0);
let a = Rect::new(60.0, 50.0, 105.0, 90.0);
let b = Rect::new(20.0, 20.0, 65.0, 55.0);
let c = Rect::new(0.0, 0.0, 25.0, 25.0);
let t = Rect::new(100.0, 50.0, 140.0, 90.0);
let painted = [Some(d), Some(a), Some(b), Some(c), Some(t)];
let reach: Vec<Option<Rect>> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect();
let see = [false, false, false, false, true];
let groups = flatten_groups(&reach, &painted, &see, 0.0).unwrap();
assert_eq!(groups, vec![vec![0], vec![1, 2, 3, 4]]);
// C away from B: it stays.
let far = [Some(d), Some(a), Some(b), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(t)];
let reach_far: Vec<Option<Rect>> = far.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect();
assert_eq!(flatten_groups(&reach_far, &far, &see, 0.0).unwrap(), vec![vec![0], vec![1, 2, 4], vec![3]]);
// B 0.5 pt above A, not overlapping it: their antialiased edges can share pixels, so B joins.
let gap = [Some(d), Some(a), Some(Rect::new(20.0, 20.0, 65.0, 49.5)), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(t)];
let reach_gap: Vec<Option<Rect>> = gap.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect();
assert_eq!(flatten_groups(&reach_gap, &gap, &see, 0.0).unwrap(), vec![vec![0], vec![1, 2, 4], vec![3]]);
// D a little left of A, under it: the fringe takes it in.
let near = [Some(Rect::new(40.0, 80.0, 59.8, 120.0)), Some(a), Some(b), Some(c), Some(t)];
let reach_near: Vec<Option<Rect>> = near.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect();
assert_eq!(flatten_groups(&reach_near, &near, &see, 0.0).unwrap()[0], vec![0]);
assert_eq!(flatten_groups(&reach_near, &near, &see, 0.24).unwrap(), vec![vec![0, 1, 2, 3, 4]]);
}
}
+89 -7
View File
@@ -1,4 +1,4 @@
use super::analysis::{Scratch, has_effects, masked, outlined_text, overlapping, reach};
use super::analysis::{Scratch, finite, flatten_groups, has_effects, masked, outlined_text, overlapping, painted, reach};
use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE};
use super::options::FlattenOptions;
use super::{
@@ -115,7 +115,7 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render:
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()?;
let mut queue = overlapping(&reaches)?;
let mut queue = groups(src, roots, &plain, &reaches, &see_through, o)?;
queue.reverse();
while let Some(g) = queue.pop() {
vectorcraft_doc::cancel::check()?;
@@ -196,6 +196,46 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render:
Ok(out)
}
/// `roots` of `src` (`plain`: as plain art; `reaches`: where each can paint, clipped) in the
/// groups that flatten together ([`flatten_groups`]), each in paint order, by their first object.
/// Objects that overlap (as [`overlapping`] groups them) stay in one group when one of them is
/// inside a clip group or clipping layer: [`inside_and_under`] and [`place_of`] then decide where
/// they go.
fn groups(
src: &Document,
roots: &[NodeId],
plain: &[Node],
reaches: &[Option<Rect>],
see_through: &[bool],
o: &FlattenOptions,
) -> Result<Vec<Vec<usize>>> {
// An image clipped to its regions, opaque over white, reaches a pixel past them; a group's
// image is no larger than the art it overlaps, so its pixels are no wider than this.
let opaque_edge = o.no_transparency.is_some() && o.clip_complex_regions && !o.preserve_alpha;
let ppi = o.line_art_ppi.min(o.gradient_ppi).max(1.0);
let extent = |ids: &mut dyn Iterator<Item = usize>| ids.filter_map(|i| reaches[i].filter(finite)).reduce(|a, b| a.union(b)).unwrap_or_default();
// Where the widest such pixel is wider than the margins of two reaches, objects that close
// overlap too.
let widest = if opaque_edge { 1.0 / raster_scale(extent(&mut (0..reaches.len())), ppi) } else { 0.0 };
let grow = ((widest - 2.0) / 2.0).max(0.0);
let near: Vec<Option<Rect>> = reaches.iter().map(|b| b.map(|b| b.inflate(grow, grow))).collect();
let mut out = vec![];
for g in overlapping(if grow > 0.0 { &near } else { reaches })? {
let clipped = || clips_count() && g.iter().any(|&i| !clip_ancestors(src, roots[i]).is_empty());
if g.len() < 2 || !g.iter().any(|&i| see_through[i]) || clipped() {
out.push(g);
continue;
}
let fringe = if opaque_edge { 1.0 / raster_scale(extent(&mut g.iter().copied()), ppi) } else { 0.0 };
let local = |v: &[Option<Rect>]| g.iter().map(|&i| v[i]).collect::<Vec<_>>();
let painted: Vec<Option<Rect>> = g.iter().map(|&i| painted(&plain[i])).collect();
let see: Vec<bool> = g.iter().map(|&i| see_through[i]).collect();
out.extend(flatten_groups(&local(reaches), &painted, &see, fringe)?.into_iter().map(|l| l.into_iter().map(|k| g[k]).collect::<Vec<_>>()));
}
out.sort_by_key(|g| g.first().copied());
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),
@@ -482,11 +522,7 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool
/// 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());
// No side longer than the renderer draws: the image grows by a pixel on each side and is
// rounded up to whole pixels below.
scale = scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0));
let scale = raster_scale(rect, ppi);
let px = 1.0 / scale;
let rect = rect.inflate(px, px);
let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c));
@@ -559,6 +595,14 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
Ok(Raster { blob: ImageBlob::new("image/png", png), width: img.width, height: img.height, xf, clip })
}
/// Pixels per point of an image of `rect` at `ppi`: lower when the image would be larger than
/// [`MAX_PIXELS`], or have a side longer than the renderer draws (the image grows by a pixel on
/// each side and is rounded up to whole pixels).
fn raster_scale(rect: Rect, ppi: f64) -> f64 {
let scale = (ppi / 72.0).min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt());
scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0))
}
/// `n` at full opacity with Normal blending throughout (objects, fills and strokes).
pub(super) fn opaque(n: &mut Node) {
n.opacity = 1.0;
@@ -877,4 +921,42 @@ mod tests {
assert_eq!(flat.parent_of(g), Some(layer));
assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g));
}
/// In a file without transparency with Clip Complex Regions on, art under a group's opaque
/// image within one of its pixels joins the group, also where a long image is made at a lower
/// resolution and its pixels are wider than at the preset's.
#[test]
fn art_under_the_edge_of_a_long_opaque_image_joins_its_group() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 31000, "height": 100})).unwrap();
let rect = |s: &mut Session, x: f64, w: f64| {
let id = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setStroke", &json!({"none": true, "ids": [id]})).unwrap();
NodeId(id)
};
// 0.5 pt left of A: wider than a pixel at 300 ppi, narrower than one of a 30,000 pt image.
let d = rect(&mut s, 40.0, 19.5);
let a = rect(&mut s, 60.0, 40.0);
let t = rect(&mut s, 95.0, 30_000.0);
s.execute("transparency.set", &json!({"ids": [t.0], "opacity": 50})).unwrap();
let o = FlattenOptions { no_transparency: Some(Default::default()), clip_complex_regions: true, ..FlattenOptions::default() };
let doc = &s.doc().unwrap().doc;
let long = plan(doc, &[d, a, t], &o, false).unwrap();
assert!(long.kept.is_empty(), "{:?}", long.kept);
assert_eq!(long.flat.iter().map(|f| f.roots.clone()).collect::<Vec<_>>(), vec![vec![d, a, t]]);
// Short, the image has 300 ppi pixels: D stays.
let short = rect(&mut s, 95.0, 40.0);
s.execute("transparency.set", &json!({"ids": [short.0], "opacity": 50})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(plan(doc, &[d, a, short], &o, false).unwrap().kept, vec![d]);
// 100,000 pt long, its pixels are over 3 pt wide: art 3 pt away, whose reach doesn't touch
// A's, joins too.
let far = rect(&mut s, 40.0, 17.0);
let longer = rect(&mut s, 95.0, 100_000.0);
s.execute("transparency.set", &json!({"ids": [longer.0], "opacity": 50})).unwrap();
let doc = &s.doc().unwrap().doc;
let p = plan(doc, &[far, a, longer], &o, false).unwrap();
assert!(p.kept.is_empty(), "{:?}", p.kept);
assert_eq!(p.flat.iter().map(|f| f.roots.clone()).collect::<Vec<_>>(), vec![vec![far, a, longer]]);
}
}
+48
View File
@@ -450,6 +450,54 @@ fn objects_without_transparency_stay_unless_outlined() {
}
}
/// A row of opaque rectangles 1.5 pt apart (closer than twice the antialiasing margin), with a
/// see-through one touching each end: only the two next to a see-through one are flattened, and
/// the document looks the same.
#[test]
fn opaque_art_that_touches_only_opaque_art_stays() {
let _settings = settings_lock();
let mut s = session();
let under = rect(&mut s, 0.0, 50.0, 10.0, 10.0, "#2060c0");
run(&mut s, "transparency.set", json!({"ids": [under], "opacity": 50}));
let row: Vec<u64> = (1..15).map(|i| rect(&mut s, 11.5 * f64::from(i), 50.0, 10.0, 10.0, "#e04010")).collect();
let over = rect(&mut s, 11.5 * 15.0, 50.0, 10.0, 10.0, "#20a040");
run(&mut s, "transparency.set", json!({"ids": [over], "opacity": 50, "blend": "Multiply"}));
let before = picture(&s);
run(&mut s, "select.all", json!({}));
let (ids, vector, rasterized) = flatten(&mut s, json!({}));
assert_eq!((vector, rasterized), (4, 0), "two groups of two regions each");
let (ends, kept) = (row[0], row.last().copied().unwrap());
assert!(!exists(&s, under) && !exists(&s, over) && !exists(&s, ends) && !exists(&s, kept));
assert!(row[1..row.len() - 1].iter().all(|id| exists(&s, *id) && ids.contains(id)), "the rest of the row stays");
assert_eq!(ids.len(), row.len() - 2 + 2);
let diff = changed(&before, &picture(&s));
assert!(diff == 0.0, "{:.2}% of the pixels changed", diff * 100.0);
}
/// Opaque art between a see-through object's group and the top of that group, painting over a
/// member, joins the group (along a chain too), so it still paints over it; opaque art under the
/// group stays.
#[test]
fn opaque_art_the_group_would_move_under_joins_it() {
let _settings = settings_lock();
let mut s = session();
let d = rect(&mut s, 40.0, 80.0, 25.0, 40.0, "#f0d000");
let a = rect(&mut s, 60.0, 50.0, 45.0, 40.0, "#e01010");
let b = rect(&mut s, 20.0, 20.0, 45.0, 35.0, "#10a010");
let c = rect(&mut s, 0.0, 0.0, 25.0, 25.0, "#1010e0");
let t = rect(&mut s, 100.0, 50.0, 40.0, 40.0, "#000000");
run(&mut s, "transparency.set", json!({"ids": [t], "opacity": 50}));
let before = picture(&s);
let (ids, ..) = flatten(&mut s, json!({"ids": [d, a, b, c, t]}));
assert!(exists(&s, d) && ids.contains(&d), "the art under the group stays");
assert!([a, b, c, t].iter().all(|id| !exists(&s, *id)), "the others are flattened together");
// Where B paints over A, and C over B, they still do.
for p in [Point::new(62.5, 52.5), Point::new(22.5, 22.5), Point::new(62.5, 85.5), Point::new(102.5, 70.5)] {
assert_eq!(pixel(&picture(&s), p), pixel(&before, p), "at {p:?}");
}
assert!(changed(&before, &picture(&s)) == 0.0);
}
#[test]
fn outlined_type_keeps_its_underline() {
let _settings = settings_lock();
+7 -2
View File
@@ -3401,8 +3401,13 @@ where the layout puts it.
## Flatten Transparency
`object.flattenTransparency {ids?, preset?, …options}` turns transparent art into opaque art that looks the same, in
one undo step. The targets split into groups of overlapping objects; groups without transparency stay as they are.
Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over
one undo step. The targets split into groups. A group holds each object with transparency and every object it
overlaps, counting 1 pt around each object for antialiasing. It also holds each object without transparency that
overlaps a member in the same way and lies above that member but below the group's topmost object, whose place the
group takes.
Objects without transparency that overlap only others without transparency stay as they are. Overlapping objects of
which one is inside a clip group or clipping layer form one group, with transparency or without; groups without
transparency stay as they are. 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
+1
View File
@@ -1155,6 +1155,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Select › Object › All on Same Layers takes the layers only of the selected objects that can be selected: an object that is hidden or locked, on a template layer, on a locked artboard while nothing is isolated, or outside the isolated group adds no layer. This is VectorCraft's own behavior.
Object › Flatten Transparency takes a frame of threaded text out of the thread in the same way when Convert All Text to Outlines outlines it or when the frame becomes flat regions or an image, and the other frames keep the text they hold.
Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them.
Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency leave art without transparency as it is when it overlaps only other art without transparency. Such art joins a see-through object's group only where it lies above a member and below the group's topmost object, whose place the group takes, so the stacking order stays the same. In files without transparency with Clip Complex Regions on, art under the group within one image pixel of a member joins it too. The 1 pt antialiasing margin goes around a group's art once, however deeply its groups nest. On a grid of 3,000 rectangles with a 0.5 pt stroke, 60 of them with a drop shadow, the Flattener Preview counts 180 affected objects where it counted 3,000, and Flatten Transparency with the Medium preset makes 240 flat regions and 2 images where it made one image of the whole grid. These rules are VectorCraft's own (`tests::flatten::opaque_art_that_touches_only_opaque_art_stays`, `tests::flatten::opaque_art_the_group_would_move_under_joins_it`).
New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text.
With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines.
Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`).