mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 19:26:46 -06:00
Merge PR #577
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user