From 2380e889bc33d25c34fc4277dc5124e1d03ef8e1 Mon Sep 17 00:00:00 2001 From: "Prin.Ing" <267065148+Prin-Ing@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:27:29 +0000 Subject: [PATCH] Group flattened multi-line transparent type by line Fixes #5252 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/flatten/apply.rs | 56 +++++++++++++++++++++++++- crates/engine/src/tests/flatten.rs | 27 +++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index cccd82ecc..e4113b47b 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -26,6 +26,40 @@ pub(super) struct Flat { /// Where its image goes, and the image (once rendered). pub(super) area: Option, raster: Option, + /// When the group is one block of type over several lines: its lines, to group the regions by. + lines: Option, +} + +/// The lines of a block of type: document space → its line space, where each line spans a band +/// across the lines (its ascent above the baseline to its descent below). +struct Lines { + to_lines: Affine, + bands: Vec<(f64, f64)>, +} + +impl Lines { + /// The lines of `n` when it is type laid out in two lines or more (not on a path). + fn of(n: &Node) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + if t.xf.determinant().abs() < 1e-12 { + return None; + } + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + if layout.on_path { + return None; + } + let bands: Vec<(f64, f64)> = layout.lines.iter().filter(|l| l.x1 > l.x0).map(|l| (l.baseline - l.ascent, l.baseline + l.descent)).collect(); + let to_lines = if layout.vertical { layout.line_xf.inverse() * t.xf.inverse() } else { t.xf.inverse() }; + (bands.len() > 1).then_some(Lines { to_lines, bands }) + } + + /// The line nearest to the middle of `path`. + fn line_of(&self, path: &PathData) -> usize { + let Some(b) = path.bounds() else { return 0 }; + let y = (self.to_lines * b.center()).y; + let away = |&(lo, hi): &(f64, f64)| if y < lo { lo - y } else { (y - hi).max(0.0) }; + self.bands.iter().enumerate().min_by(|a, b| away(a.1).total_cmp(&away(b.1))).map_or(0, |(i, _)| i) + } } /// Where a group's image goes. @@ -97,7 +131,11 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: } None => None, }; - out.flat.push(Flat { roots: ids, regions, area, raster }); + let lines = match g.as_slice() { + [i] => Lines::of(&art[*i]), + _ => None, + }; + out.flat.push(Flat { roots: ids, regions, area, raster, lines }); } Ok(out) } @@ -366,7 +404,10 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan children.push(Arc::new(raster_node(d, r))); rasterized += 1; } + // Type over several lines gives a group per line. + let mut by_line: Vec>> = f.lines.as_ref().map_or(vec![], |l| vec![vec![]; l.bands.len()]); for (path, fill) in f.regions { + let line = f.lines.as_ref().map(|l| l.line_of(&path)); let mut n = shape_node(d, path, None); n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer { overprint: fill.overprint, ..FillLayer::new(fill.paint) })], @@ -374,9 +415,20 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan } .into(); n.opacity = fill.alpha.min(1.0); - children.push(Arc::new(n)); + match line.and_then(|i| by_line.get_mut(i)) { + Some(l) => l.push(Arc::new(n)), + None => children.push(Arc::new(n)), + } vector += 1; } + by_line.retain(|l| !l.is_empty()); + if by_line.len() > 1 { + for l in by_line { + children.push(Arc::new(Node::group(d.alloc_id(), l))); + } + } else { + children.extend(by_line.into_iter().flatten()); + } let g = Node::group(d.alloc_id(), children); ids.push(g.id); if one_pass { diff --git a/crates/engine/src/tests/flatten.rs b/crates/engine/src/tests/flatten.rs index e2b9b4f6a..d4671c11e 100644 --- a/crates/engine/src/tests/flatten.rs +++ b/crates/engine/src/tests/flatten.rs @@ -381,6 +381,33 @@ fn outlined_type_keeps_its_underline() { assert!(b.y1 > 151.0, "{b:?}"); } +/// Flattening transparent type that runs over several lines gives a group per line, each holding +/// that line's regions (#5252). +#[test] +fn flattening_multiline_transparent_text_gives_one_group_per_line() { + let _settings = settings_lock(); + let mut s = session(); + let text = + run(&mut s, "text.create", json!({"x": 10, "y": 40, "text": "First line\nSecond line\nThird line", "size": 24}))["id"].as_u64().unwrap(); + run(&mut s, "transparency.set", json!({"ids": [text], "opacity": 50})); + let NodeKind::Text(t) = &node(&s, text).kind else { panic!("type") }; + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let baselines: Vec = layout.baselines().iter().map(|(a, _)| (t.xf * *a).y).collect(); + assert_eq!(baselines.len(), 3, "{baselines:?}"); + let before = picture(&s); + let (ids, vector, _) = flatten(&mut s, json!({"ids": [text]})); + assert_eq!(ids.len(), 1); + assert!(vector > 0); + let g = node(&s, ids[0]); + let lines: Vec<&std::sync::Arc> = g.children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Group { .. })).collect(); + assert_eq!(lines.len(), 3, "expected one group per line, got {} children", g.children().unwrap().len()); + for (line, y) in lines.iter().zip(&baselines) { + let b = line.geometric_bounds().unwrap(); + assert!(b.y0 > y - 24.0 && b.y1 < y + 10.0, "line at baseline {y} has bounds {b:?}"); + assert_regions_match(&s, &before, line.id.0, 2, "line"); + } +} + #[test] fn outlined_type_keeps_its_gradient_on_the_outlines_its_transform_effect_moves() { let _settings = settings_lock();