summaryrefslogtreecommitdiff
path: root/crates/typst-library/src/visualize
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-library/src/visualize')
-rw-r--r--crates/typst-library/src/visualize/image.rs197
-rw-r--r--crates/typst-library/src/visualize/line.rs118
-rw-r--r--crates/typst-library/src/visualize/mod.rs45
-rw-r--r--crates/typst-library/src/visualize/path.rs211
-rw-r--r--crates/typst-library/src/visualize/polygon.rs93
-rw-r--r--crates/typst-library/src/visualize/shape.rs569
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)
+ }
+}