This commit is contained in:
Brandon Thomas
2026-10-14 22:05:10 -04:00
6 changed files with 169 additions and 23 deletions
+1
View File
@@ -182,6 +182,7 @@ Newest first.
- **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change.
- **2026-10-12** · The section marker character shows its section's marker text on document pages (nothing when the section has none) instead of the word "Section", which stays on parent pages; editing the marker recomposes (#556). No readiness percentage change.
- **2026-10-12** · On screen, blend modes blend with the objects below and not with the paper, as in InDesign and in PDF export: a Color Dodge or Screen object over blank paper shows as it is. No readiness percentage change.
- **2026-10-12** · A gradient feather on a group fades the group as one, on screen and in PDF. Before, the screen ignored it, and PDF masked the group with a box of zero size because a group has no path of its own. No readiness percentage change.
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
- **2026-10-12** · Load Swatches refuses an `.ase` file with more than 100,000 colors, and `swatch.load` merges a file of 100,000 colors in under 0.1 s (it took 9.6 s of CPU in a test build). No readiness percentage change.
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
+46
View File
@@ -1109,6 +1109,29 @@ impl Item {
self.path.bounds().unwrap_or(Rect::ZERO)
}
/// The box a gradient feather spans, in item space: the path's bounds, or a group's visible
/// objects' (a group has no path of its own). Only the active states contribute, including in
/// nested groups. Walk iteratively so deeply nested documents don't exhaust the call stack.
pub fn feather_bounds(&self) -> Rect {
if !self.is_group() {
return self.inner_bounds();
}
let mut stack: Vec<_> = self.shown_children().map(|c| (c.as_ref(), c.xf)).collect();
let mut bounds: Option<Rect> = None;
while let Some((it, xf)) = stack.pop() {
if it.hidden {
continue;
}
if it.is_group() {
stack.extend(it.shown_children().map(|c| (c.as_ref(), xf * c.xf)));
} else {
let b = xf.transform_rect_bbox(it.inner_bounds());
bounds = Some(bounds.map_or(b, |have| have.union(b)));
}
}
bounds.unwrap_or(Rect::ZERO)
}
/// Spread-space geometric bounds (axis-aligned box of the transformed path; groups: union).
/// A group (its bounds are its children's). A frame holding pasted-into items is not.
pub fn is_group(&self) -> bool {
@@ -1265,6 +1288,29 @@ mod tests {
assert_eq!(n, 3);
}
#[test]
fn feather_bounds_follow_visible_states_in_nested_groups() {
let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(10.0, 20.0, 30.0, 40.0)));
let mut b = a.clone();
b.id = ItemId(2);
b.xf = Affine::translate((500.0, 500.0));
let mut state = Item::new(ItemId(3), LayerId(1), Shape::Group, PathData::default());
state.content = Content::Group { items: vec![Arc::new(a), Arc::new(b)] };
state.states = vec!["Visible".into(), "Inactive".into()];
assert_eq!(state.feather_bounds(), Rect::new(10.0, 20.0, 30.0, 40.0));
state.active_state = 1;
assert_eq!(state.feather_bounds(), Rect::new(510.0, 520.0, 530.0, 540.0));
state.active_state = 0;
state.xf = Affine::translate((5.0, 10.0)) * Affine::scale(2.0);
let mut hidden = state.clone();
hidden.id = ItemId(4);
hidden.hidden = true;
hidden.xf = Affine::translate((-1000.0, -1000.0));
let mut outer = Item::new(ItemId(5), LayerId(1), Shape::Group, PathData::default());
outer.content = Content::Group { items: vec![Arc::new(state), Arc::new(hidden)] };
assert_eq!(outer.feather_bounds(), Rect::new(25.0, 50.0, 65.0, 90.0));
}
#[test]
fn serde_roundtrip() {
let mut it = Item::new(ItemId(1), LayerId(1), Shape::Oval, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)));
+83
View File
@@ -435,6 +435,89 @@ mod tests {
assert!(t.contains("/SMask") || t.contains("/Luminosity"), "a soft mask");
}
#[test]
fn a_groups_gradient_feather_fades_it_on_screen_and_in_pdf() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let mut ids = vec![];
for rect in [[100, 100, 200, 200], [200, 100, 300, 200]] {
let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].as_u64().unwrap();
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
ids.push(id);
}
let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap();
s.execute("transform.move", &json!({"ids": [g], "dx": 20, "dy": 30})).unwrap();
// The Gradient Feather tool's drag, then by angle (a group has no path of its own: the
// gradient spans its objects). The gradient still follows the translated group even
// though the mask geometry covers the whole output.
for p in [json!({"ids": [g], "from": [120, 180], "to": [320, 180]}), json!({"ids": [g], "angle": 0})] {
s.execute("object.gradientFeather", &p).unwrap();
let d = s.doc().unwrap().doc.clone();
let mut rr = designcraft_render::Renderer::new();
rr.threads = 0;
let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap();
let (l, m, r) = (img.pixel(125, 180)[0], img.pixel(220, 180)[0], img.pixel(315, 180)[0]);
assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right on screen: {l} {m} {r}");
let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap();
let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap();
// Over white paper.
let at = |x: usize, y: usize| {
let c = pm.data()[y * pm.width() as usize + x];
c.r as u32 + 255 - c.a as u32
};
let (l, m, r) = (at(125, 180), at(220, 180), at(315, 180));
assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right in PDF: {l} {m} {r}");
}
}
#[test]
fn group_gradient_feather_keeps_distant_shadows_and_thick_strokes() {
for shadow in [false, true] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let mut ids = vec![];
for rect in [[120, 120, 170, 170], [175, 120, 225, 170]] {
let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].as_u64().unwrap();
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
if shadow {
s.execute("object.dropShadow", &json!({"ids": [id], "on": true, "distance": 180, "angle": 90, "size": 0, "opacity": 1})).unwrap();
} else {
s.execute("object.stroke", &json!({"ids": [id], "swatch": "[Black]", "weight": 180})).unwrap();
}
ids.push(id);
}
let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap();
// Translate the group so mask geometry and gradient paint exercise distinct spaces.
s.execute("transform.move", &json!({"ids": [g], "dx": 30, "dy": 20})).unwrap();
let point = if shadow { (175, 345) } else { (175, 60) };
let sample = |s: &Session| {
let d = &s.doc().unwrap().doc;
let mut rr = designcraft_render::Renderer::new();
rr.threads = 0;
// Zoomed viewport: the mask must extend over every device pixel.
let img = rr.render_page(d, &s.cache, 0, 2.0, false, &Default::default()).unwrap();
let screen = img.pixel(point.0 * 2, point.1 * 2)[0];
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &Default::default()).unwrap();
let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap();
let c = pm.data()[point.1 as usize * pm.width() as usize + point.0 as usize];
[f32::from(screen), c.r as f32 + 255.0 - c.a as f32]
};
let before = sample(&s);
// The sample lies past either end of the gradient, and outside the old fixed padding.
s.execute("object.gradientFeather", &json!({"ids": [g], "from": [130, 120], "to": [130, 220], "start": 40, "end": 80})).unwrap();
let after = sample(&s);
let opacity = if shadow { 0.8 } else { 0.4 };
for (kind, (before, after)) in ["screen", "PDF"].into_iter().zip(before.into_iter().zip(after)) {
assert!(before < 120.0, "{kind}, shadow={shadow}: sample contains the effect before feathering: {before}");
let expected = 255.0 - (255.0 - before) * opacity;
assert!(
(after - expected).abs() < 16.0,
"{kind}, shadow={shadow}: end opacity continues past geometry: {after}, expected {expected}"
);
}
}
}
#[test]
fn create_outlines_turns_text_into_paths() {
let mut s = Session::new();
+6 -7
View File
@@ -765,7 +765,7 @@ impl Exporter<'_> {
}
// Gradient feather: a luminosity mask (white = opaque) over the object.
let xf = parent * it.xf;
let (p0, p1) = gf.points(it.inner_bounds());
let (p0, p1) = gf.points(it.feather_bounds());
let gray = |a: f32| -> krilla::color::Color { luma::Color::new((a.clamp(0.0, 1.0) * 255.0).round() as u8).into() };
let stops = vec![
Stop { offset: NormalizedF32::ZERO, color: gray(gf.start), opacity: NormalizedF32::ONE },
@@ -780,7 +780,7 @@ impl Exporter<'_> {
cx: p0.x as f32,
cy: p0.y as f32,
cr: r,
transform: Transform::identity(),
transform: tf(xf),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
@@ -792,22 +792,21 @@ impl Exporter<'_> {
y1: p0.y as f32,
x2: p1.x as f32,
y2: p1.y as f32,
transform: Transform::identity(),
transform: tf(xf),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into()
};
let area = it.inner_bounds().inflate(it.stroke.extent() + 4.0, it.stroke.extent() + 4.0);
// The gradient keeps its item-space geometry, but its mask covers the complete exported
// area (including bleed), so distant child shadows and thick strokes are never cut off.
let mut sb = s.stream_builder();
let mut ms = sb.surface();
ms.push_transform(&tf(xf));
if let Some(p) = to_path(&area.to_path(0.1)) {
if let Some(p) = to_path(&self.clip.to_path(0.1)) {
ms.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
ms.draw_path(&p);
}
ms.pop();
ms.finish();
let stream = sb.finish();
s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity));
+23 -16
View File
@@ -64,6 +64,28 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter {
})
}
/// Keep what was drawn in the current layer as much as `it`'s gradient feather says. Cover the
/// whole viewport: strokes, text and child effects can extend arbitrarily past the geometry,
/// and past the gradient's ends its end opacities carry on.
pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine) {
let gf = &it.effects.gradient_feather;
let (p0, p1) = gf.points(it.feather_bounds());
let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8)));
let stops = [stop(0.0, gf.start), stop(1.0, gf.end)];
let grad = if gf.radial {
peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice())
} else {
peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice())
};
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
ctx.set_paint(grad);
ctx.set_paint_transform(f.view * xf);
ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height())));
ctx.reset_paint_transform();
ctx.pop_layer();
}
impl Renderer {
/// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow.
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option<usize>) {
@@ -170,22 +192,7 @@ impl Renderer {
}
}
if gf.on {
let (p0, p1) = gf.points(it.inner_bounds());
let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8)));
let stops = [stop(0.0, gf.start), stop(1.0, gf.end)];
let grad = if gf.radial {
peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice())
} else {
peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice())
};
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
ctx.set_transform(f.view * xf);
ctx.set_paint(grad);
let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { it.inner_bounds() };
ctx.fill_rect(&area.inflate(4.0, 4.0));
ctx.reset_paint_transform();
ctx.pop_layer();
gradient_feather_mask(ctx, f, it, xf);
}
if gf.on || directional {
ctx.pop_layer();
+10
View File
@@ -486,6 +486,12 @@ impl Renderer {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None);
}
// Gradient feather: the group's objects drawn together, then faded as one.
let feather = it.effects.gradient_feather.on && f.opts.quality != DisplayQuality::Fast;
if feather {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, None, None, None);
}
if it.knockout {
// Knockout: each object first clears its area of those behind it, so it composites
// with the group's backdrop rather than with its siblings.
@@ -505,6 +511,10 @@ impl Renderer {
self.draw_item(ctx, f, c, xf, page);
}
}
if feather {
fx::gradient_feather_mask(ctx, f, it, xf);
ctx.pop_layer();
}
if layered {
ctx.pop_layer();
}