diff options
Diffstat (limited to 'crates/typst-library/src/visualize')
| -rw-r--r-- | crates/typst-library/src/visualize/image.rs | 197 | ||||
| -rw-r--r-- | crates/typst-library/src/visualize/line.rs | 118 | ||||
| -rw-r--r-- | crates/typst-library/src/visualize/mod.rs | 45 | ||||
| -rw-r--r-- | crates/typst-library/src/visualize/path.rs | 211 | ||||
| -rw-r--r-- | crates/typst-library/src/visualize/polygon.rs | 93 | ||||
| -rw-r--r-- | crates/typst-library/src/visualize/shape.rs | 569 |
6 files changed, 1233 insertions, 0 deletions
diff --git a/crates/typst-library/src/visualize/image.rs b/crates/typst-library/src/visualize/image.rs new file mode 100644 index 00000000..b169cc4d --- /dev/null +++ b/crates/typst-library/src/visualize/image.rs @@ -0,0 +1,197 @@ +use std::ffi::OsStr; +use std::path::Path; + +use typst::image::{Image, ImageFormat, RasterFormat, VectorFormat}; +use typst::util::Bytes; + +use crate::meta::{Figurable, LocalName}; +use crate::prelude::*; +use crate::text::families; + +/// A raster or vector graphic. +/// +/// Supported formats are PNG, JPEG, GIF and SVG. +/// +/// _Note:_ Work on SVG export is ongoing and there might be visual inaccuracies +/// in the resulting PDF. Make sure to double-check embedded SVG images. If you +/// have an issue, also feel free to report it on [GitHub][gh-svg]. +/// +/// ## Example { #example } +/// ```example +/// #figure( +/// image("molecular.jpg", width: 80%), +/// caption: [ +/// A step in the molecular testing +/// pipeline of our lab. +/// ], +/// ) +/// ``` +/// +/// [gh-svg]: https://github.com/typst/typst/issues?q=is%3Aopen+is%3Aissue+label%3Asvg +/// +/// Display: Image +/// Category: visualize +#[element(Layout, LocalName, Figurable)] +pub struct ImageElem { + /// Path to an image file. + #[required] + #[parse( + let Spanned { v: path, span } = + args.expect::<Spanned<EcoString>>("path to image file")?; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + path + )] + pub path: EcoString, + + /// The raw file data. + #[internal] + #[required] + #[parse(data)] + pub data: Bytes, + + /// The width of the image. + pub width: Smart<Rel<Length>>, + + /// The height of the image. + pub height: Smart<Rel<Length>>, + + /// A text describing the image. + pub alt: Option<EcoString>, + + /// How the image should adjust itself to a given area. + #[default(ImageFit::Cover)] + pub fit: ImageFit, +} + +impl Layout for ImageElem { + #[tracing::instrument(name = "ImageElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let ext = Path::new(self.path().as_str()) + .extension() + .and_then(OsStr::to_str) + .unwrap_or_default() + .to_lowercase(); + + let format = match ext.as_str() { + "png" => ImageFormat::Raster(RasterFormat::Png), + "jpg" | "jpeg" => ImageFormat::Raster(RasterFormat::Jpg), + "gif" => ImageFormat::Raster(RasterFormat::Gif), + "svg" | "svgz" => ImageFormat::Vector(VectorFormat::Svg), + _ => bail!(self.span(), "unknown image format"), + }; + + let image = Image::with_fonts( + self.data(), + format, + vt.world, + families(styles).next().as_ref().map(|f| f.as_str()), + self.alt(styles), + ) + .at(self.span())?; + + let sizing = Axes::new(self.width(styles), self.height(styles)); + let region = sizing + .zip(regions.base()) + .map(|(s, r)| s.map(|v| v.resolve(styles).relative_to(r))) + .unwrap_or(regions.base()); + + let expand = sizing.as_ref().map(Smart::is_custom) | regions.expand; + let region_ratio = region.x / region.y; + + // Find out whether the image is wider or taller than the target size. + let pxw = image.width() as f64; + let pxh = image.height() as f64; + let px_ratio = pxw / pxh; + let wide = px_ratio > region_ratio; + + // The space into which the image will be placed according to its fit. + let target = if expand.x && expand.y { + region + } else if expand.x || (!expand.y && wide && region.x.is_finite()) { + Size::new(region.x, region.y.min(region.x.safe_div(px_ratio))) + } else if region.y.is_finite() { + Size::new(region.x.min(region.y * px_ratio), region.y) + } else { + Size::new(Abs::pt(pxw), Abs::pt(pxh)) + }; + + // Compute the actual size of the fitted image. + let fit = self.fit(styles); + let fitted = match fit { + ImageFit::Cover | ImageFit::Contain => { + if wide == (fit == ImageFit::Contain) { + Size::new(target.x, target.x / px_ratio) + } else { + Size::new(target.y * px_ratio, target.y) + } + } + ImageFit::Stretch => target, + }; + + // First, place the image in a frame of exactly its size and then resize + // the frame to the target size, center aligning the image in the + // process. + let mut frame = Frame::new(fitted); + frame.push(Point::zero(), FrameItem::Image(image, fitted, self.span())); + frame.resize(target, Align::CENTER_HORIZON); + + // Create a clipping group if only part of the image should be visible. + if fit == ImageFit::Cover && !target.fits(fitted) { + frame.clip(); + } + + // Apply metadata. + frame.meta(styles, false); + + Ok(Fragment::frame(frame)) + } +} + +impl LocalName for ImageElem { + fn local_name(&self, lang: Lang, _: Option<Region>) -> &'static str { + match lang { + Lang::ALBANIAN => "Figurë", + Lang::ARABIC => "شكل", + Lang::BOKMÅL => "Figur", + Lang::CHINESE => "图", + Lang::CZECH => "Obrázek", + Lang::DANISH => "Figur", + Lang::DUTCH => "Figuur", + Lang::FILIPINO => "Pigura", + Lang::FRENCH => "Figure", + Lang::GERMAN => "Abbildung", + Lang::ITALIAN => "Figura", + Lang::NYNORSK => "Figur", + Lang::POLISH => "Rysunek", + Lang::PORTUGUESE => "Figura", + Lang::RUSSIAN => "Рисунок", + Lang::SLOVENIAN => "Slika", + Lang::SPANISH => "Figura", + Lang::SWEDISH => "Figur", + Lang::TURKISH => "Şekil", + Lang::UKRAINIAN => "Рисунок", + Lang::VIETNAMESE => "Hình", + Lang::ENGLISH | _ => "Figure", + } + } +} + +impl Figurable for ImageElem {} + +/// How an image should adjust itself to a given area. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum ImageFit { + /// The image should completely cover the area. This is the default. + Cover, + /// The image should be fully contained in the area. + Contain, + /// The image should be stretched so that it exactly fills the area, even if + /// this means that the image will be distorted. + Stretch, +} diff --git a/crates/typst-library/src/visualize/line.rs b/crates/typst-library/src/visualize/line.rs new file mode 100644 index 00000000..62a381a9 --- /dev/null +++ b/crates/typst-library/src/visualize/line.rs @@ -0,0 +1,118 @@ +use crate::prelude::*; + +/// A line from one point to another. +/// +/// ## Example { #example } +/// ```example +/// #set page(height: 100pt) +/// +/// #line(length: 100%) +/// #line(end: (50%, 50%)) +/// #line( +/// length: 4cm, +/// stroke: 2pt + maroon, +/// ) +/// ``` +/// +/// Display: Line +/// Category: visualize +#[element(Layout)] +pub struct LineElem { + /// The start point of the line. + /// + /// Must be an array of exactly two relative lengths. + #[resolve] + pub start: Axes<Rel<Length>>, + + /// The offset from `start` where the line ends. + #[resolve] + pub end: Option<Axes<Rel<Length>>>, + + /// The line's length. This is only respected if `end` is `none`. + #[resolve] + #[default(Abs::pt(30.0).into())] + pub length: Rel<Length>, + + /// The angle at which the line points away from the origin. This is only + /// respected if `end` is `none`. + pub angle: Angle, + + /// How to stroke the line. This can be: + /// + /// - A length specifying the stroke's thickness. The color is inherited, + /// defaulting to black. + /// - A color to use for the stroke. The thickness is inherited, defaulting + /// to `{1pt}`. + /// - A stroke combined from color and thickness using the `+` operator as + /// in `{2pt + red}`. + /// - A stroke described by a dictionary with any of the following keys: + /// - `paint`: The [color]($type/color) to use for the stroke. + /// - `thickness`: The stroke's thickness as a [length]($type/length). + /// - `cap`: How the line terminates. One of `{"butt"}`, `{"round"}`, or + /// `{"square"}`. + /// - `join`: How sharp turns of a contour are rendered. One of + /// `{"miter"}`, `{"round"}`, or `{"bevel"}`. Not applicable to lines + /// but to [polygons]($func/polygon) or [paths]($func/path). + /// - `miter-limit`: Number at which protruding sharp angles are rendered + /// with a bevel instead. The higher the number, the sharper an angle + /// can be before it is bevelled. Only applicable if `join` is + /// `{"miter"}`. Defaults to `{4.0}`. + /// - `dash`: The dash pattern to use. Can be any of the following: + /// - One of the predefined patterns `{"solid"}`, `{"dotted"}`, + /// `{"densely-dotted"}`, `{"loosely-dotted"}`, `{"dashed"}`, + /// `{"densely-dashed"}`, `{"loosely-dashed"}`, `{"dash-dotted"}`, + /// `{"densely-dash-dotted"}` or `{"loosely-dash-dotted"}` + /// - An [array]($type/array) with alternating lengths for dashes and + /// gaps. You can also use the string `{"dot"}` for a length equal to + /// the line thickness. + /// - A [dictionary]($type/dictionary) with the keys `array` (same as + /// the array above), and `phase` (of type [length]($type/length)), + /// which defines where in the pattern to start drawing. + /// + /// ```example + /// #set line(length: 100%) + /// #stack( + /// spacing: 1em, + /// line(stroke: 2pt + red), + /// line(stroke: (paint: blue, thickness: 4pt, cap: "round")), + /// line(stroke: (paint: blue, thickness: 1pt, dash: "dashed")), + /// line(stroke: (paint: blue, thickness: 1pt, dash: ("dot", 2pt, 4pt, 2pt))), + /// ) + /// ``` + #[resolve] + #[fold] + pub stroke: PartialStroke, +} + +impl Layout for LineElem { + #[tracing::instrument(name = "LineElem::layout", skip_all)] + fn layout( + &self, + _: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let resolve = |axes: Axes<Rel<Abs>>| { + axes.zip(regions.base()).map(|(l, b)| l.relative_to(b)) + }; + + let start = resolve(self.start(styles)); + let delta = + self.end(styles).map(|end| resolve(end) - start).unwrap_or_else(|| { + let length = self.length(styles); + let angle = self.angle(styles); + let x = angle.cos() * length; + let y = angle.sin() * length; + resolve(Axes::new(x, y)) + }); + + let stroke = self.stroke(styles).unwrap_or_default(); + let size = start.max(start + delta).max(Size::zero()); + let target = regions.expand.select(regions.size, size); + + let mut frame = Frame::new(target); + let shape = Geometry::Line(delta.to_point()).stroked(stroke); + frame.push(start.to_point(), FrameItem::Shape(shape, self.span())); + Ok(Fragment::frame(frame)) + } +} diff --git a/crates/typst-library/src/visualize/mod.rs b/crates/typst-library/src/visualize/mod.rs new file mode 100644 index 00000000..ea873f44 --- /dev/null +++ b/crates/typst-library/src/visualize/mod.rs @@ -0,0 +1,45 @@ +//! Drawing and visualization. + +mod image; +mod line; +mod path; +mod polygon; +mod shape; + +pub use self::image::*; +pub use self::line::*; +pub use self::path::*; +pub use self::polygon::*; +pub use self::shape::*; + +use crate::prelude::*; + +/// Hook up all visualize definitions. +pub(super) fn define(global: &mut Scope) { + global.define("image", ImageElem::func()); + global.define("line", LineElem::func()); + global.define("rect", RectElem::func()); + global.define("square", SquareElem::func()); + global.define("ellipse", EllipseElem::func()); + global.define("circle", CircleElem::func()); + global.define("polygon", PolygonElem::func()); + global.define("path", PathElem::func()); + global.define("black", Color::BLACK); + global.define("gray", Color::GRAY); + global.define("silver", Color::SILVER); + global.define("white", Color::WHITE); + global.define("navy", Color::NAVY); + global.define("blue", Color::BLUE); + global.define("aqua", Color::AQUA); + global.define("teal", Color::TEAL); + global.define("eastern", Color::EASTERN); + global.define("purple", Color::PURPLE); + global.define("fuchsia", Color::FUCHSIA); + global.define("maroon", Color::MAROON); + global.define("red", Color::RED); + global.define("orange", Color::ORANGE); + global.define("yellow", Color::YELLOW); + global.define("olive", Color::OLIVE); + global.define("green", Color::GREEN); + global.define("lime", Color::LIME); +} diff --git a/crates/typst-library/src/visualize/path.rs b/crates/typst-library/src/visualize/path.rs new file mode 100644 index 00000000..d78abce1 --- /dev/null +++ b/crates/typst-library/src/visualize/path.rs @@ -0,0 +1,211 @@ +use kurbo::{CubicBez, ParamCurveExtrema}; +use typst::eval::Reflect; + +use crate::prelude::*; + +use PathVertex::{AllControlPoints, MirroredControlPoint, Vertex}; + +/// A path through a list of points, connected by Bezier curves. +/// +/// ## Example { #example } +/// ```example +/// #path( +/// fill: blue.lighten(80%), +/// stroke: blue, +/// closed: true, +/// (0pt, 50pt), +/// (100%, 50pt), +/// ((50%, 0pt), (40pt, 0pt)), +/// ) +/// ``` +/// +/// Display: Path +/// Category: visualize +#[element(Layout)] +pub struct PathElem { + /// How to fill the path. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + /// + /// Currently all paths are filled according to the + /// [non-zero winding rule](https://en.wikipedia.org/wiki/Nonzero-rule). + pub fill: Option<Paint>, + + /// How to stroke the path. This can be: + /// + /// See the [line's documentation]($func/line.stroke) for more details. Can + /// be set to `{none}` to disable the stroke or to `{auto}` for a stroke of + /// `{1pt}` black if and if only if no fill is given. + #[resolve] + #[fold] + pub stroke: Smart<Option<PartialStroke>>, + + /// Whether to close this path with one last bezier curve. This curve will + /// takes into account the adjacent control points. If you want to close + /// with a straight line, simply add one last point that's the same as the + /// start point. + #[default(false)] + pub closed: bool, + + /// The vertices of the path. + /// + /// Each vertex can be defined in 3 ways: + /// + /// - A regular point, as given to the [`line`]($func/line) or + /// [`polygon`]($func/polygon) function. + /// - An array of two points, the first being the vertex and the second + /// being the control point. The control point is expressed relative to + /// the vertex and is mirrored to get the second control point. The given + /// control point is the one that affects the curve coming _into_ this + /// vertex (even for the first point). The mirrored control point affects + /// the curve going out of this vertex. + /// - An array of three points, the first being the vertex and the next + /// being the control points (control point for curves coming in and out, + /// respectively) + #[variadic] + pub vertices: Vec<PathVertex>, +} + +impl Layout for PathElem { + #[tracing::instrument(name = "PathElem::layout", skip_all)] + fn layout( + &self, + _: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let resolve = |axes: Axes<Rel<Length>>| { + axes.resolve(styles) + .zip(regions.base()) + .map(|(l, b)| l.relative_to(b)) + .to_point() + }; + + let vertices: Vec<PathVertex> = self.vertices(); + let points: Vec<Point> = vertices.iter().map(|c| resolve(c.vertex())).collect(); + + let mut size = Size::zero(); + if points.is_empty() { + return Ok(Fragment::frame(Frame::new(size))); + } + + // Only create a path if there are more than zero points. + // Construct a closed path given all points. + let mut path = Path::new(); + path.move_to(points[0]); + + let mut add_cubic = + |from_point: Point, to_point: Point, from: PathVertex, to: PathVertex| { + let from_control_point = resolve(from.control_point_from()) + from_point; + let to_control_point = resolve(to.control_point_to()) + to_point; + path.cubic_to(from_control_point, to_control_point, to_point); + + let p0 = kurbo::Point::new(from_point.x.to_raw(), from_point.y.to_raw()); + let p1 = kurbo::Point::new( + from_control_point.x.to_raw(), + from_control_point.y.to_raw(), + ); + let p2 = kurbo::Point::new( + to_control_point.x.to_raw(), + to_control_point.y.to_raw(), + ); + let p3 = kurbo::Point::new(to_point.x.to_raw(), to_point.y.to_raw()); + let extrema = CubicBez::new(p0, p1, p2, p3).bounding_box(); + size.x.set_max(Abs::raw(extrema.x1)); + size.y.set_max(Abs::raw(extrema.y1)); + }; + + for (vertex_window, point_window) in vertices.windows(2).zip(points.windows(2)) { + let from = vertex_window[0]; + let to = vertex_window[1]; + let from_point = point_window[0]; + let to_point = point_window[1]; + + add_cubic(from_point, to_point, from, to); + } + + if self.closed(styles) { + let from = *vertices.last().unwrap(); // We checked that we have at least one element. + let to = vertices[0]; + let from_point = *points.last().unwrap(); + let to_point = points[0]; + + add_cubic(from_point, to_point, from, to); + path.close_path(); + } + + // Prepare fill and stroke. + let fill = self.fill(styles); + let stroke = match self.stroke(styles) { + Smart::Auto if fill.is_none() => Some(Stroke::default()), + Smart::Auto => None, + Smart::Custom(stroke) => stroke.map(PartialStroke::unwrap_or_default), + }; + + let mut frame = Frame::new(size); + let shape = Shape { geometry: Geometry::Path(path), stroke, fill }; + frame.push(Point::zero(), FrameItem::Shape(shape, self.span())); + + Ok(Fragment::frame(frame)) + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum PathVertex { + Vertex(Axes<Rel<Length>>), + MirroredControlPoint(Axes<Rel<Length>>, Axes<Rel<Length>>), + AllControlPoints(Axes<Rel<Length>>, Axes<Rel<Length>>, Axes<Rel<Length>>), +} + +impl PathVertex { + pub fn vertex(&self) -> Axes<Rel<Length>> { + match self { + Vertex(x) => *x, + MirroredControlPoint(x, _) => *x, + AllControlPoints(x, _, _) => *x, + } + } + + pub fn control_point_from(&self) -> Axes<Rel<Length>> { + match self { + Vertex(_) => Axes::new(Rel::zero(), Rel::zero()), + MirroredControlPoint(_, a) => a.map(|x| -x), + AllControlPoints(_, _, b) => *b, + } + } + + pub fn control_point_to(&self) -> Axes<Rel<Length>> { + match self { + Vertex(_) => Axes::new(Rel::zero(), Rel::zero()), + MirroredControlPoint(_, a) => *a, + AllControlPoints(_, a, _) => *a, + } + } +} + +cast! { + PathVertex, + self => match self { + Vertex(x) => x.into_value(), + MirroredControlPoint(x, c) => array![x, c].into_value(), + AllControlPoints(x, c1, c2) => array![x, c1, c2].into_value(), + }, + array: Array => { + let mut iter = array.into_iter(); + match (iter.next(), iter.next(), iter.next(), iter.next()) { + (Some(a), None, None, None) => { + Vertex(a.cast()?) + }, + (Some(a), Some(b), None, None) => { + if Axes::<Rel<Length>>::castable(&a) { + MirroredControlPoint(a.cast()?, b.cast()?) + } else { + Vertex(Axes::new(a.cast()?, b.cast()?)) + } + }, + (Some(a), Some(b), Some(c), None) => { + AllControlPoints(a.cast()?, b.cast()?, c.cast()?) + }, + _ => bail!("path vertex must have 1, 2, or 3 points"), + } + }, +} diff --git a/crates/typst-library/src/visualize/polygon.rs b/crates/typst-library/src/visualize/polygon.rs new file mode 100644 index 00000000..d2d3132a --- /dev/null +++ b/crates/typst-library/src/visualize/polygon.rs @@ -0,0 +1,93 @@ +use crate::prelude::*; + +/// A closed polygon. +/// +/// The polygon is defined by its corner points and is closed automatically. +/// +/// ## Example { #example } +/// ```example +/// #polygon( +/// fill: blue.lighten(80%), +/// stroke: blue, +/// (20%, 0pt), +/// (60%, 0pt), +/// (80%, 2cm), +/// (0%, 2cm), +/// ) +/// ``` +/// +/// Display: Polygon +/// Category: visualize +#[element(Layout)] +pub struct PolygonElem { + /// How to fill the polygon. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + /// + /// Currently all polygons are filled according to the + /// [non-zero winding rule](https://en.wikipedia.org/wiki/Nonzero-rule). + pub fill: Option<Paint>, + + /// How to stroke the polygon. This can be: + /// + /// See the [line's documentation]($func/line.stroke) for more details. Can + /// be set to `{none}` to disable the stroke or to `{auto}` for a stroke of + /// `{1pt}` black if and if only if no fill is given. + #[resolve] + #[fold] + pub stroke: Smart<Option<PartialStroke>>, + + /// The vertices of the polygon. Each point is specified as an array of two + /// [relative lengths]($type/relative-length). + #[variadic] + pub vertices: Vec<Axes<Rel<Length>>>, +} + +impl Layout for PolygonElem { + #[tracing::instrument(name = "PolygonElem::layout", skip_all)] + fn layout( + &self, + _: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let points: Vec<Point> = self + .vertices() + .iter() + .map(|c| { + c.resolve(styles) + .zip(regions.base()) + .map(|(l, b)| l.relative_to(b)) + .to_point() + }) + .collect(); + + let size = points.iter().fold(Point::zero(), |max, c| c.max(max)).to_size(); + let mut frame = Frame::new(size); + + // Only create a path if there are more than zero points. + if points.is_empty() { + return Ok(Fragment::frame(frame)); + } + + // Prepare fill and stroke. + let fill = self.fill(styles); + let stroke = match self.stroke(styles) { + Smart::Auto if fill.is_none() => Some(Stroke::default()), + Smart::Auto => None, + Smart::Custom(stroke) => stroke.map(PartialStroke::unwrap_or_default), + }; + + // Construct a closed path given all points. + let mut path = Path::new(); + path.move_to(points[0]); + for &point in &points[1..] { + path.line_to(point); + } + path.close_path(); + + let shape = Shape { geometry: Geometry::Path(path), stroke, fill }; + frame.push(Point::zero(), FrameItem::Shape(shape, self.span())); + + Ok(Fragment::frame(frame)) + } +} diff --git a/crates/typst-library/src/visualize/shape.rs b/crates/typst-library/src/visualize/shape.rs new file mode 100644 index 00000000..6129b70b --- /dev/null +++ b/crates/typst-library/src/visualize/shape.rs @@ -0,0 +1,569 @@ +use std::f64::consts::SQRT_2; + +use crate::prelude::*; + +/// A rectangle with optional content. +/// +/// ## Example { #example } +/// ```example +/// // Without content. +/// #rect(width: 35%, height: 30pt) +/// +/// // With content. +/// #rect[ +/// Automatically sized \ +/// to fit the content. +/// ] +/// ``` +/// +/// Display: Rectangle +/// Category: visualize +#[element(Layout)] +pub struct RectElem { + /// The rectangle's width, relative to its parent container. + pub width: Smart<Rel<Length>>, + + /// The rectangle's height, relative to its parent container. + pub height: Smart<Rel<Length>>, + + /// How to fill the rectangle. + /// + /// When setting a fill, the default stroke disappears. To create a + /// rectangle with both fill and stroke, you have to configure both. + /// + /// ```example + /// #rect(fill: blue) + /// ``` + pub fill: Option<Paint>, + + /// How to stroke the rectangle. This can be: + /// + /// - `{none}` to disable stroking + /// - `{auto}` for a stroke of `{1pt + black}` if and if only if no fill is + /// given. + /// - Any kind of stroke that can also be used for + /// [lines]($func/line.stroke). + /// - A dictionary describing the stroke for each side inidvidually. The + /// dictionary can contain the following keys in order of precedence: + /// - `top`: The top stroke. + /// - `right`: The right stroke. + /// - `bottom`: The bottom stroke. + /// - `left`: The left stroke. + /// - `x`: The horizontal stroke. + /// - `y`: The vertical stroke. + /// - `rest`: The stroke on all sides except those for which the + /// dictionary explicitly sets a size. + /// + /// ```example + /// #stack( + /// dir: ltr, + /// spacing: 1fr, + /// rect(stroke: red), + /// rect(stroke: 2pt), + /// rect(stroke: 2pt + red), + /// ) + /// ``` + #[resolve] + #[fold] + pub stroke: Smart<Sides<Option<Option<PartialStroke>>>>, + + /// How much to round the rectangle's corners, relative to the minimum of + /// the width and height divided by two. This can be: + /// + /// - A relative length for a uniform corner radius. + /// - A dictionary: With a dictionary, the stroke for each side can be set + /// individually. The dictionary can contain the following keys in order + /// of precedence: + /// - `top-left`: The top-left corner radius. + /// - `top-right`: The top-right corner radius. + /// - `bottom-right`: The bottom-right corner radius. + /// - `bottom-left`: The bottom-left corner radius. + /// - `left`: The top-left and bottom-left corner radii. + /// - `top`: The top-left and top-right corner radii. + /// - `right`: The top-right and bottom-right corner radii. + /// - `bottom`: The bottom-left and bottom-right corner radii. + /// - `rest`: The radii for all corners except those for which the + /// dictionary explicitly sets a size. + /// + /// ```example + /// #set rect(stroke: 4pt) + /// #rect( + /// radius: ( + /// left: 5pt, + /// top-right: 20pt, + /// bottom-right: 10pt, + /// ), + /// stroke: ( + /// left: red, + /// top: yellow, + /// right: green, + /// bottom: blue, + /// ), + /// ) + /// ``` + #[resolve] + #[fold] + pub radius: Corners<Option<Rel<Length>>>, + + /// How much to pad the rectangle's content. + /// + /// _Note:_ When the rectangle contains text, its exact size depends on the + /// current [text edges]($func/text.top-edge). + /// + /// ```example + /// #rect(inset: 0pt)[Tight] + /// ``` + #[resolve] + #[fold] + #[default(Sides::splat(Abs::pt(5.0).into()))] + pub inset: Sides<Option<Rel<Length>>>, + + /// How much to expand the rectangle's size without affecting the layout. + /// See the [box's documentation]($func/box.outset) for more details. + #[resolve] + #[fold] + pub outset: Sides<Option<Rel<Length>>>, + + /// The content to place into the rectangle. + /// + /// When this is omitted, the rectangle takes on a default size of at most + /// `{45pt}` by `{30pt}`. + #[positional] + pub body: Option<Content>, +} + +impl Layout for RectElem { + #[tracing::instrument(name = "RectElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + layout( + vt, + styles, + regions, + ShapeKind::Rect, + &self.body(styles), + Axes::new(self.width(styles), self.height(styles)), + self.fill(styles), + self.stroke(styles), + self.inset(styles), + self.outset(styles), + self.radius(styles), + self.span(), + ) + } +} + +/// A square with optional content. +/// +/// ## Example { #example } +/// ```example +/// // Without content. +/// #square(size: 40pt) +/// +/// // With content. +/// #square[ +/// Automatically \ +/// sized to fit. +/// ] +/// ``` +/// +/// Display: Square +/// Category: visualize +#[element(Layout)] +pub struct SquareElem { + /// The square's side length. This is mutually exclusive with `width` and + /// `height`. + #[external] + pub size: Smart<Length>, + + /// The square's width. This is mutually exclusive with `size` and `height`. + /// + /// In contrast to `size`, this can be relative to the parent container's + /// width. + #[parse( + let size = args.named::<Smart<Length>>("size")?.map(|s| s.map(Rel::from)); + match size { + None => args.named("width")?, + size => size, + } + )] + pub width: Smart<Rel<Length>>, + + /// The square's height. This is mutually exclusive with `size` and `width`. + /// + /// In contrast to `size`, this can be relative to the parent container's + /// height. + #[parse(match size { + None => args.named("height")?, + size => size, + })] + pub height: Smart<Rel<Length>>, + + /// How to fill the square. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + pub fill: Option<Paint>, + + /// How to stroke the square. See the [rectangle's + /// documentation]($func/rect.stroke) for more details. + #[resolve] + #[fold] + pub stroke: Smart<Sides<Option<Option<PartialStroke>>>>, + + /// How much to round the square's corners. See the [rectangle's + /// documentation]($func/rect.radius) for more details. + #[resolve] + #[fold] + pub radius: Corners<Option<Rel<Length>>>, + + /// How much to pad the square's content. See the [rectangle's + /// documentation]($func/rect.inset) for more details. + #[resolve] + #[fold] + #[default(Sides::splat(Abs::pt(5.0).into()))] + pub inset: Sides<Option<Rel<Length>>>, + + /// How much to expand the square's size without affecting the layout. See + /// the [rectangle's documentation]($func/rect.outset) for more details. + #[resolve] + #[fold] + pub outset: Sides<Option<Rel<Length>>>, + + /// The content to place into the square. The square expands to fit this + /// content, keeping the 1-1 aspect ratio. + /// + /// When this is omitted, the square takes on a default size of at most + /// `{30pt}`. + #[positional] + pub body: Option<Content>, +} + +impl Layout for SquareElem { + #[tracing::instrument(name = "SquareElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + layout( + vt, + styles, + regions, + ShapeKind::Square, + &self.body(styles), + Axes::new(self.width(styles), self.height(styles)), + self.fill(styles), + self.stroke(styles), + self.inset(styles), + self.outset(styles), + self.radius(styles), + self.span(), + ) + } +} + +/// An ellipse with optional content. +/// +/// ## Example { #example } +/// ```example +/// // Without content. +/// #ellipse(width: 35%, height: 30pt) +/// +/// // With content. +/// #ellipse[ +/// #set align(center) +/// Automatically sized \ +/// to fit the content. +/// ] +/// ``` +/// +/// Display: Ellipse +/// Category: visualize +#[element(Layout)] +pub struct EllipseElem { + /// The ellipse's width, relative to its parent container. + pub width: Smart<Rel<Length>>, + + /// The ellipse's height, relative to its parent container. + pub height: Smart<Rel<Length>>, + + /// How to fill the ellipse. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + pub fill: Option<Paint>, + + /// How to stroke the ellipse. See the [rectangle's + /// documentation]($func/rect.stroke) for more details. + #[resolve] + #[fold] + pub stroke: Smart<Option<PartialStroke>>, + + /// How much to pad the ellipse's content. See the [rectangle's + /// documentation]($func/rect.inset) for more details. + #[resolve] + #[fold] + #[default(Sides::splat(Abs::pt(5.0).into()))] + pub inset: Sides<Option<Rel<Length>>>, + + /// How much to expand the ellipse's size without affecting the layout. See + /// the [rectangle's documentation]($func/rect.outset) for more details. + #[resolve] + #[fold] + pub outset: Sides<Option<Rel<Length>>>, + + /// The content to place into the ellipse. + /// + /// When this is omitted, the ellipse takes on a default size of at most + /// `{45pt}` by `{30pt}`. + #[positional] + pub body: Option<Content>, +} + +impl Layout for EllipseElem { + #[tracing::instrument(name = "EllipseElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + layout( + vt, + styles, + regions, + ShapeKind::Ellipse, + &self.body(styles), + Axes::new(self.width(styles), self.height(styles)), + self.fill(styles), + self.stroke(styles).map(Sides::splat), + self.inset(styles), + self.outset(styles), + Corners::splat(Rel::zero()), + self.span(), + ) + } +} + +/// A circle with optional content. +/// +/// ## Example { #example } +/// ```example +/// // Without content. +/// #circle(radius: 25pt) +/// +/// // With content. +/// #circle[ +/// #set align(center + horizon) +/// Automatically \ +/// sized to fit. +/// ] +/// ``` +/// +/// Display: Circle +/// Category: visualize +#[element(Layout)] +pub struct CircleElem { + /// The circle's radius. This is mutually exclusive with `width` and + /// `height`. + #[external] + pub radius: Length, + + /// The circle's width. This is mutually exclusive with `radius` and + /// `height`. + /// + /// In contrast to `radius`, this can be relative to the parent container's + /// width. + #[parse( + let size = args + .named::<Smart<Length>>("radius")? + .map(|s| s.map(|r| 2.0 * Rel::from(r))); + match size { + None => args.named("width")?, + size => size, + } + )] + pub width: Smart<Rel<Length>>, + + /// The circle's height.This is mutually exclusive with `radius` and + /// `width`. + /// + /// In contrast to `radius`, this can be relative to the parent container's + /// height. + #[parse(match size { + None => args.named("height")?, + size => size, + })] + pub height: Smart<Rel<Length>>, + + /// How to fill the circle. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + pub fill: Option<Paint>, + + /// How to stroke the circle. See the [rectangle's + /// documentation]($func/rect.stroke) for more details. + #[resolve] + #[fold] + #[default(Smart::Auto)] + pub stroke: Smart<Option<PartialStroke>>, + + /// How much to pad the circle's content. See the [rectangle's + /// documentation]($func/rect.inset) for more details. + #[resolve] + #[fold] + #[default(Sides::splat(Abs::pt(5.0).into()))] + pub inset: Sides<Option<Rel<Length>>>, + + /// How much to expand the circle's size without affecting the layout. See + /// the [rectangle's documentation]($func/rect.outset) for more details. + #[resolve] + #[fold] + pub outset: Sides<Option<Rel<Length>>>, + + /// The content to place into the circle. The circle expands to fit this + /// content, keeping the 1-1 aspect ratio. + #[positional] + pub body: Option<Content>, +} + +impl Layout for CircleElem { + #[tracing::instrument(name = "CircleElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + layout( + vt, + styles, + regions, + ShapeKind::Circle, + &self.body(styles), + Axes::new(self.width(styles), self.height(styles)), + self.fill(styles), + self.stroke(styles).map(Sides::splat), + self.inset(styles), + self.outset(styles), + Corners::splat(Rel::zero()), + self.span(), + ) + } +} + +/// Layout a shape. +#[tracing::instrument(name = "shape::layout", skip_all)] +#[allow(clippy::too_many_arguments)] +fn layout( + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + kind: ShapeKind, + body: &Option<Content>, + sizing: Axes<Smart<Rel<Length>>>, + fill: Option<Paint>, + stroke: Smart<Sides<Option<PartialStroke<Abs>>>>, + mut inset: Sides<Rel<Abs>>, + outset: Sides<Rel<Abs>>, + radius: Corners<Rel<Abs>>, + span: Span, +) -> SourceResult<Fragment> { + let resolved = sizing + .zip(regions.base()) + .map(|(s, r)| s.map(|v| v.resolve(styles).relative_to(r))); + + let mut frame; + if let Some(child) = body { + let region = resolved.unwrap_or(regions.base()); + if kind.is_round() { + inset = inset.map(|side| side + Ratio::new(0.5 - SQRT_2 / 4.0)); + } + + // Pad the child. + let child = child.clone().padded(inset.map(|side| side.map(Length::from))); + let expand = sizing.as_ref().map(Smart::is_custom); + let pod = Regions::one(region, expand); + frame = child.layout(vt, styles, pod)?.into_frame(); + + // Enforce correct size. + *frame.size_mut() = expand.select(region, frame.size()); + + // Relayout with full expansion into square region to make sure + // the result is really a square or circle. + if kind.is_quadratic() { + frame.set_size(Size::splat(frame.size().max_by_side())); + let length = frame.size().max_by_side().min(region.min_by_side()); + let pod = Regions::one(Size::splat(length), Axes::splat(true)); + frame = child.layout(vt, styles, pod)?.into_frame(); + } + + // Enforce correct size again. + *frame.size_mut() = expand.select(region, frame.size()); + if kind.is_quadratic() { + frame.set_size(Size::splat(frame.size().max_by_side())); + } + } else { + // The default size that a shape takes on if it has no child and + // enough space. + let default = Size::new(Abs::pt(45.0), Abs::pt(30.0)); + let mut size = resolved.unwrap_or(default.min(regions.base())); + if kind.is_quadratic() { + size = Size::splat(size.min_by_side()); + } + frame = Frame::new(size); + } + + // Prepare stroke. + let stroke = match stroke { + Smart::Auto if fill.is_none() => Sides::splat(Some(Stroke::default())), + Smart::Auto => Sides::splat(None), + Smart::Custom(strokes) => { + strokes.map(|s| s.map(PartialStroke::unwrap_or_default)) + } + }; + + // Add fill and/or stroke. + if fill.is_some() || stroke.iter().any(Option::is_some) { + if kind.is_round() { + let outset = outset.relative_to(frame.size()); + let size = frame.size() + outset.sum_by_axis(); + let pos = Point::new(-outset.left, -outset.top); + let shape = ellipse(size, fill, stroke.left); + frame.prepend(pos, FrameItem::Shape(shape, span)); + } else { + frame.fill_and_stroke(fill, stroke, outset, radius, span); + } + } + + // Apply metadata. + frame.meta(styles, false); + + Ok(Fragment::frame(frame)) +} + +/// A category of shape. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum ShapeKind { + /// A rectangle with equal side lengths. + Square, + /// A quadrilateral with four right angles. + Rect, + /// An ellipse with coinciding foci. + Circle, + /// A curve around two focal points. + Ellipse, +} + +impl ShapeKind { + /// Whether this shape kind is curvy. + fn is_round(self) -> bool { + matches!(self, Self::Circle | Self::Ellipse) + } + + /// Whether this shape kind has equal side length. + fn is_quadratic(self) -> bool { + matches!(self, Self::Square | Self::Circle) + } +} |
