diff --git a/crates/typst-library/src/layout/container.rs b/crates/typst-library/src/layout/container.rs index 28a561033..36b628643 100644 --- a/crates/typst-library/src/layout/container.rs +++ b/crates/typst-library/src/layout/container.rs @@ -146,15 +146,18 @@ impl Layout for BoxElem { frame.set_baseline(frame.baseline() - shift); } - // Clip the contents - if self.clip(styles) { - frame.clip(); - } - // Prepare fill and stroke. let fill = self.fill(styles); let stroke = self.stroke(styles).map(|s| s.map(Stroke::unwrap_or_default)); + // Clip the contents + if self.clip(styles) { + let outset = self.outset(styles).relative_to(frame.size()); + let size = frame.size() + outset.sum_by_axis(); + let radius = self.radius(styles); + frame.clip(path_rect(size, radius, &stroke)); + } + // Add fill and/or stroke. if fill.is_some() || stroke.iter().any(Option::is_some) { let outset = self.outset(styles); @@ -408,17 +411,20 @@ impl Layout for BlockElem { frames }; - // Clip the contents - if self.clip(styles) { - for frame in frames.iter_mut() { - frame.clip(); - } - } - // Prepare fill and stroke. let fill = self.fill(styles); let stroke = self.stroke(styles).map(|s| s.map(Stroke::unwrap_or_default)); + // Clip the contents + if self.clip(styles) { + for frame in frames.iter_mut() { + let outset = self.outset(styles).relative_to(frame.size()); + let size = frame.size() + outset.sum_by_axis(); + let radius = self.radius(styles); + frame.clip(path_rect(size, radius, &stroke)); + } + } + // Add fill and/or stroke. if fill.is_some() || stroke.iter().any(Option::is_some) { let mut skip = false; diff --git a/crates/typst-library/src/visualize/image.rs b/crates/typst-library/src/visualize/image.rs index b0c9d8ea5..05a9c352f 100644 --- a/crates/typst-library/src/visualize/image.rs +++ b/crates/typst-library/src/visualize/image.rs @@ -1,7 +1,7 @@ use std::ffi::OsStr; use std::path::Path; -use typst::geom::Smart; +use typst::geom::{self, Smart}; use typst::image::{Image, ImageFormat, RasterFormat, VectorFormat}; use typst::util::option_eq; @@ -212,7 +212,7 @@ impl Layout for ImageElem { // Create a clipping group if only part of the image should be visible. if fit == ImageFit::Cover && !target.fits(fitted) { - frame.clip(); + frame.clip(geom::Path::rect(frame.size())); } // Apply metadata. diff --git a/crates/typst/src/doc.rs b/crates/typst/src/doc.rs index dd206044a..6fddc2810 100644 --- a/crates/typst/src/doc.rs +++ b/crates/typst/src/doc.rs @@ -13,7 +13,7 @@ use crate::export::PdfPageLabel; use crate::font::Font; use crate::geom::{ self, styled_rect, Abs, Axes, Color, Corners, Dir, Em, FixedAlign, FixedStroke, - Geometry, Length, Numeric, Paint, Point, Rel, Shape, Sides, Size, Transform, + Geometry, Length, Numeric, Paint, Path, Point, Rel, Shape, Sides, Size, Transform, }; use crate::image::Image; use crate::model::{Content, Location, MetaElem, StyleChain}; @@ -351,10 +351,14 @@ impl Frame { } } - /// Clip the contents of a frame to its size. - pub fn clip(&mut self) { + /// Clip the contents of a frame to a clip path. + /// + /// The clip path can be the size of the frame in the case of a + /// rectangular frame. In the case of a frame with rounded corner, + /// this should be a path that matches the frame's outline. + pub fn clip(&mut self, clip_path: Path) { if !self.is_empty() { - self.group(|g| g.clips = true); + self.group(|g| g.clip_path = Some(clip_path)); } } @@ -505,7 +509,7 @@ pub struct GroupItem { /// A transformation to apply to the group. pub transform: Transform, /// Whether the frame should be a clipping boundary. - pub clips: bool, + pub clip_path: Option, } impl GroupItem { @@ -514,7 +518,7 @@ impl GroupItem { Self { frame, transform: Transform::identity(), - clips: false, + clip_path: None, } } } diff --git a/crates/typst/src/export/pdf/page.rs b/crates/typst/src/export/pdf/page.rs index 412d753da..04470aad1 100644 --- a/crates/typst/src/export/pdf/page.rs +++ b/crates/typst/src/export/pdf/page.rs @@ -462,14 +462,8 @@ fn write_group(ctx: &mut PageContext, pos: Point, group: &GroupItem) { ctx.size(group.frame.size()); } - if group.clips { - let size = group.frame.size(); - let w = size.x.to_f32(); - let h = size.y.to_f32(); - ctx.content.move_to(0.0, 0.0); - ctx.content.line_to(w, 0.0); - ctx.content.line_to(w, h); - ctx.content.line_to(0.0, h); + if let Some(clip_path) = &group.clip_path { + write_path(ctx, 0.0, 0.0, clip_path); ctx.content.clip_nonzero(); ctx.content.end_path(); } diff --git a/crates/typst/src/export/render.rs b/crates/typst/src/export/render.rs index 6fd47387d..090c9756b 100644 --- a/crates/typst/src/export/render.rs +++ b/crates/typst/src/export/render.rs @@ -183,13 +183,9 @@ fn render_group(canvas: &mut sk::Pixmap, state: State, group: &GroupItem) { let mut mask = state.mask; let storage; - if group.clips { - let size: geom::Axes = group.frame.size(); - let w = size.x.to_f32(); - let h = size.y.to_f32(); - if let Some(path) = sk::Rect::from_xywh(0.0, 0.0, w, h) - .map(sk::PathBuilder::from_rect) - .and_then(|path| path.transform(state.transform)) + if let Some(clip_path) = group.clip_path.as_ref() { + if let Some(path) = + convert_path(clip_path).and_then(|path| path.transform(state.transform)) { if let Some(mask) = mask { let mut mask = mask.clone(); diff --git a/crates/typst/src/export/svg.rs b/crates/typst/src/export/svg.rs index 25cb75197..6399f77d9 100644 --- a/crates/typst/src/export/svg.rs +++ b/crates/typst/src/export/svg.rs @@ -12,8 +12,9 @@ use crate::doc::{Frame, FrameItem, FrameKind, GroupItem, TextItem}; use crate::eval::Repr; use crate::font::Font; use crate::geom::{ - Abs, Angle, Axes, Color, FixedStroke, Geometry, Gradient, LineCap, LineJoin, Paint, - PathItem, Point, Quadrant, Ratio, RatioOrAngle, Relative, Shape, Size, Transform, + self, Abs, Angle, Axes, Color, FixedStroke, Geometry, Gradient, LineCap, LineJoin, + Paint, PathItem, Point, Quadrant, Ratio, RatioOrAngle, Relative, Shape, Size, + Transform, }; use crate::image::{Image, ImageFormat, RasterFormat, VectorFormat}; use crate::util::hash128; @@ -269,16 +270,9 @@ impl SVGRenderer { self.xml.start_element("g"); self.xml.write_attribute("class", "typst-group"); - if group.clips { + if let Some(clip_path) = &group.clip_path { let hash = hash128(&group); - let size = group.frame.size(); - let x = size.x.to_pt(); - let y = size.y.to_pt(); - let id = self.clip_paths.insert_with(hash, || { - let mut builder = SvgPathBuilder(EcoString::new()); - builder.rect(x as f32, y as f32); - builder.0 - }); + let id = self.clip_paths.insert_with(hash, || convert_path(clip_path)); self.xml.write_attribute_fmt("clip-path", format_args!("url(#{id})")); } @@ -1014,31 +1008,35 @@ fn convert_geometry_to_path(geometry: &Geometry) -> EcoString { let y = rect.y.to_pt() as f32; builder.rect(x, y); } - Geometry::Path(p) => { - for item in &p.0 { - match item { - PathItem::MoveTo(m) => { - builder.move_to(m.x.to_pt() as f32, m.y.to_pt() as f32) - } - PathItem::LineTo(l) => { - builder.line_to(l.x.to_pt() as f32, l.y.to_pt() as f32) - } - PathItem::CubicTo(c1, c2, t) => builder.curve_to( - c1.x.to_pt() as f32, - c1.y.to_pt() as f32, - c2.x.to_pt() as f32, - c2.y.to_pt() as f32, - t.x.to_pt() as f32, - t.y.to_pt() as f32, - ), - PathItem::ClosePath => builder.close(), - } - } - } + Geometry::Path(p) => return convert_path(p), }; builder.0 } +fn convert_path(path: &geom::Path) -> EcoString { + let mut builder = SvgPathBuilder::default(); + for item in &path.0 { + match item { + PathItem::MoveTo(m) => { + builder.move_to(m.x.to_pt() as f32, m.y.to_pt() as f32) + } + PathItem::LineTo(l) => { + builder.line_to(l.x.to_pt() as f32, l.y.to_pt() as f32) + } + PathItem::CubicTo(c1, c2, t) => builder.curve_to( + c1.x.to_pt() as f32, + c1.y.to_pt() as f32, + c2.x.to_pt() as f32, + c2.y.to_pt() as f32, + t.x.to_pt() as f32, + t.y.to_pt() as f32, + ), + PathItem::ClosePath => builder.close(), + } + } + builder.0 +} + /// Encode an image into a data URL. The format of the URL is /// `data:image/{format};base64,`. #[comemo::memoize] diff --git a/crates/typst/src/geom/mod.rs b/crates/typst/src/geom/mod.rs index 105ee5a30..8ad6cea09 100644 --- a/crates/typst/src/geom/mod.rs +++ b/crates/typst/src/geom/mod.rs @@ -46,7 +46,7 @@ pub use self::paint::Paint; pub use self::path::{Path, PathItem}; pub use self::point::Point; pub use self::ratio::Ratio; -pub use self::rect::styled_rect; +pub use self::rect::{path_rect, styled_rect}; pub use self::rel::Rel; pub use self::scalar::Scalar; pub use self::shape::{Geometry, Shape}; diff --git a/crates/typst/src/geom/rect.rs b/crates/typst/src/geom/rect.rs index 108f5addb..37b94527f 100644 --- a/crates/typst/src/geom/rect.rs +++ b/crates/typst/src/geom/rect.rs @@ -42,6 +42,19 @@ impl PathExtension for Path { } } +/// Creates a new rectangle as a path. +pub fn path_rect( + size: Size, + radius: Corners>, + stroke: &Sides>, +) -> Path { + if stroke.is_uniform() && radius.iter().cloned().all(Rel::is_zero) { + Path::rect(size) + } else { + segmented_path_rect(size, radius, stroke) + } +} + /// Create a styled rectangle with shapes. /// - use rect primitive for simple rectangles /// - stroke sides if possible @@ -68,24 +81,13 @@ fn simple_rect( vec![Shape { geometry: Geometry::Rect(size), fill, stroke }] } -/// Use stroke and fill for the rectangle -fn segmented_rect( +fn corners_control_points( size: Size, - radius: Corners>, - fill: Option, - strokes: Sides>, -) -> Vec { - let mut res = vec![]; - let stroke_widths = strokes - .clone() - .map(|s| s.map(|s| s.thickness / 2.0).unwrap_or(Abs::zero())); - - let max_radius = (size.x.min(size.y)) / 2.0 - + stroke_widths.iter().cloned().min().unwrap_or(Abs::zero()); - - let radius = radius.map(|side| side.relative_to(max_radius * 2.0).min(max_radius)); - - let corners = Corners { + radius: Corners, + strokes: &Sides>, + stroke_widths: Sides, +) -> Corners { + Corners { top_left: Corner::TopLeft, top_right: Corner::TopRight, bottom_right: Corner::BottomRight, @@ -105,7 +107,67 @@ fn segmented_rect( (None, None) => true, _ => false, }, - }); + }) +} + +fn segmented_path_rect( + size: Size, + radius: Corners>, + strokes: &Sides>, +) -> Path { + let stroke_widths = strokes + .as_ref() + .map(|s| s.as_ref().map_or(Abs::zero(), |s| s.thickness / 2.0)); + + let max_radius = (size.x.min(size.y)) / 2.0 + + stroke_widths.iter().cloned().min().unwrap_or(Abs::zero()); + + let radius = radius.map(|side| side.relative_to(max_radius * 2.0).min(max_radius)); + + // insert stroked sides below filled sides + let mut path = Path::new(); + let corners = corners_control_points(size, radius, strokes, stroke_widths); + let current = corners.iter().find(|c| !c.same).map(|c| c.corner); + if let Some(mut current) = current { + // multiple segments + // start at a corner with a change between sides and iterate clockwise all other corners + let mut last = current; + for _ in 0..4 { + current = current.next_cw(); + if corners.get_ref(current).same { + continue; + } + // create segment + let start = last; + let end = current; + last = current; + path_segment(start, end, &corners, &mut path); + } + } else if strokes.top.is_some() { + // single segment + path_segment(Corner::TopLeft, Corner::TopLeft, &corners, &mut path); + } + path +} + +/// Use stroke and fill for the rectangle +fn segmented_rect( + size: Size, + radius: Corners>, + fill: Option, + strokes: Sides>, +) -> Vec { + let mut res = vec![]; + let stroke_widths = strokes + .as_ref() + .map(|s| s.as_ref().map_or(Abs::zero(), |s| s.thickness / 2.0)); + + let max_radius = (size.x.min(size.y)) / 2.0 + + stroke_widths.iter().cloned().min().unwrap_or(Abs::zero()); + + let radius = radius.map(|side| side.relative_to(max_radius * 2.0).min(max_radius)); + + let corners = corners_control_points(size, radius, &strokes, stroke_widths); // insert stroked sides below filled sides let mut stroke_insert = 0; @@ -171,6 +233,43 @@ fn segmented_rect( res } +fn path_segment( + start: Corner, + end: Corner, + corners: &Corners, + path: &mut Path, +) { + // create start corner + let c = corners.get_ref(start); + if start == end || !c.arc() { + path.move_to(c.end()); + } else { + path.arc_move(c.mid(), c.center(), c.end()); + } + + // create corners between start and end + let mut current = start.next_cw(); + while current != end { + let c = corners.get_ref(current); + if c.arc() { + path.arc_line(c.start(), c.center(), c.end()); + } else { + path.line_to(c.end()); + } + current = current.next_cw(); + } + + // create end corner + let c = corners.get_ref(end); + if !c.arc() { + path.line_to(c.start()); + } else if start == end { + path.arc_line(c.start(), c.center(), c.end()); + } else { + path.arc_line(c.start(), c.center(), c.mid()); + } +} + /// Returns the shape for the segment and whether the shape should be drawn on top. fn segment( start: Corner, @@ -228,35 +327,8 @@ fn stroke_segment( stroke: FixedStroke, ) -> Shape { // create start corner - let c = corners.get_ref(start); let mut path = Path::new(); - if start == end || !c.arc() { - path.move_to(c.end()); - } else { - path.arc_move(c.mid(), c.center(), c.end()); - } - - // create corners between start and end - let mut current = start.next_cw(); - while current != end { - let c = corners.get_ref(current); - if c.arc() { - path.arc_line(c.start(), c.center(), c.end()); - } else { - path.line_to(c.end()); - } - current = current.next_cw(); - } - - // create end corner - let c = corners.get_ref(end); - if !c.arc() { - path.line_to(c.start()); - } else if start == end { - path.arc_line(c.start(), c.center(), c.end()); - } else { - path.arc_line(c.start(), c.center(), c.mid()); - } + path_segment(start, end, corners, &mut path); Shape { geometry: Geometry::Path(path), diff --git a/crates/typst/src/geom/sides.rs b/crates/typst/src/geom/sides.rs index e21fe63f6..38477f36b 100644 --- a/crates/typst/src/geom/sides.rs +++ b/crates/typst/src/geom/sides.rs @@ -46,6 +46,16 @@ impl Sides { } } + /// Convert from `&Sides` to `Sides<&T>`. + pub fn as_ref(&self) -> Sides<&T> { + Sides { + left: &self.left, + top: &self.top, + right: &self.right, + bottom: &self.bottom, + } + } + /// Zip two instances into one. pub fn zip(self, other: Sides) -> Sides<(T, U)> { Sides { diff --git a/tests/ref/layout/clip.png b/tests/ref/layout/clip.png index 53565a982..c847fc63d 100644 Binary files a/tests/ref/layout/clip.png and b/tests/ref/layout/clip.png differ diff --git a/tests/typ/layout/clip.typ b/tests/typ/layout/clip.typ index 3baa8b80d..d05fdb74d 100644 --- a/tests/typ/layout/clip.typ +++ b/tests/typ/layout/clip.typ @@ -7,13 +7,13 @@ world 1 Space -Hello #box(width: 1em, height: 1em, clip: true)[#rect(width: 3em, height: 3em, fill: red)] +Hello #box(width: 1em, height: 1em, clip: true)[#rect(width: 3em, height: 3em, fill: red)] world 2 --- // Test cliping text #block(width: 5em, height: 2em, clip: false, stroke: 1pt + black)[ - But, soft! what light through + But, soft! what light through ] #v(2em) @@ -24,7 +24,7 @@ world 2 ] --- -// Test cliping svg glyphs +// Test clipping svg glyphs Emoji: #box(height: 0.5em, stroke: 1pt + black)[🐪, 🌋, 🏞] Emoji: #box(height: 0.5em, clip: true, stroke: 1pt + black)[🐪, 🌋, 🏞] @@ -40,3 +40,17 @@ First! But, soft! what light through yonder window breaks? It is the east, and Juliet is the sun. ] + +--- +// Test clipping with `radius`. + +#set page(height: 60pt) + +#box( + radius: 5pt, + stroke: 2pt + black, + width: 20pt, + height: 20pt, + clip: true, + image("/files/rhino.png", width: 30pt) +)