diff options
| author | Laurenz <laurmaedje@gmail.com> | 2022-11-03 11:44:53 +0100 |
|---|---|---|
| committer | Laurenz <laurmaedje@gmail.com> | 2022-11-03 13:35:39 +0100 |
| commit | 37a7afddfaffd44cb9bc013c9506599267e08983 (patch) | |
| tree | 20e7d62d3c5418baff01a21d0406b91bf3096214 /library/src/graphics | |
| parent | 56342bd972a13ffe21beaf2b87ab7eb1597704b4 (diff) | |
Split crates
Diffstat (limited to 'library/src/graphics')
| -rw-r--r-- | library/src/graphics/hide.rs | 27 | ||||
| -rw-r--r-- | library/src/graphics/image.rs | 121 | ||||
| -rw-r--r-- | library/src/graphics/line.rs | 68 | ||||
| -rw-r--r-- | library/src/graphics/mod.rs | 11 | ||||
| -rw-r--r-- | library/src/graphics/shape.rs | 201 |
5 files changed, 428 insertions, 0 deletions
diff --git a/library/src/graphics/hide.rs b/library/src/graphics/hide.rs new file mode 100644 index 00000000..3affd809 --- /dev/null +++ b/library/src/graphics/hide.rs @@ -0,0 +1,27 @@ +use crate::prelude::*; + +/// Hide content without affecting layout. +#[derive(Debug, Hash)] +pub struct HideNode(pub Content); + +#[node(LayoutInline)] +impl HideNode { + fn construct(_: &mut Vm, args: &mut Args) -> SourceResult<Content> { + Ok(Self(args.expect("body")?).pack()) + } +} + +impl LayoutInline for HideNode { + fn layout_inline( + &self, + world: Tracked<dyn World>, + regions: &Regions, + styles: StyleChain, + ) -> SourceResult<Vec<Frame>> { + let mut frames = self.0.layout_inline(world, regions, styles)?; + for frame in &mut frames { + frame.clear(); + } + Ok(frames) + } +} diff --git a/library/src/graphics/image.rs b/library/src/graphics/image.rs new file mode 100644 index 00000000..6bf02265 --- /dev/null +++ b/library/src/graphics/image.rs @@ -0,0 +1,121 @@ +use std::ffi::OsStr; + +use typst::image::{Image, ImageFormat, RasterFormat, VectorFormat}; + +use crate::prelude::*; +use crate::text::TextNode; + +/// Show a raster or vector graphic. +#[derive(Debug, Hash)] +pub struct ImageNode(pub Image); + +#[node(LayoutInline)] +impl ImageNode { + /// How the image should adjust itself to a given area. + pub const FIT: ImageFit = ImageFit::Cover; + + fn construct(vm: &mut Vm, args: &mut Args) -> SourceResult<Content> { + let Spanned { v: path, span } = + args.expect::<Spanned<EcoString>>("path to image file")?; + + let full = vm.locate(&path).at(span)?; + let buffer = vm.world.file(&full).at(span)?; + let ext = full.extension().and_then(OsStr::to_str).unwrap_or_default(); + let format = match ext.to_lowercase().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!(span, "unknown image format"), + }; + + let image = Image::new(buffer, format).at(span)?; + let width = args.named("width")?; + let height = args.named("height")?; + + Ok(ImageNode(image).pack().boxed(Axes::new(width, height))) + } +} + +impl LayoutInline for ImageNode { + fn layout_inline( + &self, + _: Tracked<dyn World>, + regions: &Regions, + styles: StyleChain, + ) -> SourceResult<Vec<Frame>> { + let pxw = self.0.width() as f64; + let pxh = self.0.height() as f64; + let px_ratio = pxw / pxh; + + // Find out whether the image is wider or taller than the target size. + let &Regions { first, expand, .. } = regions; + let region_ratio = first.x / first.y; + 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 { + first + } else if expand.x || (!expand.y && wide && first.x.is_finite()) { + Size::new(first.x, first.y.min(first.x.safe_div(px_ratio))) + } else if first.y.is_finite() { + Size::new(first.x.min(first.y * px_ratio), first.y) + } else { + Size::new(Abs::pt(pxw), Abs::pt(pxh)) + }; + + // Compute the actual size of the fitted image. + let fit = styles.get(Self::FIT); + 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(), Element::Image(self.0.clone(), fitted)); + 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 link if it exists. + if let Some(url) = styles.get(TextNode::LINK) { + frame.link(url.clone()); + } + + Ok(vec![frame]) + } +} + +/// How an image should adjust itself to a given area. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum ImageFit { + /// The image should completely cover the area. + Cover, + /// The image should be fully contained in the area. + Contain, + /// The image should be stretched so that it exactly fills the area. + Stretch, +} + +castable! { + ImageFit, + Expected: "string", + Value::Str(string) => match string.as_str() { + "cover" => Self::Cover, + "contain" => Self::Contain, + "stretch" => Self::Stretch, + _ => Err(r#"expected "cover", "contain" or "stretch""#)?, + }, +} diff --git a/library/src/graphics/line.rs b/library/src/graphics/line.rs new file mode 100644 index 00000000..df427171 --- /dev/null +++ b/library/src/graphics/line.rs @@ -0,0 +1,68 @@ +use crate::prelude::*; + +/// Display a line without affecting the layout. +#[derive(Debug, Hash)] +pub struct LineNode { + /// Where the line starts. + origin: Axes<Rel<Length>>, + /// The offset from the `origin` where the line ends. + delta: Axes<Rel<Length>>, +} + +#[node(LayoutInline)] +impl LineNode { + /// How to stroke the line. + #[property(resolve, fold)] + pub const STROKE: PartialStroke = PartialStroke::default(); + + fn construct(_: &mut Vm, args: &mut Args) -> SourceResult<Content> { + let origin = args.named("origin")?.unwrap_or_default(); + + let delta = match args.named::<Axes<Rel<Length>>>("to")? { + Some(to) => to.zip(origin).map(|(to, from)| to - from), + None => { + let length = + args.named::<Rel<Length>>("length")?.unwrap_or(Abs::cm(1.0).into()); + + let angle = args.named::<Angle>("angle")?.unwrap_or_default(); + let x = angle.cos() * length; + let y = angle.sin() * length; + + Axes::new(x, y) + } + }; + + Ok(Self { origin, delta }.pack()) + } +} + +impl LayoutInline for LineNode { + fn layout_inline( + &self, + _: Tracked<dyn World>, + regions: &Regions, + styles: StyleChain, + ) -> SourceResult<Vec<Frame>> { + let stroke = styles.get(Self::STROKE).unwrap_or_default(); + + let origin = self + .origin + .resolve(styles) + .zip(regions.base) + .map(|(l, b)| l.relative_to(b)); + + let delta = self + .delta + .resolve(styles) + .zip(regions.base) + .map(|(l, b)| l.relative_to(b)); + + let target = regions.expand.select(regions.first, Size::zero()); + let mut frame = Frame::new(target); + + let shape = Geometry::Line(delta.to_point()).stroked(stroke); + frame.push(origin.to_point(), Element::Shape(shape)); + + Ok(vec![frame]) + } +} diff --git a/library/src/graphics/mod.rs b/library/src/graphics/mod.rs new file mode 100644 index 00000000..34182121 --- /dev/null +++ b/library/src/graphics/mod.rs @@ -0,0 +1,11 @@ +//! Graphical elements and effects. + +mod hide; +mod image; +mod line; +mod shape; + +pub use self::image::*; +pub use hide::*; +pub use line::*; +pub use shape::*; diff --git a/library/src/graphics/shape.rs b/library/src/graphics/shape.rs new file mode 100644 index 00000000..d980b64a --- /dev/null +++ b/library/src/graphics/shape.rs @@ -0,0 +1,201 @@ +use std::f64::consts::SQRT_2; + +use crate::prelude::*; +use crate::text::TextNode; + +/// A sizable and fillable shape with optional content. +#[derive(Debug, Hash)] +pub struct ShapeNode<const S: ShapeKind>(pub Option<Content>); + +/// A square with optional content. +pub type SquareNode = ShapeNode<SQUARE>; + +/// A rectangle with optional content. +pub type RectNode = ShapeNode<RECT>; + +/// A circle with optional content. +pub type CircleNode = ShapeNode<CIRCLE>; + +/// A ellipse with optional content. +pub type EllipseNode = ShapeNode<ELLIPSE>; + +#[node(LayoutInline)] +impl<const S: ShapeKind> ShapeNode<S> { + /// How to fill the shape. + pub const FILL: Option<Paint> = None; + /// How to stroke the shape. + #[property(skip, resolve, fold)] + pub const STROKE: Smart<Sides<Option<PartialStroke>>> = Smart::Auto; + + /// How much to pad the shape's content. + #[property(resolve, fold)] + pub const INSET: Sides<Option<Rel<Length>>> = Sides::splat(Rel::zero()); + /// How much to extend the shape's dimensions beyond the allocated space. + #[property(resolve, fold)] + pub const OUTSET: Sides<Option<Rel<Length>>> = Sides::splat(Rel::zero()); + + /// How much to round the shape's corners. + #[property(skip, resolve, fold)] + pub const RADIUS: Corners<Option<Rel<Length>>> = Corners::splat(Rel::zero()); + + fn construct(_: &mut Vm, args: &mut Args) -> SourceResult<Content> { + let size = match S { + SQUARE => args.named::<Length>("size")?.map(Rel::from), + CIRCLE => args.named::<Length>("radius")?.map(|r| 2.0 * Rel::from(r)), + _ => None, + }; + + let width = match size { + None => args.named("width")?, + size => size, + }; + + let height = match size { + None => args.named("height")?, + size => size, + }; + + Ok(Self(args.eat()?).pack().boxed(Axes::new(width, height))) + } + + fn set(...) { + if is_round(S) { + styles.set_opt( + Self::STROKE, + args.named::<Smart<Option<PartialStroke>>>("stroke")? + .map(|some| some.map(Sides::splat)), + ); + } else { + styles.set_opt(Self::STROKE, args.named("stroke")?); + styles.set_opt(Self::RADIUS, args.named("radius")?); + } + } +} + +impl<const S: ShapeKind> LayoutInline for ShapeNode<S> { + fn layout_inline( + &self, + world: Tracked<dyn World>, + regions: &Regions, + styles: StyleChain, + ) -> SourceResult<Vec<Frame>> { + let mut frames; + if let Some(child) = &self.0 { + let mut inset = styles.get(Self::INSET); + if is_round(S) { + 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 mut pod = Regions::one(regions.first, regions.base, regions.expand); + frames = child.layout_inline(world, &pod, styles)?; + + for frame in frames.iter_mut() { + frame.apply_role(Role::GenericBlock); + } + + // Relayout with full expansion into square region to make sure + // the result is really a square or circle. + if is_quadratic(S) { + let length = if regions.expand.x || regions.expand.y { + let target = regions.expand.select(regions.first, Size::zero()); + target.x.max(target.y) + } else { + let size = frames[0].size(); + let desired = size.x.max(size.y); + desired.min(regions.first.x).min(regions.first.y) + }; + + pod.first = Size::splat(length); + pod.expand = Axes::splat(true); + frames = child.layout_inline(world, &pod, styles)?; + } + } else { + // The default size that a shape takes on if it has no child and + // enough space. + let mut size = Size::new(Abs::pt(45.0), Abs::pt(30.0)).min(regions.first); + + if is_quadratic(S) { + let length = if regions.expand.x || regions.expand.y { + let target = regions.expand.select(regions.first, Size::zero()); + target.x.max(target.y) + } else { + size.x.min(size.y) + }; + size = Size::splat(length); + } else { + size = regions.expand.select(regions.first, size); + } + + frames = vec![Frame::new(size)]; + } + + let frame = &mut frames[0]; + + // Add fill and/or stroke. + let fill = styles.get(Self::FILL); + let stroke = match styles.get(Self::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)) + } + }; + + let outset = styles.get(Self::OUTSET).relative_to(frame.size()); + let size = frame.size() + outset.sum_by_axis(); + + let radius = styles + .get(Self::RADIUS) + .map(|side| side.relative_to(size.x.min(size.y) / 2.0)); + + let pos = Point::new(-outset.left, -outset.top); + + if fill.is_some() || stroke.iter().any(Option::is_some) { + if is_round(S) { + let shape = ellipse(size, fill, stroke.left); + frame.prepend(pos, Element::Shape(shape)); + } else { + frame.prepend_multiple( + rounded_rect(size, radius, fill, stroke) + .into_iter() + .map(|x| (pos, Element::Shape(x))), + ) + } + } + + // Apply link if it exists. + if let Some(url) = styles.get(TextNode::LINK) { + frame.link(url.clone()); + } + + Ok(frames) + } +} + +/// A category of shape. +pub type ShapeKind = usize; + +/// A rectangle with equal side lengths. +const SQUARE: ShapeKind = 0; + +/// A quadrilateral with four right angles. +const RECT: ShapeKind = 1; + +/// An ellipse with coinciding foci. +const CIRCLE: ShapeKind = 2; + +/// A curve around two focal points. +const ELLIPSE: ShapeKind = 3; + +/// Whether a shape kind is curvy. +fn is_round(kind: ShapeKind) -> bool { + matches!(kind, CIRCLE | ELLIPSE) +} + +/// Whether a shape kind has equal side length. +fn is_quadratic(kind: ShapeKind) -> bool { + matches!(kind, SQUARE | CIRCLE) +} |
