summaryrefslogtreecommitdiff
path: root/crates/typst-library/src/layout
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-library/src/layout')
-rw-r--r--crates/typst-library/src/layout/align.rs67
-rw-r--r--crates/typst-library/src/layout/columns.rs172
-rw-r--r--crates/typst-library/src/layout/container.rs497
-rw-r--r--crates/typst-library/src/layout/enum.rs318
-rw-r--r--crates/typst-library/src/layout/flow.rs583
-rw-r--r--crates/typst-library/src/layout/fragment.rs87
-rw-r--r--crates/typst-library/src/layout/grid.rs708
-rw-r--r--crates/typst-library/src/layout/hide.rs30
-rw-r--r--crates/typst-library/src/layout/list.rs239
-rw-r--r--crates/typst-library/src/layout/measure.rs56
-rw-r--r--crates/typst-library/src/layout/mod.rs709
-rw-r--r--crates/typst-library/src/layout/pad.rs125
-rw-r--r--crates/typst-library/src/layout/page.rs898
-rw-r--r--crates/typst-library/src/layout/par.rs1566
-rw-r--r--crates/typst-library/src/layout/place.rs103
-rw-r--r--crates/typst-library/src/layout/regions.rs144
-rw-r--r--crates/typst-library/src/layout/repeat.rs79
-rw-r--r--crates/typst-library/src/layout/spacing.rs240
-rw-r--r--crates/typst-library/src/layout/stack.rs339
-rw-r--r--crates/typst-library/src/layout/table.rs334
-rw-r--r--crates/typst-library/src/layout/terms.rs166
-rw-r--r--crates/typst-library/src/layout/transform.rs194
22 files changed, 7654 insertions, 0 deletions
diff --git a/crates/typst-library/src/layout/align.rs b/crates/typst-library/src/layout/align.rs
new file mode 100644
index 00000000..bbfe9f7e
--- /dev/null
+++ b/crates/typst-library/src/layout/align.rs
@@ -0,0 +1,67 @@
+use crate::prelude::*;
+
+/// Aligns content horizontally and vertically.
+///
+/// ## Example { #example }
+/// ```example
+/// #set align(center)
+///
+/// Centered text, a sight to see \
+/// In perfect balance, visually \
+/// Not left nor right, it stands alone \
+/// A work of art, a visual throne
+/// ```
+///
+/// Display: Align
+/// Category: layout
+#[element(Show)]
+pub struct AlignElem {
+ /// The alignment along both axes.
+ ///
+ /// Possible values for horizontal alignments are:
+ /// - `start`
+ /// - `end`
+ /// - `left`
+ /// - `center`
+ /// - `right`
+ ///
+ /// The `start` and `end` alignments are relative to the current [text
+ /// direction]($func/text.dir).
+ ///
+ /// Possible values for vertical alignments are:
+ /// - `top`
+ /// - `horizon`
+ /// - `bottom`
+ ///
+ /// To align along both axes at the same time, add the two alignments using
+ /// the `+` operator to get a `2d alignment`. For example, `top + right`
+ /// aligns the content to the top right corner.
+ ///
+ /// ```example
+ /// #set page(height: 6cm)
+ /// #set text(lang: "ar")
+ ///
+ /// مثال
+ /// #align(
+ /// end + horizon,
+ /// rect(inset: 12pt)[ركن]
+ /// )
+ /// ```
+ #[positional]
+ #[fold]
+ #[default(Axes::new(GenAlign::Start, GenAlign::Specific(Align::Top)))]
+ pub alignment: Axes<Option<GenAlign>>,
+
+ /// The content to align.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for AlignElem {
+ #[tracing::instrument(name = "AlignElem::show", skip_all)]
+ fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult<Content> {
+ Ok(self
+ .body()
+ .styled(Self::set_alignment(self.alignment(styles).map(Some))))
+ }
+}
diff --git a/crates/typst-library/src/layout/columns.rs b/crates/typst-library/src/layout/columns.rs
new file mode 100644
index 00000000..d2138491
--- /dev/null
+++ b/crates/typst-library/src/layout/columns.rs
@@ -0,0 +1,172 @@
+use crate::prelude::*;
+use crate::text::TextElem;
+
+/// Separates a region into multiple equally sized columns.
+///
+/// The `column` function allows to separate the interior of any container into
+/// multiple columns. It will not equalize the height of the columns, instead,
+/// the columns will take up the height of their container or the remaining
+/// height on the page. The columns function can break across pages if
+/// necessary.
+///
+/// ## Example { #example }
+/// ```example
+/// = Towards Advanced Deep Learning
+///
+/// #box(height: 68pt,
+/// columns(2, gutter: 11pt)[
+/// #set par(justify: true)
+/// This research was funded by the
+/// National Academy of Sciences.
+/// NAoS provided support for field
+/// tests and interviews with a
+/// grant of up to USD 40.000 for a
+/// period of 6 months.
+/// ]
+/// )
+///
+/// In recent years, deep learning has
+/// increasingly been used to solve a
+/// variety of problems.
+/// ```
+///
+/// Display: Columns
+/// Category: layout
+#[element(Layout)]
+pub struct ColumnsElem {
+ /// The number of columns.
+ #[positional]
+ #[default(NonZeroUsize::new(2).unwrap())]
+ pub count: NonZeroUsize,
+
+ /// The size of the gutter space between each column.
+ #[resolve]
+ #[default(Ratio::new(0.04).into())]
+ pub gutter: Rel<Length>,
+
+ /// The content that should be layouted into the columns.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for ColumnsElem {
+ #[tracing::instrument(name = "ColumnsElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let body = self.body();
+
+ // Separating the infinite space into infinite columns does not make
+ // much sense.
+ if !regions.size.x.is_finite() {
+ return body.layout(vt, styles, regions);
+ }
+
+ // Determine the width of the gutter and each column.
+ let columns = self.count(styles).get();
+ let gutter = self.gutter(styles).relative_to(regions.base().x);
+ let width = (regions.size.x - gutter * (columns - 1) as f64) / columns as f64;
+
+ let backlog: Vec<_> = std::iter::once(&regions.size.y)
+ .chain(regions.backlog)
+ .flat_map(|&height| std::iter::repeat(height).take(columns))
+ .skip(1)
+ .collect();
+
+ // Create the pod regions.
+ let pod = Regions {
+ size: Size::new(width, regions.size.y),
+ full: regions.full,
+ backlog: &backlog,
+ last: regions.last,
+ expand: Axes::new(true, regions.expand.y),
+ root: regions.root,
+ };
+
+ // Layout the children.
+ let mut frames = body.layout(vt, styles, pod)?.into_iter();
+ let mut finished = vec![];
+
+ let dir = TextElem::dir_in(styles);
+ let total_regions = (frames.len() as f32 / columns as f32).ceil() as usize;
+
+ // Stitch together the columns for each region.
+ for region in regions.iter().take(total_regions) {
+ // The height should be the parent height if we should expand.
+ // Otherwise its the maximum column height for the frame. In that
+ // case, the frame is first created with zero height and then
+ // resized.
+ let height = if regions.expand.y { region.y } else { Abs::zero() };
+ let mut output = Frame::new(Size::new(regions.size.x, height));
+ let mut cursor = Abs::zero();
+
+ for _ in 0..columns {
+ let Some(frame) = frames.next() else { break };
+ if !regions.expand.y {
+ output.size_mut().y.set_max(frame.height());
+ }
+
+ let width = frame.width();
+ let x = if dir == Dir::LTR {
+ cursor
+ } else {
+ regions.size.x - cursor - width
+ };
+
+ output.push_frame(Point::with_x(x), frame);
+ cursor += width + gutter;
+ }
+
+ finished.push(output);
+ }
+
+ Ok(Fragment::frames(finished))
+ }
+}
+
+/// Forces a column break.
+///
+/// The function will behave like a [page break]($func/pagebreak) when used in a
+/// single column layout or the last column on a page. Otherwise, content after
+/// the column break will be placed in the next column.
+///
+/// ## Example { #example }
+/// ```example
+/// #set page(columns: 2)
+/// Preliminary findings from our
+/// ongoing research project have
+/// revealed a hitherto unknown
+/// phenomenon of extraordinary
+/// significance.
+///
+/// #colbreak()
+/// Through rigorous experimentation
+/// and analysis, we have discovered
+/// a hitherto uncharacterized process
+/// that defies our current
+/// understanding of the fundamental
+/// laws of nature.
+/// ```
+///
+/// Display: Column Break
+/// Category: layout
+#[element(Behave)]
+pub struct ColbreakElem {
+ /// If `{true}`, the column break is skipped if the current column is
+ /// already empty.
+ #[default(false)]
+ pub weak: bool,
+}
+
+impl Behave for ColbreakElem {
+ fn behaviour(&self) -> Behaviour {
+ if self.weak(StyleChain::default()) {
+ Behaviour::Weak(1)
+ } else {
+ Behaviour::Destructive
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/container.rs b/crates/typst-library/src/layout/container.rs
new file mode 100644
index 00000000..c79669d0
--- /dev/null
+++ b/crates/typst-library/src/layout/container.rs
@@ -0,0 +1,497 @@
+use typst::eval::AutoValue;
+
+use super::VElem;
+use crate::layout::Spacing;
+use crate::prelude::*;
+
+/// An inline-level container that sizes content.
+///
+/// All elements except inline math, text, and boxes are block-level and cannot
+/// occur inside of a paragraph. The box function can be used to integrate such
+/// elements into a paragraph. Boxes take the size of their contents by default
+/// but can also be sized explicitly.
+///
+/// ## Example { #example }
+/// ```example
+/// Refer to the docs
+/// #box(
+/// height: 9pt,
+/// image("docs.svg")
+/// )
+/// for more information.
+/// ```
+///
+/// Display: Box
+/// Category: layout
+#[element(Layout)]
+pub struct BoxElem {
+ /// The width of the box.
+ ///
+ /// Boxes can have [fractional]($type/fraction) widths, as the example
+ /// below demonstrates.
+ ///
+ /// _Note:_ Currently, only boxes and only their widths might be fractionally
+ /// sized within paragraphs. Support for fractionally sized images, shapes,
+ /// and more might be added in the future.
+ ///
+ /// ```example
+ /// Line in #box(width: 1fr, line(length: 100%)) between.
+ /// ```
+ pub width: Sizing,
+
+ /// The height of the box.
+ pub height: Smart<Rel<Length>>,
+
+ /// An amount to shift the box's baseline by.
+ ///
+ /// ```example
+ /// Image: #box(baseline: 40%, image("tiger.jpg", width: 2cm)).
+ /// ```
+ #[resolve]
+ pub baseline: Rel<Length>,
+
+ /// The box's background color. See the
+ /// [rectangle's documentation]($func/rect.fill) for more details.
+ pub fill: Option<Paint>,
+
+ /// The box's border color. See the
+ /// [rectangle's documentation]($func/rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ pub stroke: Sides<Option<Option<PartialStroke>>>,
+
+ /// How much to round the box'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 box's content. See the [rectangle's
+ /// documentation]($func/rect.inset) for more details.
+ #[resolve]
+ #[fold]
+ pub inset: Sides<Option<Rel<Length>>>,
+
+ /// How much to expand the box's size without affecting the layout.
+ ///
+ /// This is useful to prevent padding from affecting line layout. For a
+ /// generalized version of the example below, see the documentation for the
+ /// [raw text's block parameter]($func/raw.block).
+ ///
+ /// ```example
+ /// An inline
+ /// #box(
+ /// fill: luma(235),
+ /// inset: (x: 3pt, y: 0pt),
+ /// outset: (y: 3pt),
+ /// radius: 2pt,
+ /// )[rectangle].
+ /// ```
+ #[resolve]
+ #[fold]
+ pub outset: Sides<Option<Rel<Length>>>,
+
+ /// Whether to clip the content inside the box.
+ #[default(false)]
+ pub clip: bool,
+
+ /// The contents of the box.
+ #[positional]
+ pub body: Option<Content>,
+}
+
+impl Layout for BoxElem {
+ #[tracing::instrument(name = "BoxElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let width = match self.width(styles) {
+ Sizing::Auto => Smart::Auto,
+ Sizing::Rel(rel) => Smart::Custom(rel),
+ Sizing::Fr(_) => Smart::Custom(Ratio::one().into()),
+ };
+
+ // Resolve the sizing to a concrete size.
+ let sizing = Axes::new(width, self.height(styles));
+ let expand = sizing.as_ref().map(Smart::is_custom);
+ let size = sizing
+ .resolve(styles)
+ .zip(regions.base())
+ .map(|(s, b)| s.map(|v| v.relative_to(b)))
+ .unwrap_or(regions.base());
+
+ // Apply inset.
+ let mut body = self.body(styles).unwrap_or_default();
+ let inset = self.inset(styles);
+ if inset.iter().any(|v| !v.is_zero()) {
+ body = body.padded(inset.map(|side| side.map(Length::from)));
+ }
+
+ // Select the appropriate base and expansion for the child depending
+ // on whether it is automatically or relatively sized.
+ let pod = Regions::one(size, expand);
+ let mut frame = body.layout(vt, styles, pod)?.into_frame();
+
+ // Enforce correct size.
+ *frame.size_mut() = expand.select(size, frame.size());
+
+ // Apply baseline shift.
+ let shift = self.baseline(styles).relative_to(frame.height());
+ if !shift.is_zero() {
+ 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(PartialStroke::unwrap_or_default));
+
+ // Add fill and/or stroke.
+ if fill.is_some() || stroke.iter().any(Option::is_some) {
+ let outset = self.outset(styles);
+ let radius = self.radius(styles);
+ frame.fill_and_stroke(fill, stroke, outset, radius, self.span());
+ }
+
+ // Apply metadata.
+ frame.meta(styles, false);
+
+ Ok(Fragment::frame(frame))
+ }
+}
+
+/// A block-level container.
+///
+/// Such a container can be used to separate content, size it, and give it a
+/// background or border.
+///
+/// ## Examples { #examples }
+/// With a block, you can give a background to content while still allowing it
+/// to break across multiple pages.
+/// ```example
+/// #set page(height: 100pt)
+/// #block(
+/// fill: luma(230),
+/// inset: 8pt,
+/// radius: 4pt,
+/// lorem(30),
+/// )
+/// ```
+///
+/// Blocks are also useful to force elements that would otherwise be inline to
+/// become block-level, especially when writing show rules.
+/// ```example
+/// #show heading: it => it.body
+/// = Blockless
+/// More text.
+///
+/// #show heading: it => block(it.body)
+/// = Blocky
+/// More text.
+/// ```
+///
+/// Display: Block
+/// Category: layout
+#[element(Layout)]
+pub struct BlockElem {
+ /// The block's width.
+ ///
+ /// ```example
+ /// #set align(center)
+ /// #block(
+ /// width: 60%,
+ /// inset: 8pt,
+ /// fill: silver,
+ /// lorem(10),
+ /// )
+ /// ```
+ pub width: Smart<Rel<Length>>,
+
+ /// The block's height. When the height is larger than the remaining space
+ /// on a page and [`breakable`]($func/block.breakable) is `{true}`, the
+ /// block will continue on the next page with the remaining height.
+ ///
+ /// ```example
+ /// #set page(height: 80pt)
+ /// #set align(center)
+ /// #block(
+ /// width: 80%,
+ /// height: 150%,
+ /// fill: aqua,
+ /// )
+ /// ```
+ pub height: Smart<Rel<Length>>,
+
+ /// Whether the block can be broken and continue on the next page.
+ ///
+ /// ```example
+ /// #set page(height: 80pt)
+ /// The following block will
+ /// jump to its own page.
+ /// #block(
+ /// breakable: false,
+ /// lorem(15),
+ /// )
+ /// ```
+ #[default(true)]
+ pub breakable: bool,
+
+ /// The block's background color. See the
+ /// [rectangle's documentation]($func/rect.fill) for more details.
+ pub fill: Option<Paint>,
+
+ /// The block's border color. See the
+ /// [rectangle's documentation]($func/rect.stroke) for more details.
+ #[resolve]
+ #[fold]
+ pub stroke: Sides<Option<Option<PartialStroke>>>,
+
+ /// How much to round the block'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 block's content. See the [rectangle's
+ /// documentation]($func/rect.inset) for more details.
+ #[resolve]
+ #[fold]
+ pub inset: Sides<Option<Rel<Length>>>,
+
+ /// How much to expand the block'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 spacing around this block. This is shorthand to set `above` and
+ /// `below` to the same value.
+ ///
+ /// ```example
+ /// #set align(center)
+ /// #show math.equation: set block(above: 8pt, below: 16pt)
+ ///
+ /// This sum of $x$ and $y$:
+ /// $ x + y = z $
+ /// A second paragraph.
+ /// ```
+ #[external]
+ #[default(Em::new(1.2).into())]
+ pub spacing: Spacing,
+
+ /// The spacing between this block and its predecessor. Takes precedence
+ /// over `spacing`. Can be used in combination with a show rule to adjust
+ /// the spacing around arbitrary block-level elements.
+ #[external]
+ #[default(Em::new(1.2).into())]
+ pub above: Spacing,
+ #[internal]
+ #[parse(
+ let spacing = args.named("spacing")?;
+ args.named("above")?
+ .map(VElem::block_around)
+ .or_else(|| spacing.map(VElem::block_spacing))
+ )]
+ #[default(VElem::block_spacing(Em::new(1.2).into()))]
+ pub above: VElem,
+
+ /// The spacing between this block and its successor. Takes precedence
+ /// over `spacing`.
+ #[external]
+ #[default(Em::new(1.2).into())]
+ pub below: Spacing,
+ #[internal]
+ #[parse(
+ args.named("below")?
+ .map(VElem::block_around)
+ .or_else(|| spacing.map(VElem::block_spacing))
+ )]
+ #[default(VElem::block_spacing(Em::new(1.2).into()))]
+ pub below: VElem,
+
+ /// Whether to clip the content inside the block.
+ #[default(false)]
+ pub clip: bool,
+
+ /// The contents of the block.
+ #[positional]
+ pub body: Option<Content>,
+
+ /// Whether this block must stick to the following one.
+ ///
+ /// Use this to prevent page breaks between e.g. a heading and its body.
+ #[internal]
+ #[default(false)]
+ pub sticky: bool,
+}
+
+impl Layout for BlockElem {
+ #[tracing::instrument(name = "BlockElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ // Apply inset.
+ let mut body = self.body(styles).unwrap_or_default();
+ let inset = self.inset(styles);
+ if inset.iter().any(|v| !v.is_zero()) {
+ body = body.clone().padded(inset.map(|side| side.map(Length::from)));
+ }
+
+ // Resolve the sizing to a concrete size.
+ let sizing = Axes::new(self.width(styles), self.height(styles));
+ let mut expand = sizing.as_ref().map(Smart::is_custom);
+ let mut size = sizing
+ .resolve(styles)
+ .zip(regions.base())
+ .map(|(s, b)| s.map(|v| v.relative_to(b)))
+ .unwrap_or(regions.base());
+
+ // Layout the child.
+ let mut frames = if self.breakable(styles) {
+ // Measure to ensure frames for all regions have the same width.
+ if sizing.x == Smart::Auto {
+ let pod = Regions::one(size, Axes::splat(false));
+ let frame = body.measure(vt, styles, pod)?.into_frame();
+ size.x = frame.width();
+ expand.x = true;
+ }
+
+ let mut pod = regions;
+ pod.size.x = size.x;
+ pod.expand = expand;
+
+ if expand.y {
+ pod.full = size.y;
+ }
+
+ // Generate backlog for fixed height.
+ let mut heights = vec![];
+ if sizing.y.is_custom() {
+ let mut remaining = size.y;
+ for region in regions.iter() {
+ let limited = region.y.min(remaining);
+ heights.push(limited);
+ remaining -= limited;
+ if Abs::zero().fits(remaining) {
+ break;
+ }
+ }
+
+ if let Some(last) = heights.last_mut() {
+ *last += remaining;
+ }
+
+ pod.size.y = heights[0];
+ pod.backlog = &heights[1..];
+ pod.last = None;
+ }
+
+ let mut frames = body.layout(vt, styles, pod)?.into_frames();
+ for (frame, &height) in frames.iter_mut().zip(&heights) {
+ *frame.size_mut() =
+ expand.select(Size::new(size.x, height), frame.size());
+ }
+ frames
+ } else {
+ let pod = Regions::one(size, expand);
+ let mut frames = body.layout(vt, styles, pod)?.into_frames();
+ *frames[0].size_mut() = expand.select(size, frames[0].size());
+ 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(PartialStroke::unwrap_or_default));
+
+ // Add fill and/or stroke.
+ if fill.is_some() || stroke.iter().any(Option::is_some) {
+ let mut skip = false;
+ if let [first, rest @ ..] = frames.as_slice() {
+ skip = first.is_empty() && rest.iter().any(|frame| !frame.is_empty());
+ }
+
+ let outset = self.outset(styles);
+ let radius = self.radius(styles);
+ for frame in frames.iter_mut().skip(skip as usize) {
+ frame.fill_and_stroke(
+ fill.clone(),
+ stroke.clone(),
+ outset,
+ radius,
+ self.span(),
+ );
+ }
+ }
+
+ // Apply metadata.
+ for frame in &mut frames {
+ frame.meta(styles, false);
+ }
+
+ Ok(Fragment::frames(frames))
+ }
+}
+
+/// Defines how to size a grid cell along an axis.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Sizing {
+ /// A track that fits its cell's contents.
+ Auto,
+ /// A track size specified in absolute terms and relative to the parent's
+ /// size.
+ Rel(Rel<Length>),
+ /// A track size specified as a fraction of the remaining free space in the
+ /// parent.
+ Fr(Fr),
+}
+
+impl Sizing {
+ /// Whether this is fractional sizing.
+ pub fn is_fractional(self) -> bool {
+ matches!(self, Self::Fr(_))
+ }
+}
+
+impl Default for Sizing {
+ fn default() -> Self {
+ Self::Auto
+ }
+}
+
+impl<T: Into<Spacing>> From<T> for Sizing {
+ fn from(spacing: T) -> Self {
+ match spacing.into() {
+ Spacing::Rel(rel) => Self::Rel(rel),
+ Spacing::Fr(fr) => Self::Fr(fr),
+ }
+ }
+}
+
+cast! {
+ Sizing,
+ self => match self {
+ Self::Auto => Value::Auto,
+ Self::Rel(rel) => rel.into_value(),
+ Self::Fr(fr) => fr.into_value(),
+ },
+ _: AutoValue => Self::Auto,
+ v: Rel<Length> => Self::Rel(v),
+ v: Fr => Self::Fr(v),
+}
diff --git a/crates/typst-library/src/layout/enum.rs b/crates/typst-library/src/layout/enum.rs
new file mode 100644
index 00000000..d66477fc
--- /dev/null
+++ b/crates/typst-library/src/layout/enum.rs
@@ -0,0 +1,318 @@
+use std::str::FromStr;
+
+use crate::layout::{BlockElem, ParElem, Sizing, Spacing};
+use crate::meta::{Numbering, NumberingPattern};
+use crate::prelude::*;
+use crate::text::TextElem;
+
+use super::GridLayouter;
+
+/// A numbered list.
+///
+/// Displays a sequence of items vertically and numbers them consecutively.
+///
+/// ## Example { #example }
+/// ```example
+/// Automatically numbered:
+/// + Preparations
+/// + Analysis
+/// + Conclusions
+///
+/// Manually numbered:
+/// 2. What is the first step?
+/// 5. I am confused.
+/// + Moving on ...
+///
+/// Function call.
+/// #enum[First][Second]
+/// ```
+///
+/// You can easily switch all your enumerations to a different numbering style
+/// with a set rule.
+/// ```example
+/// #set enum(numbering: "a)")
+///
+/// + Starting off ...
+/// + Don't forget step two
+/// ```
+///
+/// You can also use [`enum.item`]($func/enum.item) to programmatically
+/// customize the number of each item in the enumeration:
+///
+/// ```example
+/// #enum(
+/// enum.item(1)[First step],
+/// enum.item(5)[Fifth step],
+/// enum.item(10)[Tenth step]
+/// )
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This functions also has dedicated syntax:
+///
+/// - Starting a line with a plus sign creates an automatically numbered
+/// enumeration item.
+/// - Starting a line with a number followed by a dot creates an explicitly
+/// numbered enumeration item.
+///
+/// Enumeration items can contain multiple paragraphs and other block-level
+/// content. All content that is indented more than an item's plus sign or dot
+/// becomes part of that item.
+///
+/// Display: Numbered List
+/// Category: layout
+#[element(Layout)]
+#[scope(
+ scope.define("item", EnumItem::func());
+ scope
+)]
+pub struct EnumElem {
+ /// If this is `{false}`, the items are spaced apart with
+ /// [enum spacing]($func/enum.spacing). If it is `{true}`, they use normal
+ /// [leading]($func/par.leading) instead. This makes the enumeration more
+ /// compact, which can look better if the items are short.
+ ///
+ /// In markup mode, the value of this parameter is determined based on
+ /// whether items are separated with a blank line. If items directly follow
+ /// each other, this is set to `{true}`; if items are separated by a blank
+ /// line, this is set to `{false}`.
+ ///
+ /// ```example
+ /// + If an enum has a lot of text, and
+ /// maybe other inline content, it
+ /// should not be tight anymore.
+ ///
+ /// + To make an enum wide, simply
+ /// insert a blank line between the
+ /// items.
+ /// ```
+ #[default(true)]
+ pub tight: bool,
+
+ /// How to number the enumeration. Accepts a
+ /// [numbering pattern or function]($func/numbering).
+ ///
+ /// If the numbering pattern contains multiple counting symbols, they apply
+ /// to nested enums. If given a function, the function receives one argument
+ /// if `full` is `{false}` and multiple arguments if `full` is `{true}`.
+ ///
+ /// ```example
+ /// #set enum(numbering: "1.a)")
+ /// + Different
+ /// + Numbering
+ /// + Nested
+ /// + Items
+ /// + Style
+ ///
+ /// #set enum(numbering: n => super[#n])
+ /// + Superscript
+ /// + Numbering!
+ /// ```
+ #[default(Numbering::Pattern(NumberingPattern::from_str("1.").unwrap()))]
+ pub numbering: Numbering,
+
+ /// Which number to start the enumeration with.
+ ///
+ /// ```example
+ /// #enum(
+ /// start: 3,
+ /// [Skipping],
+ /// [Ahead],
+ /// )
+ /// ```
+ #[default(1)]
+ pub start: usize,
+
+ /// Whether to display the full numbering, including the numbers of
+ /// all parent enumerations.
+ ///
+ ///
+ /// ```example
+ /// #set enum(numbering: "1.a)", full: true)
+ /// + Cook
+ /// + Heat water
+ /// + Add integredients
+ /// + Eat
+ /// ```
+ #[default(false)]
+ pub full: bool,
+
+ /// The indentation of each item.
+ #[resolve]
+ pub indent: Length,
+
+ /// The space between the numbering and the body of each item.
+ #[resolve]
+ #[default(Em::new(0.5).into())]
+ pub body_indent: Length,
+
+ /// The spacing between the items of a wide (non-tight) enumeration.
+ ///
+ /// If set to `{auto}`, uses the spacing [below blocks]($func/block.below).
+ pub spacing: Smart<Spacing>,
+
+ /// The horizontal alignment that enum numbers should have.
+ ///
+ /// By default, this is set to `{end}`, which aligns enum numbers
+ /// towards end of the current text direction (in left-to-right script,
+ /// for example, this is the same as `{right}`). The choice of `{end}`
+ /// for horizontal alignment of enum numbers is usually preferred over
+ /// `{start}`, as numbers then grow away from the text instead of towards
+ /// it, avoiding certain visual issues. This option lets you override this
+ /// behavior, however.
+ ///
+ /// ````example
+ /// #set enum(number-align: start)
+ ///
+ /// Here are some powers of two:
+ /// 1. One
+ /// 2. Two
+ /// 4. Four
+ /// 8. Eight
+ /// 16. Sixteen
+ /// 32. Thirty two
+ /// ````
+ #[default(HorizontalAlign(GenAlign::End))]
+ pub number_align: HorizontalAlign,
+
+ /// The numbered list's items.
+ ///
+ /// When using the enum syntax, adjacent items are automatically collected
+ /// into enumerations, even through constructs like for loops.
+ ///
+ /// ```example
+ /// #for phase in (
+ /// "Launch",
+ /// "Orbit",
+ /// "Descent",
+ /// ) [+ #phase]
+ /// ```
+ #[variadic]
+ pub children: Vec<EnumItem>,
+
+ /// The numbers of parent items.
+ #[internal]
+ #[fold]
+ parents: Parent,
+}
+
+impl Layout for EnumElem {
+ #[tracing::instrument(name = "EnumElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let numbering = self.numbering(styles);
+ let indent = self.indent(styles);
+ let body_indent = self.body_indent(styles);
+ let gutter = if self.tight(styles) {
+ ParElem::leading_in(styles).into()
+ } else {
+ self.spacing(styles)
+ .unwrap_or_else(|| BlockElem::below_in(styles).amount())
+ };
+
+ let mut cells = vec![];
+ let mut number = self.start(styles);
+ let mut parents = self.parents(styles);
+ let full = self.full(styles);
+
+ // Horizontally align based on the given respective parameter.
+ // Vertically align to the top to avoid inheriting 'horizon' or
+ // 'bottom' alignment from the context and having the number be
+ // displaced in relation to the item it refers to.
+ let number_align: Axes<Option<GenAlign>> =
+ Axes::new(self.number_align(styles).into(), Align::Top.into()).map(Some);
+
+ for item in self.children() {
+ number = item.number(styles).unwrap_or(number);
+
+ let resolved = if full {
+ parents.push(number);
+ let content = numbering.apply_vt(vt, &parents)?.display();
+ parents.pop();
+ content
+ } else {
+ match &numbering {
+ Numbering::Pattern(pattern) => {
+ TextElem::packed(pattern.apply_kth(parents.len(), number))
+ }
+ other => other.apply_vt(vt, &[number])?.display(),
+ }
+ };
+
+ // Disable overhang as a workaround to end-aligned dots glitching
+ // and decreasing spacing between numbers and items.
+ let resolved =
+ resolved.aligned(number_align).styled(TextElem::set_overhang(false));
+
+ cells.push(Content::empty());
+ cells.push(resolved);
+ cells.push(Content::empty());
+ cells.push(item.body().styled(Self::set_parents(Parent(number))));
+ number = number.saturating_add(1);
+ }
+
+ let layouter = GridLayouter::new(
+ Axes::with_x(&[
+ Sizing::Rel(indent.into()),
+ Sizing::Auto,
+ Sizing::Rel(body_indent.into()),
+ Sizing::Auto,
+ ]),
+ Axes::with_y(&[gutter.into()]),
+ &cells,
+ regions,
+ styles,
+ );
+
+ Ok(layouter.layout(vt)?.fragment)
+ }
+}
+
+/// An enumeration item.
+///
+/// Display: Numbered List Item
+/// Category: layout
+#[element]
+pub struct EnumItem {
+ /// The item's number.
+ #[positional]
+ pub number: Option<usize>,
+
+ /// The item's body.
+ #[required]
+ pub body: Content,
+}
+
+cast! {
+ EnumItem,
+ array: Array => {
+ let mut iter = array.into_iter();
+ let (number, body) = match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => (a.cast()?, b.cast()?),
+ _ => bail!("array must contain exactly two entries"),
+ };
+ Self::new(body).with_number(number)
+ },
+ v: Content => v.to::<Self>().cloned().unwrap_or_else(|| Self::new(v.clone())),
+}
+
+struct Parent(usize);
+
+cast! {
+ Parent,
+ self => self.0.into_value(),
+ v: usize => Self(v),
+}
+
+impl Fold for Parent {
+ type Output = Vec<usize>;
+
+ fn fold(self, mut outer: Self::Output) -> Self::Output {
+ outer.push(self.0);
+ outer
+ }
+}
diff --git a/crates/typst-library/src/layout/flow.rs b/crates/typst-library/src/layout/flow.rs
new file mode 100644
index 00000000..accd092a
--- /dev/null
+++ b/crates/typst-library/src/layout/flow.rs
@@ -0,0 +1,583 @@
+use std::mem;
+
+use super::{
+ AlignElem, BlockElem, ColbreakElem, ColumnsElem, ParElem, PlaceElem, Spacing, VElem,
+};
+use crate::meta::{FootnoteElem, FootnoteEntry};
+use crate::prelude::*;
+use crate::visualize::{
+ CircleElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, RectElem,
+ SquareElem,
+};
+
+/// Arranges spacing, paragraphs and block-level elements into a flow.
+///
+/// This element is responsible for layouting both the top-level content flow
+/// and the contents of boxes.
+///
+/// Display: Flow
+/// Category: layout
+#[element(Layout)]
+pub struct FlowElem {
+ /// The children that will be arranges into a flow.
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+impl Layout for FlowElem {
+ #[tracing::instrument(name = "FlowElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let mut layouter = FlowLayouter::new(regions, styles);
+
+ for mut child in &self.children() {
+ let outer = styles;
+ let mut styles = styles;
+ if let Some((elem, map)) = child.to_styled() {
+ child = elem;
+ styles = outer.chain(map);
+ }
+
+ if let Some(elem) = child.to::<VElem>() {
+ layouter.layout_spacing(vt, elem, styles)?;
+ } else if let Some(elem) = child.to::<ParElem>() {
+ layouter.layout_par(vt, elem, styles)?;
+ } else if child.is::<LineElem>()
+ || child.is::<RectElem>()
+ || child.is::<SquareElem>()
+ || child.is::<EllipseElem>()
+ || child.is::<CircleElem>()
+ || child.is::<ImageElem>()
+ || child.is::<PolygonElem>()
+ || child.is::<PathElem>()
+ {
+ let layoutable = child.with::<dyn Layout>().unwrap();
+ layouter.layout_single(vt, layoutable, styles)?;
+ } else if child.is::<MetaElem>() {
+ let mut frame = Frame::new(Size::zero());
+ frame.meta(styles, true);
+ layouter.items.push(FlowItem::Frame {
+ frame,
+ aligns: Axes::new(Align::Top, Align::Left),
+ sticky: true,
+ movable: false,
+ });
+ } else if child.can::<dyn Layout>() {
+ layouter.layout_multiple(vt, child, styles)?;
+ } else if child.is::<ColbreakElem>() {
+ if !layouter.regions.backlog.is_empty() || layouter.regions.last.is_some()
+ {
+ layouter.finish_region()?;
+ }
+ } else {
+ bail!(child.span(), "unexpected flow child");
+ }
+ }
+
+ layouter.finish()
+ }
+}
+
+/// Performs flow layout.
+struct FlowLayouter<'a> {
+ /// Whether this is the root flow.
+ root: bool,
+ /// The regions to layout children into.
+ regions: Regions<'a>,
+ /// The shared styles.
+ styles: StyleChain<'a>,
+ /// Whether the flow should expand to fill the region.
+ expand: Axes<bool>,
+ /// The initial size of `regions.size` that was available before we started
+ /// subtracting.
+ initial: Size,
+ /// Whether the last block was a paragraph.
+ last_was_par: bool,
+ /// Spacing and layouted blocks for the current region.
+ items: Vec<FlowItem>,
+ /// Whether we have any footnotes in the current region.
+ has_footnotes: bool,
+ /// Footnote configuration.
+ footnote_config: FootnoteConfig,
+ /// Finished frames for previous regions.
+ finished: Vec<Frame>,
+}
+
+/// Cached footnote configuration.
+struct FootnoteConfig {
+ separator: Content,
+ clearance: Abs,
+ gap: Abs,
+}
+
+/// A prepared item in a flow layout.
+#[derive(Debug)]
+enum FlowItem {
+ /// Spacing between other items and whether it is weak.
+ Absolute(Abs, bool),
+ /// Fractional spacing between other items.
+ Fractional(Fr),
+ /// A frame for a layouted block, how to align it, whether it sticks to the
+ /// item after it (for orphan prevention), and whether it is movable
+ /// (to keep it together with its footnotes).
+ Frame { frame: Frame, aligns: Axes<Align>, sticky: bool, movable: bool },
+ /// An absolutely placed frame.
+ Placed(Frame),
+ /// A footnote frame (can also be the separator).
+ Footnote(Frame),
+}
+
+impl FlowItem {
+ /// The inherent height of the item.
+ fn height(&self) -> Abs {
+ match self {
+ Self::Absolute(v, _) => *v,
+ Self::Fractional(_) | Self::Placed(_) => Abs::zero(),
+ Self::Frame { frame, .. } | Self::Footnote(frame) => frame.height(),
+ }
+ }
+}
+
+impl<'a> FlowLayouter<'a> {
+ /// Create a new flow layouter.
+ fn new(mut regions: Regions<'a>, styles: StyleChain<'a>) -> Self {
+ let expand = regions.expand;
+
+ // Disable vertical expansion & root for children.
+ regions.expand.y = false;
+ let root = mem::replace(&mut regions.root, false);
+
+ Self {
+ root,
+ regions,
+ styles,
+ expand,
+ initial: regions.size,
+ last_was_par: false,
+ items: vec![],
+ has_footnotes: false,
+ footnote_config: FootnoteConfig {
+ separator: FootnoteEntry::separator_in(styles),
+ clearance: FootnoteEntry::clearance_in(styles),
+ gap: FootnoteEntry::gap_in(styles),
+ },
+ finished: vec![],
+ }
+ }
+
+ /// Layout vertical spacing.
+ #[tracing::instrument(name = "FlowLayouter::layout_spacing", skip_all)]
+ fn layout_spacing(
+ &mut self,
+ vt: &mut Vt,
+ v: &VElem,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ self.layout_item(
+ vt,
+ match v.amount() {
+ Spacing::Rel(rel) => FlowItem::Absolute(
+ rel.resolve(styles).relative_to(self.initial.y),
+ v.weakness(styles) > 0,
+ ),
+ Spacing::Fr(fr) => FlowItem::Fractional(fr),
+ },
+ )
+ }
+
+ /// Layout a paragraph.
+ #[tracing::instrument(name = "FlowLayouter::layout_par", skip_all)]
+ fn layout_par(
+ &mut self,
+ vt: &mut Vt,
+ par: &ParElem,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ let aligns = AlignElem::alignment_in(styles).resolve(styles);
+ let leading = ParElem::leading_in(styles);
+ let consecutive = self.last_was_par;
+ let lines = par
+ .layout(vt, styles, consecutive, self.regions.base(), self.regions.expand.x)?
+ .into_frames();
+
+ let mut sticky = self.items.len();
+ for (i, item) in self.items.iter().enumerate().rev() {
+ match *item {
+ FlowItem::Absolute(_, _) => {}
+ FlowItem::Frame { sticky: true, .. } => sticky = i,
+ _ => break,
+ }
+ }
+
+ if let Some(first) = lines.first() {
+ if !self.regions.size.y.fits(first.height()) && !self.regions.in_last() {
+ let carry: Vec<_> = self.items.drain(sticky..).collect();
+ self.finish_region()?;
+ for item in carry {
+ self.layout_item(vt, item)?;
+ }
+ }
+ }
+
+ for (i, frame) in lines.into_iter().enumerate() {
+ if i > 0 {
+ self.layout_item(vt, FlowItem::Absolute(leading, true))?;
+ }
+
+ self.layout_item(
+ vt,
+ FlowItem::Frame { frame, aligns, sticky: false, movable: true },
+ )?;
+ }
+
+ self.last_was_par = true;
+ Ok(())
+ }
+
+ /// Layout into a single region.
+ #[tracing::instrument(name = "FlowLayouter::layout_single", skip_all)]
+ fn layout_single(
+ &mut self,
+ vt: &mut Vt,
+ content: &dyn Layout,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ let aligns = AlignElem::alignment_in(styles).resolve(styles);
+ let sticky = BlockElem::sticky_in(styles);
+ let pod = Regions::one(self.regions.base(), Axes::splat(false));
+ let frame = content.layout(vt, styles, pod)?.into_frame();
+ self.layout_item(vt, FlowItem::Frame { frame, aligns, sticky, movable: true })?;
+ self.last_was_par = false;
+ Ok(())
+ }
+
+ /// Layout into multiple regions.
+ fn layout_multiple(
+ &mut self,
+ vt: &mut Vt,
+ block: &Content,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ // Placed elements that are out of flow produce placed items which
+ // aren't aligned later.
+ if let Some(placed) = block.to::<PlaceElem>() {
+ if placed.out_of_flow(styles) {
+ let frame = block.layout(vt, styles, self.regions)?.into_frame();
+ self.layout_item(vt, FlowItem::Placed(frame))?;
+ return Ok(());
+ }
+ } else if self.regions.is_full() {
+ // Skip directly if region is already full.
+ self.finish_region()?;
+ }
+
+ // How to align the block.
+ let aligns = if let Some(align) = block.to::<AlignElem>() {
+ align.alignment(styles)
+ } else if let Some((_, local)) = block.to_styled() {
+ AlignElem::alignment_in(styles.chain(local))
+ } else {
+ AlignElem::alignment_in(styles)
+ }
+ .resolve(styles);
+
+ // Temporarily delegerate rootness to the columns.
+ let is_root = self.root;
+ if is_root && block.is::<ColumnsElem>() {
+ self.root = false;
+ self.regions.root = true;
+ }
+
+ // Layout the block itself.
+ let sticky = BlockElem::sticky_in(styles);
+ let fragment = block.layout(vt, styles, self.regions)?;
+ let mut notes = Vec::new();
+
+ for (i, frame) in fragment.into_iter().enumerate() {
+ // Find footnotes in the frame.
+ if self.root {
+ find_footnotes(&mut notes, &frame);
+ }
+
+ if i > 0 {
+ self.finish_region()?;
+ }
+
+ self.layout_item(
+ vt,
+ FlowItem::Frame { frame, aligns, sticky, movable: false },
+ )?;
+ }
+
+ if self.root && !self.handle_footnotes(vt, &mut notes, false, false)? {
+ self.finish_region()?;
+ self.handle_footnotes(vt, &mut notes, false, true)?;
+ }
+
+ self.root = is_root;
+ self.regions.root = false;
+ self.last_was_par = false;
+
+ Ok(())
+ }
+
+ /// Layout a finished frame.
+ #[tracing::instrument(name = "FlowLayouter::layout_item", skip_all)]
+ fn layout_item(&mut self, vt: &mut Vt, item: FlowItem) -> SourceResult<()> {
+ match item {
+ FlowItem::Absolute(v, weak) => {
+ if weak
+ && !self
+ .items
+ .iter()
+ .any(|item| matches!(item, FlowItem::Frame { .. }))
+ {
+ return Ok(());
+ }
+ self.regions.size.y -= v
+ }
+ FlowItem::Fractional(_) => {}
+ FlowItem::Frame { ref frame, movable, .. } => {
+ let size = frame.size();
+ if !self.regions.size.y.fits(size.y) && !self.regions.in_last() {
+ self.finish_region()?;
+ }
+
+ self.regions.size.y -= size.y;
+ if self.root && movable {
+ let mut notes = Vec::new();
+ find_footnotes(&mut notes, frame);
+ self.items.push(item);
+ if !self.handle_footnotes(vt, &mut notes, true, false)? {
+ let item = self.items.pop();
+ self.finish_region()?;
+ self.items.extend(item);
+ self.regions.size.y -= size.y;
+ self.handle_footnotes(vt, &mut notes, true, true)?;
+ }
+ return Ok(());
+ }
+ }
+ FlowItem::Placed(_) => {}
+ FlowItem::Footnote(_) => {}
+ }
+
+ self.items.push(item);
+ Ok(())
+ }
+
+ /// Finish the frame for one region.
+ fn finish_region(&mut self) -> SourceResult<()> {
+ // Trim weak spacing.
+ while self
+ .items
+ .last()
+ .map_or(false, |item| matches!(item, FlowItem::Absolute(_, true)))
+ {
+ self.items.pop();
+ }
+
+ // Determine the used size.
+ let mut fr = Fr::zero();
+ let mut used = Size::zero();
+ let mut footnote_height = Abs::zero();
+ let mut first_footnote = true;
+ for item in &self.items {
+ match item {
+ FlowItem::Absolute(v, _) => used.y += *v,
+ FlowItem::Fractional(v) => fr += *v,
+ FlowItem::Frame { frame, .. } => {
+ let size = frame.size();
+ used.y += size.y;
+ used.x.set_max(size.x);
+ }
+ FlowItem::Placed(_) => {}
+ FlowItem::Footnote(frame) => {
+ let size = frame.size();
+ footnote_height += size.y;
+ if !first_footnote {
+ footnote_height += self.footnote_config.gap;
+ }
+ first_footnote = false;
+ used.x.set_max(size.x);
+ }
+ }
+ }
+ used.y += footnote_height;
+
+ // Determine the size of the flow in this region depending on whether
+ // the region expands. Also account for fractional spacing and
+ // footnotes.
+ let mut size = self.expand.select(self.initial, used).min(self.initial);
+ if (fr.get() > 0.0 || self.has_footnotes) && self.initial.y.is_finite() {
+ size.y = self.initial.y;
+ }
+
+ let mut output = Frame::new(size);
+ let mut offset = Abs::zero();
+ let mut ruler = Align::Top;
+ let mut footnote_offset = size.y - footnote_height;
+
+ // Place all frames.
+ for item in self.items.drain(..) {
+ match item {
+ FlowItem::Absolute(v, _) => {
+ offset += v;
+ }
+ FlowItem::Fractional(v) => {
+ let remaining = self.initial.y - used.y;
+ offset += v.share(fr, remaining);
+ }
+ FlowItem::Frame { frame, aligns, .. } => {
+ ruler = ruler.max(aligns.y);
+ let x = aligns.x.position(size.x - frame.width());
+ let y = offset + ruler.position(size.y - used.y);
+ let pos = Point::new(x, y);
+ offset += frame.height();
+ output.push_frame(pos, frame);
+ }
+ FlowItem::Footnote(frame) => {
+ let pos = Point::with_y(footnote_offset);
+ footnote_offset += frame.height() + self.footnote_config.gap;
+ output.push_frame(pos, frame);
+ }
+ FlowItem::Placed(frame) => {
+ output.push_frame(Point::zero(), frame);
+ }
+ }
+ }
+
+ // Advance to the next region.
+ self.finished.push(output);
+ self.regions.next();
+ self.initial = self.regions.size;
+ self.has_footnotes = false;
+ Ok(())
+ }
+
+ /// Finish layouting and return the resulting fragment.
+ fn finish(mut self) -> SourceResult<Fragment> {
+ if self.expand.y {
+ while !self.regions.backlog.is_empty() {
+ self.finish_region()?;
+ }
+ }
+
+ self.finish_region()?;
+ Ok(Fragment::frames(self.finished))
+ }
+}
+
+impl FlowLayouter<'_> {
+ /// Processes all footnotes in the frame.
+ #[tracing::instrument(skip_all)]
+ fn handle_footnotes(
+ &mut self,
+ vt: &mut Vt,
+ notes: &mut Vec<FootnoteElem>,
+ movable: bool,
+ force: bool,
+ ) -> SourceResult<bool> {
+ let items_len = self.items.len();
+ let notes_len = notes.len();
+
+ // Process footnotes one at a time.
+ let mut k = 0;
+ while k < notes.len() {
+ if notes[k].is_ref() {
+ k += 1;
+ continue;
+ }
+
+ if !self.has_footnotes {
+ self.layout_footnote_separator(vt)?;
+ }
+
+ self.regions.size.y -= self.footnote_config.gap;
+ let frames = FootnoteEntry::new(notes[k].clone())
+ .pack()
+ .layout(vt, self.styles, self.regions.with_root(false))?
+ .into_frames();
+
+ // If the entries didn't fit, abort (to keep footnote and entry
+ // together).
+ if !force
+ && (k == 0 || movable)
+ && frames.first().map_or(false, Frame::is_empty)
+ {
+ // Remove existing footnotes attempts because we need to
+ // move the item to the next page.
+ notes.truncate(notes_len);
+
+ // Undo region modifications.
+ for item in self.items.drain(items_len..) {
+ self.regions.size.y -= item.height();
+ }
+
+ return Ok(false);
+ }
+
+ let prev = notes.len();
+ for (i, frame) in frames.into_iter().enumerate() {
+ find_footnotes(notes, &frame);
+ if i > 0 {
+ self.finish_region()?;
+ self.layout_footnote_separator(vt)?;
+ self.regions.size.y -= self.footnote_config.gap;
+ }
+ self.regions.size.y -= frame.height();
+ self.items.push(FlowItem::Footnote(frame));
+ }
+
+ k += 1;
+
+ // Process the nested notes before dealing with further top-level
+ // notes.
+ let nested = notes.len() - prev;
+ if nested > 0 {
+ notes[k..].rotate_right(nested);
+ }
+ }
+
+ Ok(true)
+ }
+
+ /// Layout and save the footnote separator, typically a line.
+ #[tracing::instrument(skip_all)]
+ fn layout_footnote_separator(&mut self, vt: &mut Vt) -> SourceResult<()> {
+ let expand = Axes::new(self.regions.expand.x, false);
+ let pod = Regions::one(self.regions.base(), expand);
+ let separator = &self.footnote_config.separator;
+
+ let mut frame = separator.layout(vt, self.styles, pod)?.into_frame();
+ frame.size_mut().y += self.footnote_config.clearance;
+ frame.translate(Point::with_y(self.footnote_config.clearance));
+
+ self.has_footnotes = true;
+ self.regions.size.y -= frame.height();
+ self.items.push(FlowItem::Footnote(frame));
+
+ Ok(())
+ }
+}
+
+/// Finds all footnotes in the frame.
+#[tracing::instrument(skip_all)]
+fn find_footnotes(notes: &mut Vec<FootnoteElem>, frame: &Frame) {
+ for (_, item) in frame.items() {
+ match item {
+ FrameItem::Group(group) => find_footnotes(notes, &group.frame),
+ FrameItem::Meta(Meta::Elem(content), _)
+ if !notes.iter().any(|note| note.0.location() == content.location()) =>
+ {
+ let Some(footnote) = content.to::<FootnoteElem>() else { continue };
+ notes.push(footnote.clone());
+ }
+ _ => {}
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/fragment.rs b/crates/typst-library/src/layout/fragment.rs
new file mode 100644
index 00000000..3550df2a
--- /dev/null
+++ b/crates/typst-library/src/layout/fragment.rs
@@ -0,0 +1,87 @@
+use crate::prelude::*;
+
+/// A partial layout result.
+#[derive(Clone)]
+pub struct Fragment(Vec<Frame>);
+
+impl Fragment {
+ /// Create a fragment from a single frame.
+ pub fn frame(frame: Frame) -> Self {
+ Self(vec![frame])
+ }
+
+ /// Create a fragment from multiple frames.
+ pub fn frames(frames: Vec<Frame>) -> Self {
+ Self(frames)
+ }
+
+ /// Return `true` if the length is 0.
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ /// The number of frames in the fragment.
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
+ /// Extract the first and only frame.
+ ///
+ /// Panics if there are multiple frames.
+ #[track_caller]
+ pub fn into_frame(self) -> Frame {
+ assert_eq!(self.0.len(), 1, "expected exactly one frame");
+ self.0.into_iter().next().unwrap()
+ }
+
+ /// Extract the frames.
+ pub fn into_frames(self) -> Vec<Frame> {
+ self.0
+ }
+
+ /// Iterate over the contained frames.
+ pub fn iter(&self) -> std::slice::Iter<Frame> {
+ self.0.iter()
+ }
+
+ /// Iterate over the contained frames.
+ pub fn iter_mut(&mut self) -> std::slice::IterMut<Frame> {
+ self.0.iter_mut()
+ }
+}
+
+impl Debug for Fragment {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self.0.as_slice() {
+ [frame] => frame.fmt(f),
+ frames => frames.fmt(f),
+ }
+ }
+}
+
+impl IntoIterator for Fragment {
+ type Item = Frame;
+ type IntoIter = std::vec::IntoIter<Frame>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter()
+ }
+}
+
+impl<'a> IntoIterator for &'a Fragment {
+ type Item = &'a Frame;
+ type IntoIter = std::slice::Iter<'a, Frame>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.iter()
+ }
+}
+
+impl<'a> IntoIterator for &'a mut Fragment {
+ type Item = &'a mut Frame;
+ type IntoIter = std::slice::IterMut<'a, Frame>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.iter_mut()
+ }
+}
diff --git a/crates/typst-library/src/layout/grid.rs b/crates/typst-library/src/layout/grid.rs
new file mode 100644
index 00000000..4f5175e9
--- /dev/null
+++ b/crates/typst-library/src/layout/grid.rs
@@ -0,0 +1,708 @@
+use crate::prelude::*;
+use crate::text::TextElem;
+
+use super::Sizing;
+
+/// Arranges content in a grid.
+///
+/// The grid element allows you to arrange content in a grid. You can define the
+/// number of rows and columns, as well as the size of the gutters between them.
+/// There are multiple sizing modes for columns and rows that can be used to
+/// create complex layouts.
+///
+/// The sizing of the grid is determined by the track sizes specified in the
+/// arguments. Because each of the sizing parameters accepts the same values, we
+/// will explain them just once, here. Each sizing argument accepts an array of
+/// individual track sizes. A track size is either:
+///
+/// - `{auto}`: The track will be sized to fit its contents. It will be at most
+/// as large as the remaining space. If there is more than one `{auto}` track
+/// which, and together they claim more than the available space, the `{auto}`
+/// tracks will fairly distribute the available space among themselves.
+///
+/// - A fixed or relative length (e.g. `{10pt}` or `{20% - 1cm}`): The track
+/// will be exactly of this size.
+///
+/// - A fractional length (e.g. `{1fr}`): Once all other tracks have been sized,
+/// the remaining space will be divided among the fractional tracks according
+/// to their fractions. For example, if there are two fractional tracks, each
+/// with a fraction of `{1fr}`, they will each take up half of the remaining
+/// space.
+///
+/// To specify a single track, the array can be omitted in favor of a single
+/// value. To specify multiple `{auto}` tracks, enter the number of tracks
+/// instead of an array. For example, `columns:` `{3}` is equivalent to
+/// `columns:` `{(auto, auto, auto)}`.
+///
+/// ## Example { #example }
+/// ```example
+/// #set text(10pt, style: "italic")
+/// #let cell = rect.with(
+/// inset: 8pt,
+/// fill: rgb("e4e5ea"),
+/// width: 100%,
+/// radius: 6pt
+/// )
+/// #grid(
+/// columns: (60pt, 1fr, 60pt),
+/// rows: (60pt, auto),
+/// gutter: 3pt,
+/// cell(height: 100%)[Easy to learn],
+/// cell(height: 100%)[Great output],
+/// cell(height: 100%)[Intuitive],
+/// cell[Our best Typst yet],
+/// cell[
+/// Responsive design in print
+/// for everyone
+/// ],
+/// cell[One more thing...],
+/// )
+/// ```
+///
+/// Display: Grid
+/// Category: layout
+#[element(Layout)]
+pub struct GridElem {
+ /// The column sizes.
+ ///
+ /// Either specify a track size array or provide an integer to create a grid
+ /// with that many `{auto}`-sized columns. Note that opposed to rows and
+ /// gutters, providing a single track size will only ever create a single
+ /// column.
+ pub columns: TrackSizings,
+
+ /// The row sizes.
+ ///
+ /// If there are more cells than fit the defined rows, the last row is
+ /// repeated until there are no more cells.
+ pub rows: TrackSizings,
+
+ /// The gaps between rows & columns.
+ ///
+ /// If there are more gutters than defined sizes, the last gutter is repeated.
+ #[external]
+ pub gutter: TrackSizings,
+
+ /// The gaps between columns. Takes precedence over `gutter`.
+ #[parse(
+ let gutter = args.named("gutter")?;
+ args.named("column-gutter")?.or_else(|| gutter.clone())
+ )]
+ pub column_gutter: TrackSizings,
+
+ /// The gaps between rows. Takes precedence over `gutter`.
+ #[parse(args.named("row-gutter")?.or_else(|| gutter.clone()))]
+ pub row_gutter: TrackSizings,
+
+ /// The contents of the grid cells.
+ ///
+ /// The cells are populated in row-major order.
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+impl Layout for GridElem {
+ #[tracing::instrument(name = "GridElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ // Prepare grid layout by unifying content and gutter tracks.
+ let cells = self.children();
+ let layouter = GridLayouter::new(
+ Axes::new(&self.columns(styles).0, &self.rows(styles).0),
+ Axes::new(&self.column_gutter(styles).0, &self.row_gutter(styles).0),
+ &cells,
+ regions,
+ styles,
+ );
+
+ // Measure the columns and layout the grid row-by-row.
+ Ok(layouter.layout(vt)?.fragment)
+ }
+}
+
+/// Track sizing definitions.
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct TrackSizings(pub Vec<Sizing>);
+
+cast! {
+ TrackSizings,
+ self => self.0.into_value(),
+ sizing: Sizing => Self(vec![sizing]),
+ count: NonZeroUsize => Self(vec![Sizing::Auto; count.get()]),
+ values: Array => Self(values.into_iter().map(Value::cast).collect::<StrResult<_>>()?),
+}
+
+/// Performs grid layout.
+pub struct GridLayouter<'a> {
+ /// The grid cells.
+ cells: &'a [Content],
+ /// Whether this is an RTL grid.
+ is_rtl: bool,
+ /// Whether this grid has gutters.
+ has_gutter: bool,
+ /// The column tracks including gutter tracks.
+ cols: Vec<Sizing>,
+ /// The row tracks including gutter tracks.
+ rows: Vec<Sizing>,
+ /// The regions to layout children into.
+ regions: Regions<'a>,
+ /// The inherited styles.
+ styles: StyleChain<'a>,
+ /// Resolved column sizes.
+ rcols: Vec<Abs>,
+ /// The sum of `rcols`.
+ width: Abs,
+ /// Resolve row sizes, by region.
+ rrows: Vec<Vec<RowPiece>>,
+ /// Rows in the current region.
+ lrows: Vec<Row>,
+ /// The initial size of the current region before we started subtracting.
+ initial: Size,
+ /// Frames for finished regions.
+ finished: Vec<Frame>,
+}
+
+/// The resulting sizes of columns and rows in a grid.
+#[derive(Debug)]
+pub struct GridLayout {
+ /// The fragment.
+ pub fragment: Fragment,
+ /// The column widths.
+ pub cols: Vec<Abs>,
+ /// The heights of the resulting rows segments, by region.
+ pub rows: Vec<Vec<RowPiece>>,
+}
+
+/// Details about a resulting row piece.
+#[derive(Debug)]
+pub struct RowPiece {
+ /// The height of the segment.
+ pub height: Abs,
+ /// The index of the row.
+ pub y: usize,
+}
+
+/// Produced by initial row layout, auto and relative rows are already finished,
+/// fractional rows not yet.
+enum Row {
+ /// Finished row frame of auto or relative row with y index.
+ Frame(Frame, usize),
+ /// Fractional row with y index.
+ Fr(Fr, usize),
+}
+
+impl<'a> GridLayouter<'a> {
+ /// Create a new grid layouter.
+ ///
+ /// This prepares grid layout by unifying content and gutter tracks.
+ pub fn new(
+ tracks: Axes<&[Sizing]>,
+ gutter: Axes<&[Sizing]>,
+ cells: &'a [Content],
+ regions: Regions<'a>,
+ styles: StyleChain<'a>,
+ ) -> Self {
+ let mut cols = vec![];
+ let mut rows = vec![];
+
+ // Number of content columns: Always at least one.
+ let c = tracks.x.len().max(1);
+
+ // Number of content rows: At least as many as given, but also at least
+ // as many as needed to place each item.
+ let r = {
+ let len = cells.len();
+ let given = tracks.y.len();
+ let needed = len / c + (len % c).clamp(0, 1);
+ given.max(needed)
+ };
+
+ let has_gutter = gutter.any(|tracks| !tracks.is_empty());
+ let auto = Sizing::Auto;
+ let zero = Sizing::Rel(Rel::zero());
+ let get_or = |tracks: &[_], idx, default| {
+ tracks.get(idx).or(tracks.last()).copied().unwrap_or(default)
+ };
+
+ // Collect content and gutter columns.
+ for x in 0..c {
+ cols.push(get_or(tracks.x, x, auto));
+ if has_gutter {
+ cols.push(get_or(gutter.x, x, zero));
+ }
+ }
+
+ // Collect content and gutter rows.
+ for y in 0..r {
+ rows.push(get_or(tracks.y, y, auto));
+ if has_gutter {
+ rows.push(get_or(gutter.y, y, zero));
+ }
+ }
+
+ // Remove superfluous gutter tracks.
+ if has_gutter {
+ cols.pop();
+ rows.pop();
+ }
+
+ // Reverse for RTL.
+ let is_rtl = TextElem::dir_in(styles) == Dir::RTL;
+ if is_rtl {
+ cols.reverse();
+ }
+
+ // We use these regions for auto row measurement. Since at that moment,
+ // columns are already sized, we can enable horizontal expansion.
+ let mut regions = regions;
+ regions.expand = Axes::new(true, false);
+
+ Self {
+ cells,
+ is_rtl,
+ has_gutter,
+ rows,
+ regions,
+ styles,
+ rcols: vec![Abs::zero(); cols.len()],
+ cols,
+ width: Abs::zero(),
+ rrows: vec![],
+ lrows: vec![],
+ initial: regions.size,
+ finished: vec![],
+ }
+ }
+
+ /// Determines the columns sizes and then layouts the grid row-by-row.
+ pub fn layout(mut self, vt: &mut Vt) -> SourceResult<GridLayout> {
+ self.measure_columns(vt)?;
+
+ for y in 0..self.rows.len() {
+ // Skip to next region if current one is full, but only for content
+ // rows, not for gutter rows.
+ if self.regions.is_full() && (!self.has_gutter || y % 2 == 0) {
+ self.finish_region(vt)?;
+ }
+
+ match self.rows[y] {
+ Sizing::Auto => self.layout_auto_row(vt, y)?,
+ Sizing::Rel(v) => self.layout_relative_row(vt, v, y)?,
+ Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y)),
+ }
+ }
+
+ self.finish_region(vt)?;
+
+ Ok(GridLayout {
+ fragment: Fragment::frames(self.finished),
+ cols: self.rcols,
+ rows: self.rrows,
+ })
+ }
+
+ /// Determine all column sizes.
+ #[tracing::instrument(name = "GridLayouter::measure_columns", skip_all)]
+ fn measure_columns(&mut self, vt: &mut Vt) -> SourceResult<()> {
+ // Sum of sizes of resolved relative tracks.
+ let mut rel = Abs::zero();
+
+ // Sum of fractions of all fractional tracks.
+ let mut fr = Fr::zero();
+
+ // Resolve the size of all relative columns and compute the sum of all
+ // fractional tracks.
+ for (&col, rcol) in self.cols.iter().zip(&mut self.rcols) {
+ match col {
+ Sizing::Auto => {}
+ Sizing::Rel(v) => {
+ let resolved =
+ v.resolve(self.styles).relative_to(self.regions.base().x);
+ *rcol = resolved;
+ rel += resolved;
+ }
+ Sizing::Fr(v) => fr += v,
+ }
+ }
+
+ // Size that is not used by fixed-size columns.
+ let available = self.regions.size.x - rel;
+ if available >= Abs::zero() {
+ // Determine size of auto columns.
+ let (auto, count) = self.measure_auto_columns(vt, available)?;
+
+ // If there is remaining space, distribute it to fractional columns,
+ // otherwise shrink auto columns.
+ let remaining = available - auto;
+ if remaining >= Abs::zero() {
+ self.grow_fractional_columns(remaining, fr);
+ } else {
+ self.shrink_auto_columns(available, count);
+ }
+ }
+
+ // Sum up the resolved column sizes once here.
+ self.width = self.rcols.iter().sum();
+
+ Ok(())
+ }
+
+ /// Measure the size that is available to auto columns.
+ fn measure_auto_columns(
+ &mut self,
+ vt: &mut Vt,
+ available: Abs,
+ ) -> SourceResult<(Abs, usize)> {
+ let mut auto = Abs::zero();
+ let mut count = 0;
+
+ // Determine size of auto columns by laying out all cells in those
+ // columns, measuring them and finding the largest one.
+ for (x, &col) in self.cols.iter().enumerate() {
+ if col != Sizing::Auto {
+ continue;
+ }
+
+ let mut resolved = Abs::zero();
+ for y in 0..self.rows.len() {
+ if let Some(cell) = self.cell(x, y) {
+ // For relative rows, we can already resolve the correct
+ // base and for auto and fr we could only guess anyway.
+ let height = match self.rows[y] {
+ Sizing::Rel(v) => {
+ v.resolve(self.styles).relative_to(self.regions.base().y)
+ }
+ _ => self.regions.base().y,
+ };
+
+ let size = Size::new(available, height);
+ let pod = Regions::one(size, Axes::splat(false));
+ let frame = cell.measure(vt, self.styles, pod)?.into_frame();
+ resolved.set_max(frame.width());
+ }
+ }
+
+ self.rcols[x] = resolved;
+ auto += resolved;
+ count += 1;
+ }
+
+ Ok((auto, count))
+ }
+
+ /// Distribute remaining space to fractional columns.
+ fn grow_fractional_columns(&mut self, remaining: Abs, fr: Fr) {
+ if fr.is_zero() {
+ return;
+ }
+
+ for (&col, rcol) in self.cols.iter().zip(&mut self.rcols) {
+ if let Sizing::Fr(v) = col {
+ *rcol = v.share(fr, remaining);
+ }
+ }
+ }
+
+ /// Redistribute space to auto columns so that each gets a fair share.
+ fn shrink_auto_columns(&mut self, available: Abs, count: usize) {
+ let mut last;
+ let mut fair = -Abs::inf();
+ let mut redistribute = available;
+ let mut overlarge = count;
+ let mut changed = true;
+
+ // Iteratively remove columns that don't need to be shrunk.
+ while changed && overlarge > 0 {
+ changed = false;
+ last = fair;
+ fair = redistribute / (overlarge as f64);
+
+ for (&col, &rcol) in self.cols.iter().zip(&self.rcols) {
+ // Remove an auto column if it is not overlarge (rcol <= fair),
+ // but also hasn't already been removed (rcol > last).
+ if col == Sizing::Auto && rcol <= fair && rcol > last {
+ redistribute -= rcol;
+ overlarge -= 1;
+ changed = true;
+ }
+ }
+ }
+
+ // Redistribute space fairly among overlarge columns.
+ for (&col, rcol) in self.cols.iter().zip(&mut self.rcols) {
+ if col == Sizing::Auto && *rcol > fair {
+ *rcol = fair;
+ }
+ }
+ }
+
+ /// Layout a row with automatic height. Such a row may break across multiple
+ /// regions.
+ fn layout_auto_row(&mut self, vt: &mut Vt, y: usize) -> SourceResult<()> {
+ // Determine the size for each region of the row. If the first region
+ // ends up empty for some column, skip the region and remeasure.
+ let mut resolved = match self.measure_auto_row(vt, y, true)? {
+ Some(resolved) => resolved,
+ None => {
+ self.finish_region(vt)?;
+ self.measure_auto_row(vt, y, false)?.unwrap()
+ }
+ };
+
+ // Nothing to layout.
+ if resolved.is_empty() {
+ return Ok(());
+ }
+
+ // Layout into a single region.
+ if let &[first] = resolved.as_slice() {
+ let frame = self.layout_single_row(vt, first, y)?;
+ self.push_row(frame, y);
+ return Ok(());
+ }
+
+ // Expand all but the last region.
+ // Skip the first region if the space is eaten up by an fr row.
+ let len = resolved.len();
+ for (region, target) in self
+ .regions
+ .iter()
+ .zip(&mut resolved[..len - 1])
+ .skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize)
+ {
+ target.set_max(region.y);
+ }
+
+ // Layout into multiple regions.
+ let fragment = self.layout_multi_row(vt, &resolved, y)?;
+ let len = fragment.len();
+ for (i, frame) in fragment.into_iter().enumerate() {
+ self.push_row(frame, y);
+ if i + 1 < len {
+ self.finish_region(vt)?;
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Measure the regions sizes of an auto row. The option is always `Some(_)`
+ /// if `can_skip` is false.
+ fn measure_auto_row(
+ &mut self,
+ vt: &mut Vt,
+ y: usize,
+ can_skip: bool,
+ ) -> SourceResult<Option<Vec<Abs>>> {
+ let mut resolved: Vec<Abs> = vec![];
+
+ for (x, &rcol) in self.rcols.iter().enumerate() {
+ if let Some(cell) = self.cell(x, y) {
+ let mut pod = self.regions;
+ pod.size.x = rcol;
+
+ let frames = cell.measure(vt, self.styles, pod)?.into_frames();
+
+ // Skip the first region if one cell in it is empty. Then,
+ // remeasure.
+ if let [first, rest @ ..] = frames.as_slice() {
+ if can_skip
+ && first.is_empty()
+ && rest.iter().any(|frame| !frame.is_empty())
+ {
+ return Ok(None);
+ }
+ }
+
+ let mut sizes = frames.iter().map(|frame| frame.height());
+ for (target, size) in resolved.iter_mut().zip(&mut sizes) {
+ target.set_max(size);
+ }
+
+ // New heights are maximal by virtue of being new. Note that
+ // this extend only uses the rest of the sizes iterator.
+ resolved.extend(sizes);
+ }
+ }
+
+ Ok(Some(resolved))
+ }
+
+ /// Layout a row with relative height. Such a row cannot break across
+ /// multiple regions, but it may force a region break.
+ fn layout_relative_row(
+ &mut self,
+ vt: &mut Vt,
+ v: Rel<Length>,
+ y: usize,
+ ) -> SourceResult<()> {
+ let resolved = v.resolve(self.styles).relative_to(self.regions.base().y);
+ let frame = self.layout_single_row(vt, resolved, y)?;
+
+ // Skip to fitting region.
+ let height = frame.height();
+ while !self.regions.size.y.fits(height) && !self.regions.in_last() {
+ self.finish_region(vt)?;
+
+ // Don't skip multiple regions for gutter and don't push a row.
+ if self.has_gutter && y % 2 == 1 {
+ return Ok(());
+ }
+ }
+
+ self.push_row(frame, y);
+
+ Ok(())
+ }
+
+ /// Layout a row with fixed height and return its frame.
+ fn layout_single_row(
+ &mut self,
+ vt: &mut Vt,
+ height: Abs,
+ y: usize,
+ ) -> SourceResult<Frame> {
+ let mut output = Frame::new(Size::new(self.width, height));
+ let mut pos = Point::zero();
+
+ for (x, &rcol) in self.rcols.iter().enumerate() {
+ if let Some(cell) = self.cell(x, y) {
+ let size = Size::new(rcol, height);
+ let mut pod = Regions::one(size, Axes::splat(true));
+ if self.rows[y] == Sizing::Auto {
+ pod.full = self.regions.full;
+ }
+ let frame = cell.layout(vt, self.styles, pod)?.into_frame();
+ output.push_frame(pos, frame);
+ }
+
+ pos.x += rcol;
+ }
+
+ Ok(output)
+ }
+
+ /// Layout a row spanning multiple regions.
+ fn layout_multi_row(
+ &mut self,
+ vt: &mut Vt,
+ heights: &[Abs],
+ y: usize,
+ ) -> SourceResult<Fragment> {
+ // Prepare frames.
+ let mut outputs: Vec<_> = heights
+ .iter()
+ .map(|&h| Frame::new(Size::new(self.width, h)))
+ .collect();
+
+ // Prepare regions.
+ let size = Size::new(self.width, heights[0]);
+ let mut pod = Regions::one(size, Axes::splat(true));
+ pod.full = self.regions.full;
+ pod.backlog = &heights[1..];
+
+ // Layout the row.
+ let mut pos = Point::zero();
+ for (x, &rcol) in self.rcols.iter().enumerate() {
+ if let Some(cell) = self.cell(x, y) {
+ pod.size.x = rcol;
+
+ // Push the layouted frames into the individual output frames.
+ let fragment = cell.layout(vt, self.styles, pod)?;
+ for (output, frame) in outputs.iter_mut().zip(fragment) {
+ output.push_frame(pos, frame);
+ }
+ }
+
+ pos.x += rcol;
+ }
+
+ Ok(Fragment::frames(outputs))
+ }
+
+ /// Push a row frame into the current region.
+ fn push_row(&mut self, frame: Frame, y: usize) {
+ self.regions.size.y -= frame.height();
+ self.lrows.push(Row::Frame(frame, y));
+ }
+
+ /// Finish rows for one region.
+ fn finish_region(&mut self, vt: &mut Vt) -> SourceResult<()> {
+ // Determine the height of existing rows in the region.
+ let mut used = Abs::zero();
+ let mut fr = Fr::zero();
+ for row in &self.lrows {
+ match row {
+ Row::Frame(frame, _) => used += frame.height(),
+ Row::Fr(v, _) => fr += *v,
+ }
+ }
+
+ // Determine the size of the grid in this region, expanding fully if
+ // there are fr rows.
+ let mut size = Size::new(self.width, used).min(self.initial);
+ if fr.get() > 0.0 && self.initial.y.is_finite() {
+ size.y = self.initial.y;
+ }
+
+ // The frame for the region.
+ let mut output = Frame::new(size);
+ let mut pos = Point::zero();
+ let mut rrows = vec![];
+
+ // Place finished rows and layout fractional rows.
+ for row in std::mem::take(&mut self.lrows) {
+ let (frame, y) = match row {
+ Row::Frame(frame, y) => (frame, y),
+ Row::Fr(v, y) => {
+ let remaining = self.regions.full - used;
+ let height = v.share(fr, remaining);
+ (self.layout_single_row(vt, height, y)?, y)
+ }
+ };
+
+ let height = frame.height();
+ output.push_frame(pos, frame);
+ rrows.push(RowPiece { height, y });
+ pos.y += height;
+ }
+
+ self.finished.push(output);
+ self.rrows.push(rrows);
+ self.regions.next();
+ self.initial = self.regions.size;
+
+ Ok(())
+ }
+
+ /// Get the content of the cell in column `x` and row `y`.
+ ///
+ /// Returns `None` if it's a gutter cell.
+ #[track_caller]
+ fn cell(&self, mut x: usize, y: usize) -> Option<&'a Content> {
+ assert!(x < self.cols.len());
+ assert!(y < self.rows.len());
+
+ // Columns are reorder, but the cell slice is not.
+ if self.is_rtl {
+ x = self.cols.len() - 1 - x;
+ }
+
+ if self.has_gutter {
+ // Even columns and rows are children, odd ones are gutter.
+ if x % 2 == 0 && y % 2 == 0 {
+ let c = 1 + self.cols.len() / 2;
+ self.cells.get((y / 2) * c + x / 2)
+ } else {
+ None
+ }
+ } else {
+ let c = self.cols.len();
+ self.cells.get(y * c + x)
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/hide.rs b/crates/typst-library/src/layout/hide.rs
new file mode 100644
index 00000000..c6e83e0c
--- /dev/null
+++ b/crates/typst-library/src/layout/hide.rs
@@ -0,0 +1,30 @@
+use crate::prelude::*;
+
+/// Hides content without affecting layout.
+///
+/// The `hide` function allows you to hide content while the layout still 'sees'
+/// it. This is useful to create whitespace that is exactly as large as some
+/// content. It may also be useful to redact content because its arguments are
+/// not included in the output.
+///
+/// ## Example { #example }
+/// ```example
+/// Hello Jane \
+/// #hide[Hello] Joe
+/// ```
+///
+/// Display: Hide
+/// Category: layout
+#[element(Show)]
+pub struct HideElem {
+ /// The content to hide.
+ #[required]
+ pub body: Content,
+}
+
+impl Show for HideElem {
+ #[tracing::instrument(name = "HideElem::show", skip(self))]
+ fn show(&self, _: &mut Vt, _: StyleChain) -> SourceResult<Content> {
+ Ok(self.body().styled(MetaElem::set_data(vec![Meta::Hide])))
+ }
+}
diff --git a/crates/typst-library/src/layout/list.rs b/crates/typst-library/src/layout/list.rs
new file mode 100644
index 00000000..e39ec3f5
--- /dev/null
+++ b/crates/typst-library/src/layout/list.rs
@@ -0,0 +1,239 @@
+use crate::layout::{BlockElem, ParElem, Sizing, Spacing};
+use crate::prelude::*;
+use crate::text::TextElem;
+
+use super::GridLayouter;
+
+/// A bullet list.
+///
+/// Displays a sequence of items vertically, with each item introduced by a
+/// marker.
+///
+/// ## Example { #example }
+/// ```example
+/// - *Content*
+/// - Text
+/// - Math
+/// - Layout
+/// - Visualize
+/// - Meta
+/// - Symbols
+///
+/// - *Compute*
+/// #list(
+/// [Foundations],
+/// [Calculate],
+/// [Construct],
+/// [Data Loading],
+/// )
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This functions also has dedicated syntax: Start a line with a hyphen,
+/// followed by a space to create a list item. A list item can contain multiple
+/// paragraphs and other block-level content. All content that is indented
+/// more than an item's hyphen becomes part of that item.
+///
+/// Display: Bullet List
+/// Category: layout
+#[element(Layout)]
+#[scope(
+ scope.define("item", ListItem::func());
+ scope
+)]
+pub struct ListElem {
+ /// If this is `{false}`, the items are spaced apart with [list
+ /// spacing]($func/list.spacing). If it is `{true}`, they use normal
+ /// [leading]($func/par.leading) instead. This makes the list more compact,
+ /// which can look better if the items are short.
+ ///
+ /// In markup mode, the value of this parameter is determined based on
+ /// whether items are separated with a blank line. If items directly follow
+ /// each other, this is set to `{true}`; if items are separated by a blank
+ /// line, this is set to `{false}`.
+ ///
+ /// ```example
+ /// - If a list has a lot of text, and
+ /// maybe other inline content, it
+ /// should not be tight anymore.
+ ///
+ /// - To make a list wide, simply insert
+ /// a blank line between the items.
+ /// ```
+ #[default(true)]
+ pub tight: bool,
+
+ /// The marker which introduces each item.
+ ///
+ /// Instead of plain content, you can also pass an array with multiple
+ /// markers that should be used for nested lists. If the list nesting depth
+ /// exceeds the number of markers, the last one is repeated. For total
+ /// control, you may pass a function that maps the list's nesting depth
+ /// (starting from `{0}`) to a desired marker.
+ ///
+ /// ```example
+ /// #set list(marker: [--])
+ /// - A more classic list
+ /// - With en-dashes
+ ///
+ /// #set list(marker: ([•], [--]))
+ /// - Top-level
+ /// - Nested
+ /// - Items
+ /// - Items
+ /// ```
+ #[default(ListMarker::Content(vec![TextElem::packed('•')]))]
+ pub marker: ListMarker,
+
+ /// The indent of each item.
+ #[resolve]
+ pub indent: Length,
+
+ /// The spacing between the marker and the body of each item.
+ #[resolve]
+ #[default(Em::new(0.5).into())]
+ pub body_indent: Length,
+
+ /// The spacing between the items of a wide (non-tight) list.
+ ///
+ /// If set to `{auto}`, uses the spacing [below blocks]($func/block.below).
+ pub spacing: Smart<Spacing>,
+
+ /// The bullet list's children.
+ ///
+ /// When using the list syntax, adjacent items are automatically collected
+ /// into lists, even through constructs like for loops.
+ ///
+ /// ```example
+ /// #for letter in "ABC" [
+ /// - Letter #letter
+ /// ]
+ /// ```
+ #[variadic]
+ pub children: Vec<ListItem>,
+
+ /// The nesting depth.
+ #[internal]
+ #[fold]
+ depth: Depth,
+}
+
+impl Layout for ListElem {
+ #[tracing::instrument(name = "ListElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let indent = self.indent(styles);
+ let body_indent = self.body_indent(styles);
+ let gutter = if self.tight(styles) {
+ ParElem::leading_in(styles).into()
+ } else {
+ self.spacing(styles)
+ .unwrap_or_else(|| BlockElem::below_in(styles).amount())
+ };
+
+ let depth = self.depth(styles);
+ let marker = self
+ .marker(styles)
+ .resolve(vt, depth)?
+ // avoid '#set align' interference with the list
+ .aligned(Align::LEFT_TOP.into());
+
+ let mut cells = vec![];
+ for item in self.children() {
+ cells.push(Content::empty());
+ cells.push(marker.clone());
+ cells.push(Content::empty());
+ cells.push(item.body().styled(Self::set_depth(Depth)));
+ }
+
+ let layouter = GridLayouter::new(
+ Axes::with_x(&[
+ Sizing::Rel(indent.into()),
+ Sizing::Auto,
+ Sizing::Rel(body_indent.into()),
+ Sizing::Auto,
+ ]),
+ Axes::with_y(&[gutter.into()]),
+ &cells,
+ regions,
+ styles,
+ );
+
+ Ok(layouter.layout(vt)?.fragment)
+ }
+}
+
+/// A bullet list item.
+///
+/// Display: Bullet List Item
+/// Category: layout
+#[element]
+pub struct ListItem {
+ /// The item's body.
+ #[required]
+ pub body: Content,
+}
+
+cast! {
+ ListItem,
+ v: Content => v.to::<Self>().cloned().unwrap_or_else(|| Self::new(v.clone())),
+}
+
+/// A list's marker.
+#[derive(Debug, Clone, Hash)]
+pub enum ListMarker {
+ Content(Vec<Content>),
+ Func(Func),
+}
+
+impl ListMarker {
+ /// Resolve the marker for the given depth.
+ fn resolve(&self, vt: &mut Vt, depth: usize) -> SourceResult<Content> {
+ Ok(match self {
+ Self::Content(list) => {
+ list.get(depth).or(list.last()).cloned().unwrap_or_default()
+ }
+ Self::Func(func) => func.call_vt(vt, [depth])?.display(),
+ })
+ }
+}
+
+cast! {
+ ListMarker,
+ self => match self {
+ Self::Content(vec) => if vec.len() == 1 {
+ vec.into_iter().next().unwrap().into_value()
+ } else {
+ vec.into_value()
+ },
+ Self::Func(func) => func.into_value(),
+ },
+ v: Content => Self::Content(vec![v]),
+ array: Array => {
+ if array.is_empty() {
+ bail!("array must contain at least one marker");
+ }
+ Self::Content(array.into_iter().map(Value::display).collect())
+ },
+ v: Func => Self::Func(v),
+}
+
+struct Depth;
+
+cast! {
+ Depth,
+ self => Value::None,
+ _: Value => Self,
+}
+
+impl Fold for Depth {
+ type Output = usize;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ outer + 1
+ }
+}
diff --git a/crates/typst-library/src/layout/measure.rs b/crates/typst-library/src/layout/measure.rs
new file mode 100644
index 00000000..eb8e509e
--- /dev/null
+++ b/crates/typst-library/src/layout/measure.rs
@@ -0,0 +1,56 @@
+use crate::prelude::*;
+
+/// Measures the layouted size of content.
+///
+/// The `measure` function lets you determine the layouted size of content.
+/// Note that an infinite space is assumed, therefore the measured height/width
+/// may not necessarily match the final height/width of the measured content.
+/// If you want to measure in the current layout dimensions, you can combined
+/// `measure` and [`layout`]($func/layout).
+///
+/// # Example { #example }
+/// The same content can have a different size depending on the styles that
+/// are active when it is layouted. For example, in the example below
+/// `[#content]` is of course bigger when we increase the font size.
+///
+/// ```example
+/// #let content = [Hello!]
+/// #content
+/// #set text(14pt)
+/// #content
+/// ```
+///
+/// To do a meaningful measurement, you therefore first need to retrieve the
+/// active styles with the [`style`]($func/style) function. You can then pass
+/// them to the `measure` function.
+///
+/// ```example
+/// #let thing(body) = style(styles => {
+/// let size = measure(body, styles)
+/// [Width of "#body" is #size.width]
+/// })
+///
+/// #thing[Hey] \
+/// #thing[Welcome]
+/// ```
+///
+/// The measure function returns a dictionary with the entries `width` and
+/// `height`, both of type [`length`]($type/length).
+///
+/// Display: Measure
+/// Category: layout
+#[func]
+pub fn measure(
+ /// The content whose size to measure.
+ content: Content,
+ /// The styles with which to layout the content.
+ styles: Styles,
+ /// The virtual machine.
+ vm: &mut Vm,
+) -> SourceResult<Dict> {
+ let pod = Regions::one(Axes::splat(Abs::inf()), Axes::splat(false));
+ let styles = StyleChain::new(&styles);
+ let frame = content.measure(&mut vm.vt, styles, pod)?.into_frame();
+ let Size { x, y } = frame.size();
+ Ok(dict! { "width" => x, "height" => y })
+}
diff --git a/crates/typst-library/src/layout/mod.rs b/crates/typst-library/src/layout/mod.rs
new file mode 100644
index 00000000..41490eb8
--- /dev/null
+++ b/crates/typst-library/src/layout/mod.rs
@@ -0,0 +1,709 @@
+//! Composable layouts.
+
+mod align;
+mod columns;
+mod container;
+#[path = "enum.rs"]
+mod enum_;
+mod flow;
+mod fragment;
+mod grid;
+mod hide;
+mod list;
+mod measure;
+mod pad;
+mod page;
+mod par;
+mod place;
+mod regions;
+mod repeat;
+mod spacing;
+mod stack;
+mod table;
+mod terms;
+mod transform;
+
+pub use self::align::*;
+pub use self::columns::*;
+pub use self::container::*;
+pub use self::enum_::*;
+pub use self::flow::*;
+pub use self::fragment::*;
+pub use self::grid::*;
+pub use self::hide::*;
+pub use self::list::*;
+pub use self::measure::*;
+pub use self::pad::*;
+pub use self::page::*;
+pub use self::par::*;
+pub use self::place::*;
+pub use self::regions::*;
+pub use self::repeat::*;
+pub use self::spacing::*;
+pub use self::stack::*;
+pub use self::table::*;
+pub use self::terms::*;
+pub use self::transform::*;
+
+use std::mem;
+
+use typed_arena::Arena;
+use typst::diag::SourceResult;
+use typst::eval::Tracer;
+use typst::model::DelayedErrors;
+use typst::model::{applicable, realize, StyleVecBuilder};
+
+use crate::math::{EquationElem, LayoutMath};
+use crate::meta::DocumentElem;
+use crate::prelude::*;
+use crate::shared::BehavedBuilder;
+use crate::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem};
+use crate::visualize::{
+ CircleElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, RectElem,
+ SquareElem,
+};
+
+/// Hook up all layout definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.define("page", PageElem::func());
+ global.define("pagebreak", PagebreakElem::func());
+ global.define("v", VElem::func());
+ global.define("par", ParElem::func());
+ global.define("parbreak", ParbreakElem::func());
+ global.define("h", HElem::func());
+ global.define("box", BoxElem::func());
+ global.define("block", BlockElem::func());
+ global.define("list", ListElem::func());
+ global.define("enum", EnumElem::func());
+ global.define("terms", TermsElem::func());
+ global.define("table", TableElem::func());
+ global.define("stack", StackElem::func());
+ global.define("grid", GridElem::func());
+ global.define("columns", ColumnsElem::func());
+ global.define("colbreak", ColbreakElem::func());
+ global.define("place", PlaceElem::func());
+ global.define("align", AlignElem::func());
+ global.define("pad", PadElem::func());
+ global.define("repeat", RepeatElem::func());
+ global.define("move", MoveElem::func());
+ global.define("scale", ScaleElem::func());
+ global.define("rotate", RotateElem::func());
+ global.define("hide", HideElem::func());
+ global.define("measure", measure_func());
+ global.define("ltr", Dir::LTR);
+ global.define("rtl", Dir::RTL);
+ global.define("ttb", Dir::TTB);
+ global.define("btt", Dir::BTT);
+ global.define("start", GenAlign::Start);
+ global.define("end", GenAlign::End);
+ global.define("left", GenAlign::Specific(Align::Left));
+ global.define("center", GenAlign::Specific(Align::Center));
+ global.define("right", GenAlign::Specific(Align::Right));
+ global.define("top", GenAlign::Specific(Align::Top));
+ global.define("horizon", GenAlign::Specific(Align::Horizon));
+ global.define("bottom", GenAlign::Specific(Align::Bottom));
+}
+
+/// Root-level layout.
+pub trait LayoutRoot {
+ /// Layout into one frame per page.
+ fn layout_root(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Document>;
+}
+
+impl LayoutRoot for Content {
+ #[tracing::instrument(name = "Content::layout_root", skip_all)]
+ fn layout_root(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult<Document> {
+ #[comemo::memoize]
+ fn cached(
+ content: &Content,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ locator: Tracked<Locator>,
+ delayed: TrackedMut<DelayedErrors>,
+ tracer: TrackedMut<Tracer>,
+ styles: StyleChain,
+ ) -> SourceResult<Document> {
+ let mut locator = Locator::chained(locator);
+ let mut vt = Vt {
+ world,
+ introspector,
+ locator: &mut locator,
+ delayed,
+ tracer,
+ };
+ let scratch = Scratch::default();
+ let (realized, styles) = realize_root(&mut vt, &scratch, content, styles)?;
+ realized
+ .with::<dyn LayoutRoot>()
+ .unwrap()
+ .layout_root(&mut vt, styles)
+ }
+
+ tracing::info!("Starting layout");
+ cached(
+ self,
+ vt.world,
+ vt.introspector,
+ vt.locator.track(),
+ TrackedMut::reborrow_mut(&mut vt.delayed),
+ TrackedMut::reborrow_mut(&mut vt.tracer),
+ styles,
+ )
+ }
+}
+
+/// Layout into regions.
+pub trait Layout {
+ /// Layout into one frame per region.
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment>;
+
+ /// Layout without side effects.
+ ///
+ /// This element must be layouted again in the same order for the results to
+ /// be valid.
+ #[tracing::instrument(name = "Layout::measure", skip_all)]
+ fn measure(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let mut locator = Locator::chained(vt.locator.track());
+ let mut vt = Vt {
+ world: vt.world,
+ introspector: vt.introspector,
+ locator: &mut locator,
+ tracer: TrackedMut::reborrow_mut(&mut vt.tracer),
+ delayed: TrackedMut::reborrow_mut(&mut vt.delayed),
+ };
+ self.layout(&mut vt, styles, regions)
+ }
+}
+
+impl Layout for Content {
+ #[tracing::instrument(name = "Content::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ #[allow(clippy::too_many_arguments)]
+ #[comemo::memoize]
+ fn cached(
+ content: &Content,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ locator: Tracked<Locator>,
+ delayed: TrackedMut<DelayedErrors>,
+ tracer: TrackedMut<Tracer>,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let mut locator = Locator::chained(locator);
+ let mut vt = Vt {
+ world,
+ introspector,
+ locator: &mut locator,
+ delayed,
+ tracer,
+ };
+ let scratch = Scratch::default();
+ let (realized, styles) = realize_block(&mut vt, &scratch, content, styles)?;
+ realized
+ .with::<dyn Layout>()
+ .unwrap()
+ .layout(&mut vt, styles, regions)
+ }
+
+ tracing::info!("Layouting `Content`");
+
+ let fragment = cached(
+ self,
+ vt.world,
+ vt.introspector,
+ vt.locator.track(),
+ TrackedMut::reborrow_mut(&mut vt.delayed),
+ TrackedMut::reborrow_mut(&mut vt.tracer),
+ styles,
+ regions,
+ )?;
+
+ vt.locator.visit_frames(&fragment);
+ Ok(fragment)
+ }
+}
+
+/// Realize into an element that is capable of root-level layout.
+#[tracing::instrument(skip_all)]
+fn realize_root<'a>(
+ vt: &mut Vt,
+ scratch: &'a Scratch<'a>,
+ content: &'a Content,
+ styles: StyleChain<'a>,
+) -> SourceResult<(Content, StyleChain<'a>)> {
+ if content.can::<dyn LayoutRoot>() && !applicable(content, styles) {
+ return Ok((content.clone(), styles));
+ }
+
+ let mut builder = Builder::new(vt, scratch, true);
+ builder.accept(content, styles)?;
+ builder.interrupt_page(Some(styles))?;
+ let (pages, shared) = builder.doc.unwrap().pages.finish();
+ Ok((DocumentElem::new(pages.to_vec()).pack(), shared))
+}
+
+/// Realize into an element that is capable of block-level layout.
+#[tracing::instrument(skip_all)]
+fn realize_block<'a>(
+ vt: &mut Vt,
+ scratch: &'a Scratch<'a>,
+ content: &'a Content,
+ styles: StyleChain<'a>,
+) -> SourceResult<(Content, StyleChain<'a>)> {
+ if content.can::<dyn Layout>()
+ && !content.is::<LineElem>()
+ && !content.is::<RectElem>()
+ && !content.is::<SquareElem>()
+ && !content.is::<EllipseElem>()
+ && !content.is::<CircleElem>()
+ && !content.is::<ImageElem>()
+ && !content.is::<PolygonElem>()
+ && !content.is::<PathElem>()
+ && !applicable(content, styles)
+ {
+ return Ok((content.clone(), styles));
+ }
+
+ let mut builder = Builder::new(vt, scratch, false);
+ builder.accept(content, styles)?;
+ builder.interrupt_par()?;
+ let (children, shared) = builder.flow.0.finish();
+ Ok((FlowElem::new(children.to_vec()).pack(), shared))
+}
+
+/// Builds a document or a flow element from content.
+struct Builder<'a, 'v, 't> {
+ /// The virtual typesetter.
+ vt: &'v mut Vt<'t>,
+ /// Scratch arenas for building.
+ scratch: &'a Scratch<'a>,
+ /// The current document building state.
+ doc: Option<DocBuilder<'a>>,
+ /// The current flow building state.
+ flow: FlowBuilder<'a>,
+ /// The current paragraph building state.
+ par: ParBuilder<'a>,
+ /// The current list building state.
+ list: ListBuilder<'a>,
+}
+
+/// Temporary storage arenas for building.
+#[derive(Default)]
+struct Scratch<'a> {
+ /// An arena where intermediate style chains are stored.
+ styles: Arena<StyleChain<'a>>,
+ /// An arena where intermediate content resulting from show rules is stored.
+ content: Arena<Content>,
+}
+
+impl<'a, 'v, 't> Builder<'a, 'v, 't> {
+ fn new(vt: &'v mut Vt<'t>, scratch: &'a Scratch<'a>, top: bool) -> Self {
+ Self {
+ vt,
+ scratch,
+ doc: top.then(DocBuilder::default),
+ flow: FlowBuilder::default(),
+ par: ParBuilder::default(),
+ list: ListBuilder::default(),
+ }
+ }
+
+ fn accept(
+ &mut self,
+ mut content: &'a Content,
+ styles: StyleChain<'a>,
+ ) -> SourceResult<()> {
+ if content.can::<dyn LayoutMath>() && !content.is::<EquationElem>() {
+ content =
+ self.scratch.content.alloc(EquationElem::new(content.clone()).pack());
+ }
+
+ if let Some(realized) = realize(self.vt, content, styles)? {
+ let stored = self.scratch.content.alloc(realized);
+ return self.accept(stored, styles);
+ }
+
+ if let Some((elem, local)) = content.to_styled() {
+ return self.styled(elem, local, styles);
+ }
+
+ if let Some(children) = content.to_sequence() {
+ for elem in children {
+ self.accept(elem, styles)?;
+ }
+ return Ok(());
+ }
+
+ if self.list.accept(content, styles) {
+ return Ok(());
+ }
+
+ self.interrupt_list()?;
+
+ if self.list.accept(content, styles) {
+ return Ok(());
+ }
+
+ if self.par.accept(content, styles) {
+ return Ok(());
+ }
+
+ self.interrupt_par()?;
+
+ if self.flow.accept(content, styles) {
+ return Ok(());
+ }
+
+ let keep = content
+ .to::<PagebreakElem>()
+ .map_or(false, |pagebreak| !pagebreak.weak(styles));
+
+ self.interrupt_page(keep.then_some(styles))?;
+
+ if let Some(doc) = &mut self.doc {
+ if doc.accept(content, styles) {
+ return Ok(());
+ }
+ }
+
+ if content.is::<PagebreakElem>() {
+ bail!(content.span(), "pagebreaks are not allowed inside of containers");
+ } else {
+ bail!(content.span(), "{} is not allowed here", content.func().name());
+ }
+ }
+
+ fn styled(
+ &mut self,
+ elem: &'a Content,
+ map: &'a Styles,
+ styles: StyleChain<'a>,
+ ) -> SourceResult<()> {
+ let stored = self.scratch.styles.alloc(styles);
+ let styles = stored.chain(map);
+ self.interrupt_style(map, None)?;
+ self.accept(elem, styles)?;
+ self.interrupt_style(map, Some(styles))?;
+ Ok(())
+ }
+
+ fn interrupt_style(
+ &mut self,
+ local: &Styles,
+ outer: Option<StyleChain<'a>>,
+ ) -> SourceResult<()> {
+ if let Some(Some(span)) = local.interruption::<DocumentElem>() {
+ if self.doc.is_none() {
+ bail!(span, "document set rules are not allowed inside of containers");
+ }
+ if outer.is_none()
+ && (!self.flow.0.is_empty()
+ || !self.par.0.is_empty()
+ || !self.list.items.is_empty())
+ {
+ bail!(span, "document set rules must appear before any content");
+ }
+ } else if let Some(Some(span)) = local.interruption::<PageElem>() {
+ if self.doc.is_none() {
+ bail!(span, "page configuration is not allowed inside of containers");
+ }
+ self.interrupt_page(outer)?;
+ } else if local.interruption::<ParElem>().is_some()
+ || local.interruption::<AlignElem>().is_some()
+ {
+ self.interrupt_par()?;
+ } else if local.interruption::<ListElem>().is_some()
+ || local.interruption::<EnumElem>().is_some()
+ || local.interruption::<TermsElem>().is_some()
+ {
+ self.interrupt_list()?;
+ }
+ Ok(())
+ }
+
+ fn interrupt_list(&mut self) -> SourceResult<()> {
+ if !self.list.items.is_empty() {
+ let staged = mem::take(&mut self.list.staged);
+ let (list, styles) = mem::take(&mut self.list).finish();
+ let stored = self.scratch.content.alloc(list);
+ self.accept(stored, styles)?;
+ for (content, styles) in staged {
+ self.accept(content, styles)?;
+ }
+ }
+ Ok(())
+ }
+
+ fn interrupt_par(&mut self) -> SourceResult<()> {
+ self.interrupt_list()?;
+ if !self.par.0.is_empty() {
+ let (par, styles) = mem::take(&mut self.par).finish();
+ let stored = self.scratch.content.alloc(par);
+ self.accept(stored, styles)?;
+ }
+
+ Ok(())
+ }
+
+ fn interrupt_page(&mut self, styles: Option<StyleChain<'a>>) -> SourceResult<()> {
+ self.interrupt_par()?;
+ let Some(doc) = &mut self.doc else { return Ok(()) };
+ if !self.flow.0.is_empty() || (doc.keep_next && styles.is_some()) {
+ let (flow, shared) = mem::take(&mut self.flow).0.finish();
+ let styles = if shared == StyleChain::default() {
+ styles.unwrap_or_default()
+ } else {
+ shared
+ };
+ let page = PageElem::new(FlowElem::new(flow.to_vec()).pack());
+ let stored = self.scratch.content.alloc(page.pack());
+ self.accept(stored, styles)?;
+ }
+ Ok(())
+ }
+}
+
+/// Accepts pagebreaks and pages.
+struct DocBuilder<'a> {
+ /// The page runs built so far.
+ pages: StyleVecBuilder<'a, Content>,
+ /// Whether to keep a following page even if it is empty.
+ keep_next: bool,
+ /// Whether the next page should be cleared to an even or odd number.
+ clear_next: Option<Parity>,
+}
+
+impl<'a> DocBuilder<'a> {
+ fn accept(&mut self, content: &Content, styles: StyleChain<'a>) -> bool {
+ if let Some(pagebreak) = content.to::<PagebreakElem>() {
+ self.keep_next = !pagebreak.weak(styles);
+ self.clear_next = pagebreak.to(styles);
+ return true;
+ }
+
+ if let Some(page) = content.to::<PageElem>() {
+ let elem = if let Some(clear_to) = self.clear_next.take() {
+ let mut page = page.clone();
+ page.push_clear_to(Some(clear_to));
+ page.pack()
+ } else {
+ content.clone()
+ };
+
+ self.pages.push(elem, styles);
+ self.keep_next = false;
+ return true;
+ }
+
+ false
+ }
+}
+
+impl Default for DocBuilder<'_> {
+ fn default() -> Self {
+ Self {
+ pages: StyleVecBuilder::new(),
+ keep_next: true,
+ clear_next: None,
+ }
+ }
+}
+
+/// Accepts flow content.
+#[derive(Default)]
+struct FlowBuilder<'a>(BehavedBuilder<'a>, bool);
+
+impl<'a> FlowBuilder<'a> {
+ fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> bool {
+ if content.is::<ParbreakElem>() {
+ self.1 = true;
+ return true;
+ }
+
+ let last_was_parbreak = self.1;
+ self.1 = false;
+
+ if content.is::<VElem>()
+ || content.is::<ColbreakElem>()
+ || content.is::<MetaElem>()
+ {
+ self.0.push(content.clone(), styles);
+ return true;
+ }
+
+ if content.can::<dyn Layout>() || content.is::<ParElem>() {
+ let is_tight_list = if let Some(elem) = content.to::<ListElem>() {
+ elem.tight(styles)
+ } else if let Some(elem) = content.to::<EnumElem>() {
+ elem.tight(styles)
+ } else if let Some(elem) = content.to::<TermsElem>() {
+ elem.tight(styles)
+ } else {
+ false
+ };
+
+ if !last_was_parbreak && is_tight_list {
+ let leading = ParElem::leading_in(styles);
+ let spacing = VElem::list_attach(leading.into());
+ self.0.push(spacing.pack(), styles);
+ }
+
+ let (above, below) = if let Some(block) = content.to::<BlockElem>() {
+ (block.above(styles), block.below(styles))
+ } else {
+ (BlockElem::above_in(styles), BlockElem::below_in(styles))
+ };
+
+ self.0.push(above.pack(), styles);
+ self.0.push(content.clone(), styles);
+ self.0.push(below.pack(), styles);
+ return true;
+ }
+
+ false
+ }
+}
+
+/// Accepts paragraph content.
+#[derive(Default)]
+struct ParBuilder<'a>(BehavedBuilder<'a>);
+
+impl<'a> ParBuilder<'a> {
+ fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> bool {
+ if content.is::<MetaElem>() {
+ if !self.0.is_basically_empty() {
+ self.0.push(content.clone(), styles);
+ return true;
+ }
+ } else if content.is::<SpaceElem>()
+ || content.is::<TextElem>()
+ || content.is::<HElem>()
+ || content.is::<LinebreakElem>()
+ || content.is::<SmartQuoteElem>()
+ || content.to::<EquationElem>().map_or(false, |elem| !elem.block(styles))
+ || content.is::<BoxElem>()
+ {
+ self.0.push(content.clone(), styles);
+ return true;
+ }
+
+ false
+ }
+
+ fn finish(self) -> (Content, StyleChain<'a>) {
+ let (children, shared) = self.0.finish();
+ (ParElem::new(children.to_vec()).pack(), shared)
+ }
+}
+
+/// Accepts list / enum items, spaces, paragraph breaks.
+struct ListBuilder<'a> {
+ /// The list items collected so far.
+ items: StyleVecBuilder<'a, Content>,
+ /// Whether the list contains no paragraph breaks.
+ tight: bool,
+ /// Trailing content for which it is unclear whether it is part of the list.
+ staged: Vec<(&'a Content, StyleChain<'a>)>,
+}
+
+impl<'a> ListBuilder<'a> {
+ fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> bool {
+ if !self.items.is_empty()
+ && (content.is::<SpaceElem>() || content.is::<ParbreakElem>())
+ {
+ self.staged.push((content, styles));
+ return true;
+ }
+
+ if (content.is::<ListItem>()
+ || content.is::<EnumItem>()
+ || content.is::<TermItem>())
+ && self
+ .items
+ .elems()
+ .next()
+ .map_or(true, |first| first.func() == content.func())
+ {
+ self.items.push(content.clone(), styles);
+ self.tight &= self.staged.drain(..).all(|(t, _)| !t.is::<ParbreakElem>());
+ return true;
+ }
+
+ false
+ }
+
+ fn finish(self) -> (Content, StyleChain<'a>) {
+ let (items, shared) = self.items.finish();
+ let item = items.items().next().unwrap();
+ let output = if item.is::<ListItem>() {
+ ListElem::new(
+ items
+ .iter()
+ .map(|(item, local)| {
+ let item = item.to::<ListItem>().unwrap();
+ item.clone().with_body(item.body().styled_with_map(local.clone()))
+ })
+ .collect::<Vec<_>>(),
+ )
+ .with_tight(self.tight)
+ .pack()
+ } else if item.is::<EnumItem>() {
+ EnumElem::new(
+ items
+ .iter()
+ .map(|(item, local)| {
+ let item = item.to::<EnumItem>().unwrap();
+ item.clone().with_body(item.body().styled_with_map(local.clone()))
+ })
+ .collect::<Vec<_>>(),
+ )
+ .with_tight(self.tight)
+ .pack()
+ } else if item.is::<TermItem>() {
+ TermsElem::new(
+ items
+ .iter()
+ .map(|(item, local)| {
+ let item = item.to::<TermItem>().unwrap();
+ item.clone()
+ .with_term(item.term().styled_with_map(local.clone()))
+ .with_description(
+ item.description().styled_with_map(local.clone()),
+ )
+ })
+ .collect::<Vec<_>>(),
+ )
+ .with_tight(self.tight)
+ .pack()
+ } else {
+ unreachable!()
+ };
+ (output, shared)
+ }
+}
+
+impl Default for ListBuilder<'_> {
+ fn default() -> Self {
+ Self {
+ items: StyleVecBuilder::default(),
+ tight: true,
+ staged: vec![],
+ }
+ }
+}
diff --git a/crates/typst-library/src/layout/pad.rs b/crates/typst-library/src/layout/pad.rs
new file mode 100644
index 00000000..a3d5646b
--- /dev/null
+++ b/crates/typst-library/src/layout/pad.rs
@@ -0,0 +1,125 @@
+use crate::prelude::*;
+
+/// Adds spacing around content.
+///
+/// The spacing can be specified for each side individually, or for all sides at
+/// once by specifying a positional argument.
+///
+/// ## Example { #example }
+/// ```example
+/// #set align(center)
+///
+/// #pad(x: 16pt, image("typing.jpg"))
+/// _Typing speeds can be
+/// measured in words per minute._
+/// ```
+///
+/// Display: Padding
+/// Category: layout
+#[element(Layout)]
+pub struct PadElem {
+ /// The padding at the left side.
+ #[parse(
+ let all = args.named("rest")?.or(args.find()?);
+ let x = args.named("x")?.or(all);
+ let y = args.named("y")?.or(all);
+ args.named("left")?.or(x)
+ )]
+ pub left: Rel<Length>,
+
+ /// The padding at the top side.
+ #[parse(args.named("top")?.or(y))]
+ pub top: Rel<Length>,
+
+ /// The padding at the right side.
+ #[parse(args.named("right")?.or(x))]
+ pub right: Rel<Length>,
+
+ /// The padding at the bottom side.
+ #[parse(args.named("bottom")?.or(y))]
+ pub bottom: Rel<Length>,
+
+ /// The horizontal padding. Both `left` and `right` take precedence over
+ /// this.
+ #[external]
+ pub x: Rel<Length>,
+
+ /// The vertical padding. Both `top` and `bottom` take precedence over this.
+ #[external]
+ pub y: Rel<Length>,
+
+ /// The padding for all sides. All other parameters take precedence over
+ /// this.
+ #[external]
+ pub rest: Rel<Length>,
+
+ /// The content to pad at the sides.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for PadElem {
+ #[tracing::instrument(name = "PadElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let sides = Sides::new(
+ self.left(styles),
+ self.top(styles),
+ self.right(styles),
+ self.bottom(styles),
+ );
+
+ // Layout child into padded regions.
+ let mut backlog = vec![];
+ let padding = sides.resolve(styles);
+ let pod = regions.map(&mut backlog, |size| shrink(size, padding));
+ let mut fragment = self.body().layout(vt, styles, pod)?;
+
+ for frame in &mut fragment {
+ // Apply the padding inversely such that the grown size padded
+ // yields the frame's size.
+ let padded = grow(frame.size(), padding);
+ let padding = padding.relative_to(padded);
+ let offset = Point::new(padding.left, padding.top);
+
+ // Grow the frame and translate everything in the frame inwards.
+ frame.set_size(padded);
+ frame.translate(offset);
+ }
+
+ Ok(fragment)
+ }
+}
+
+/// Shrink a size by padding relative to the size itself.
+fn shrink(size: Size, padding: Sides<Rel<Abs>>) -> Size {
+ size - padding.relative_to(size).sum_by_axis()
+}
+
+/// Grow a size by padding relative to the grown size.
+/// This is the inverse operation to `shrink()`.
+///
+/// For the horizontal axis the derivation looks as follows.
+/// (Vertical axis is analogous.)
+///
+/// Let w be the grown target width,
+/// s be the given width,
+/// l be the left padding,
+/// r be the right padding,
+/// p = l + r.
+///
+/// We want that: w - l.resolve(w) - r.resolve(w) = s
+///
+/// Thus: w - l.resolve(w) - r.resolve(w) = s
+/// <=> w - p.resolve(w) = s
+/// <=> w - p.rel * w - p.abs = s
+/// <=> (1 - p.rel) * w = s + p.abs
+/// <=> w = (s + p.abs) / (1 - p.rel)
+fn grow(size: Size, padding: Sides<Rel<Abs>>) -> Size {
+ size.zip(padding.sum_by_axis())
+ .map(|(s, p)| (s + p.abs).safe_div(1.0 - p.rel.get()))
+}
diff --git a/crates/typst-library/src/layout/page.rs b/crates/typst-library/src/layout/page.rs
new file mode 100644
index 00000000..3b81f92c
--- /dev/null
+++ b/crates/typst-library/src/layout/page.rs
@@ -0,0 +1,898 @@
+use std::ptr;
+use std::str::FromStr;
+
+use super::{AlignElem, ColumnsElem};
+use crate::meta::{Counter, CounterKey, Numbering};
+use crate::prelude::*;
+use crate::text::TextElem;
+
+/// Layouts its child onto one or multiple pages.
+///
+/// Although this function is primarily used in set rules to affect page
+/// properties, it can also be used to explicitly render its argument onto
+/// a set of pages of its own.
+///
+/// Pages can be set to use `{auto}` as their width or height. In this case,
+/// the pages will grow to fit their content on the respective axis.
+///
+/// ## Example { #example }
+/// ```example
+/// >>> #set page(margin: auto)
+/// #set page("us-letter")
+///
+/// There you go, US friends!
+/// ```
+///
+/// Display: Page
+/// Category: layout
+#[element]
+pub struct PageElem {
+ /// A standard paper size to set width and height.
+ #[external]
+ #[default(Paper::A4)]
+ pub paper: Paper,
+
+ /// The width of the page.
+ ///
+ /// ```example
+ /// #set page(
+ /// width: 3cm,
+ /// margin: (x: 0cm),
+ /// )
+ ///
+ /// #for i in range(3) {
+ /// box(square(width: 1cm))
+ /// }
+ /// ```
+ #[resolve]
+ #[parse(
+ let paper = args.named_or_find::<Paper>("paper")?;
+ args.named("width")?
+ .or_else(|| paper.map(|paper| Smart::Custom(paper.width().into())))
+ )]
+ #[default(Smart::Custom(Paper::A4.width().into()))]
+ pub width: Smart<Length>,
+
+ /// The height of the page.
+ ///
+ /// If this is set to `{auto}`, page breaks can only be triggered manually
+ /// by inserting a [page break]($func/pagebreak). Most examples throughout
+ /// this documentation use `{auto}` for the height of the page to
+ /// dynamically grow and shrink to fit their content.
+ #[resolve]
+ #[parse(
+ args.named("height")?
+ .or_else(|| paper.map(|paper| Smart::Custom(paper.height().into())))
+ )]
+ #[default(Smart::Custom(Paper::A4.height().into()))]
+ pub height: Smart<Length>,
+
+ /// Whether the page is flipped into landscape orientation.
+ ///
+ /// ```example
+ /// #set page(
+ /// "us-business-card",
+ /// flipped: true,
+ /// fill: rgb("f2e5dd"),
+ /// )
+ ///
+ /// #set align(bottom + end)
+ /// #text(14pt)[*Sam H. Richards*] \
+ /// _Procurement Manager_
+ ///
+ /// #set text(10pt)
+ /// 17 Main Street \
+ /// New York, NY 10001 \
+ /// +1 555 555 5555
+ /// ```
+ #[default(false)]
+ pub flipped: bool,
+
+ /// The page's margins.
+ ///
+ /// - A single length: The same margin on all sides.
+ /// - `{auto}`: The margin is set to the default value for the page's size.
+ /// - A dictionary: With a dictionary, the margins can be set individually.
+ /// The dictionary can contain the following keys in order of precedence:
+ /// - `top`: The top margin.
+ /// - `right`: The right margin.
+ /// - `bottom`: The bottom margin.
+ /// - `left`: The left margin.
+ /// - `inside`: The margin at the inner side of the page (where the
+ /// [binding]($func/page.binding) is).
+ /// - `outside`: The margin at the outer side of the page (opposite to the
+ /// [binding]($func/page.binding)).
+ /// - `x`: The horizontal margins.
+ /// - `y`: The vertical margins.
+ /// - `rest`: The margins on all sides except those for which the
+ /// dictionary explicitly sets a size.
+ ///
+ /// The values for `left` and `right` are mutually exclusive with
+ /// the values for `inside` and `outside`.
+ ///
+ /// ```example
+ /// #set page(
+ /// width: 3cm,
+ /// height: 4cm,
+ /// margin: (x: 8pt, y: 4pt),
+ /// )
+ ///
+ /// #rect(
+ /// width: 100%,
+ /// height: 100%,
+ /// fill: aqua,
+ /// )
+ /// ```
+ #[fold]
+ pub margin: Margin,
+
+ /// On which side the pages will be bound.
+ ///
+ /// - `{auto}`: Equivalent to `left` if the [text direction]($func/text.dir)
+ /// is left-to-right and `right` if it is right-to-left.
+ /// - `left`: Bound on the left side.
+ /// - `right`: Bound on the right side.
+ ///
+ /// This affects the meaning of the `inside` and `outside` options for
+ /// margins.
+ pub binding: Smart<Binding>,
+
+ /// How many columns the page has.
+ ///
+ /// ```example:single
+ /// #set page(columns: 2, height: 4.8cm)
+ /// Climate change is one of the most
+ /// pressing issues of our time, with
+ /// the potential to devastate
+ /// communities, ecosystems, and
+ /// economies around the world. It's
+ /// clear that we need to take urgent
+ /// action to reduce our carbon
+ /// emissions and mitigate the impacts
+ /// of a rapidly changing climate.
+ /// ```
+ #[default(NonZeroUsize::ONE)]
+ pub columns: NonZeroUsize,
+
+ /// The page's background color.
+ ///
+ /// This instructs the printer to color the complete page with the given
+ /// color. If you are considering larger production runs, it may be more
+ /// environmentally friendly and cost-effective to source pre-dyed pages and
+ /// not set this property.
+ ///
+ /// ```example
+ /// #set page(fill: rgb("444352"))
+ /// #set text(fill: rgb("fdfdfd"))
+ /// *Dark mode enabled.*
+ /// ```
+ pub fill: Option<Paint>,
+
+ /// How to [number]($func/numbering) the pages.
+ ///
+ /// If an explicit `footer` is given, the numbering is ignored.
+ ///
+ /// ```example
+ /// #set page(
+ /// height: 100pt,
+ /// margin: (top: 16pt, bottom: 24pt),
+ /// numbering: "1 / 1",
+ /// )
+ ///
+ /// #lorem(48)
+ /// ```
+ pub numbering: Option<Numbering>,
+
+ /// The alignment of the page numbering.
+ ///
+ /// ```example
+ /// #set page(
+ /// margin: (top: 16pt, bottom: 24pt),
+ /// numbering: "1",
+ /// number-align: right,
+ /// )
+ ///
+ /// #lorem(30)
+ /// ```
+ #[default(Align::Center.into())]
+ pub number_align: Axes<Option<GenAlign>>,
+
+ /// The page's header. Fills the top margin of each page.
+ ///
+ /// ```example
+ /// #set par(justify: true)
+ /// #set page(
+ /// margin: (top: 32pt, bottom: 20pt),
+ /// header: [
+ /// #set text(8pt)
+ /// #smallcaps[Typst Academcy]
+ /// #h(1fr) _Exercise Sheet 3_
+ /// ],
+ /// )
+ ///
+ /// #lorem(19)
+ /// ```
+ pub header: Option<Content>,
+
+ /// The amount the header is raised into the top margin.
+ #[resolve]
+ #[default(Ratio::new(0.3).into())]
+ pub header_ascent: Rel<Length>,
+
+ /// The page's footer. Fills the bottom margin of each page.
+ ///
+ /// For just a page number, the `numbering` property, typically suffices. If
+ /// you want to create a custom footer, but still display the page number,
+ /// you can directly access the [page counter]($func/counter).
+ ///
+ /// ```example
+ /// #set par(justify: true)
+ /// #set page(
+ /// height: 100pt,
+ /// margin: 20pt,
+ /// footer: [
+ /// #set align(right)
+ /// #set text(8pt)
+ /// #counter(page).display(
+ /// "1 of I",
+ /// both: true,
+ /// )
+ /// ]
+ /// )
+ ///
+ /// #lorem(48)
+ /// ```
+ pub footer: Option<Content>,
+
+ /// The amount the footer is lowered into the bottom margin.
+ #[resolve]
+ #[default(Ratio::new(0.3).into())]
+ pub footer_descent: Rel<Length>,
+
+ /// Content in the page's background.
+ ///
+ /// This content will be placed behind the page's body. It can be
+ /// used to place a background image or a watermark.
+ ///
+ /// ```example
+ /// #set page(background: rotate(24deg,
+ /// text(18pt, fill: rgb("FFCBC4"))[
+ /// *CONFIDENTIAL*
+ /// ]
+ /// ))
+ ///
+ /// = Typst's secret plans
+ /// In the year 2023, we plan to take
+ /// over the world (of typesetting).
+ /// ```
+ pub background: Option<Content>,
+
+ /// Content in the page's foreground.
+ ///
+ /// This content will overlay the page's body.
+ ///
+ /// ```example
+ /// #set page(foreground: text(24pt)[🥸])
+ ///
+ /// Reviewer 2 has marked our paper
+ /// "Weak Reject" because they did
+ /// not understand our approach...
+ /// ```
+ pub foreground: Option<Content>,
+
+ /// The contents of the page(s).
+ ///
+ /// Multiple pages will be created if the content does not fit on a single
+ /// page. A new page with the page properties prior to the function invocation
+ /// will be created after the body has been typeset.
+ #[required]
+ pub body: Content,
+
+ /// Whether the page should be aligned to an even or odd page.
+ /// Not part of the public API for now.
+ #[internal]
+ pub clear_to: Option<Parity>,
+}
+
+impl PageElem {
+ /// A document can consist of multiple `PageElem`s, one per run of pages
+ /// with equal properties (not one per actual output page!). The `number` is
+ /// the physical page number of the first page of this run. It is mutated
+ /// while we post-process the pages in this function. This function returns
+ /// a fragment consisting of multiple frames, one per output page of this
+ /// page run.
+ #[tracing::instrument(skip_all)]
+ pub fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ mut number: NonZeroUsize,
+ ) -> SourceResult<Fragment> {
+ tracing::info!("Page layout");
+
+ // When one of the lengths is infinite the page fits its content along
+ // that axis.
+ let width = self.width(styles).unwrap_or(Abs::inf());
+ let height = self.height(styles).unwrap_or(Abs::inf());
+ let mut size = Size::new(width, height);
+ if self.flipped(styles) {
+ std::mem::swap(&mut size.x, &mut size.y);
+ }
+
+ let mut min = width.min(height);
+ if !min.is_finite() {
+ min = Paper::A4.width();
+ }
+
+ // Determine the margins.
+ let default = Rel::<Length>::from(0.1190 * min);
+ let margin = self.margin(styles);
+ let two_sided = margin.two_sided.unwrap_or(false);
+ let margin = margin
+ .sides
+ .map(|side| side.and_then(Smart::as_custom).unwrap_or(default))
+ .resolve(styles)
+ .relative_to(size);
+
+ // Determine the binding.
+ let binding =
+ self.binding(styles)
+ .unwrap_or_else(|| match TextElem::dir_in(styles) {
+ Dir::LTR => Binding::Left,
+ _ => Binding::Right,
+ });
+
+ // Realize columns.
+ let mut child = self.body();
+ let columns = self.columns(styles);
+ if columns.get() > 1 {
+ child = ColumnsElem::new(child).with_count(columns).pack();
+ }
+
+ let area = size - margin.sum_by_axis();
+ let mut regions = Regions::repeat(area, area.map(Abs::is_finite));
+ regions.root = true;
+
+ // Layout the child.
+ let mut frames = child.layout(vt, styles, regions)?.into_frames();
+
+ // Align the child to the pagebreak's parity.
+ if self.clear_to(styles).is_some_and(|p| !p.matches(number.get())) {
+ let size = area.map(Abs::is_finite).select(area, Size::zero());
+ frames.insert(0, Frame::new(size));
+ }
+
+ let fill = self.fill(styles);
+ let foreground = self.foreground(styles);
+ let background = self.background(styles);
+ let header = self.header(styles);
+ let header_ascent = self.header_ascent(styles);
+ let footer = self.footer(styles).or_else(|| {
+ self.numbering(styles).map(|numbering| {
+ let both = match &numbering {
+ Numbering::Pattern(pattern) => pattern.pieces() >= 2,
+ Numbering::Func(_) => true,
+ };
+ Counter::new(CounterKey::Page)
+ .display(Some(numbering), both)
+ .aligned(self.number_align(styles))
+ })
+ });
+ let footer_descent = self.footer_descent(styles);
+
+ let numbering_meta = FrameItem::Meta(
+ Meta::PageNumbering(self.numbering(styles).into_value()),
+ Size::zero(),
+ );
+
+ // Post-process pages.
+ for frame in frames.iter_mut() {
+ tracing::info!("Layouting page #{number}");
+
+ // The padded width of the page's content without margins.
+ let pw = frame.width();
+
+ // If two sided, left becomes inside and right becomes outside.
+ // Thus, for left-bound pages, we want to swap on even pages and
+ // for right-bound pages, we want to swap on odd pages.
+ let mut margin = margin;
+ if two_sided && binding.swap(number) {
+ std::mem::swap(&mut margin.left, &mut margin.right);
+ }
+
+ // Realize margins.
+ frame.set_size(frame.size() + margin.sum_by_axis());
+ frame.translate(Point::new(margin.left, margin.top));
+ frame.push(Point::zero(), numbering_meta.clone());
+
+ // The page size with margins.
+ let size = frame.size();
+
+ // Realize overlays.
+ for (name, marginal) in [
+ ("header", &header),
+ ("footer", &footer),
+ ("background", &background),
+ ("foreground", &foreground),
+ ] {
+ tracing::info!("Layouting {name}");
+
+ let Some(content) = marginal else { continue };
+
+ let (pos, area, align);
+ if ptr::eq(marginal, &header) {
+ let ascent = header_ascent.relative_to(margin.top);
+ pos = Point::with_x(margin.left);
+ area = Size::new(pw, margin.top - ascent);
+ align = Align::Bottom.into();
+ } else if ptr::eq(marginal, &footer) {
+ let descent = footer_descent.relative_to(margin.bottom);
+ pos = Point::new(margin.left, size.y - margin.bottom + descent);
+ area = Size::new(pw, margin.bottom - descent);
+ align = Align::Top.into();
+ } else {
+ pos = Point::zero();
+ area = size;
+ align = Align::CENTER_HORIZON.into();
+ };
+
+ let pod = Regions::one(area, Axes::splat(true));
+ let sub = content
+ .clone()
+ .styled(AlignElem::set_alignment(align))
+ .layout(vt, styles, pod)?
+ .into_frame();
+
+ if ptr::eq(marginal, &header) || ptr::eq(marginal, &background) {
+ frame.prepend_frame(pos, sub);
+ } else {
+ frame.push_frame(pos, sub);
+ }
+ }
+
+ if let Some(fill) = &fill {
+ frame.fill(fill.clone());
+ }
+
+ number = number.saturating_add(1);
+ }
+
+ Ok(Fragment::frames(frames))
+ }
+}
+
+/// Specification of the page's margins.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct Margin {
+ /// The margins for each side.
+ pub sides: Sides<Option<Smart<Rel<Length>>>>,
+ /// Whether to swap `left` and `right` to make them `inside` and `outside`
+ /// (when to swap depends on the binding).
+ pub two_sided: Option<bool>,
+}
+
+impl Margin {
+ /// Create an instance with four equal components.
+ pub fn splat(value: Option<Smart<Rel<Length>>>) -> Self {
+ Self { sides: Sides::splat(value), two_sided: None }
+ }
+}
+
+impl Fold for Margin {
+ type Output = Margin;
+
+ fn fold(self, outer: Self::Output) -> Self::Output {
+ let sides =
+ self.sides
+ .zip(outer.sides)
+ .map(|(inner, outer)| match (inner, outer) {
+ (Some(value), Some(outer)) => Some(value.fold(outer)),
+ _ => inner.or(outer),
+ });
+ let two_sided = self.two_sided.or(outer.two_sided);
+ Margin { sides, two_sided }
+ }
+}
+
+cast! {
+ Margin,
+ self => {
+ let mut dict = Dict::new();
+ let mut handle = |key: &str, component: Value| {
+ let value = component.into_value();
+ if value != Value::None {
+ dict.insert(key.into(), value);
+ }
+ };
+
+ handle("top", self.sides.top.into_value());
+ handle("bottom", self.sides.bottom.into_value());
+ if self.two_sided.unwrap_or(false) {
+ handle("inside", self.sides.left.into_value());
+ handle("outside", self.sides.right.into_value());
+ } else {
+ handle("left", self.sides.left.into_value());
+ handle("right", self.sides.right.into_value());
+ }
+
+ Value::Dict(dict)
+ },
+ _: AutoValue => Self::splat(Some(Smart::Auto)),
+ v: Rel<Length> => Self::splat(Some(Smart::Custom(v))),
+ mut dict: Dict => {
+ let mut take = |key| dict.take(key).ok().map(Value::cast).transpose();
+
+ let rest = take("rest")?;
+ let x = take("x")?.or(rest);
+ let y = take("y")?.or(rest);
+ let top = take("top")?.or(y);
+ let bottom = take("bottom")?.or(y);
+ let outside = take("outside")?;
+ let inside = take("inside")?;
+ let left = take("left")?;
+ let right = take("right")?;
+
+ let implicitly_two_sided = outside.is_some() || inside.is_some();
+ let implicitly_not_two_sided = left.is_some() || right.is_some();
+ if implicitly_two_sided && implicitly_not_two_sided {
+ bail!("`inside` and `outside` are mutually exclusive with `left` and `right`");
+ }
+
+ // - If 'implicitly_two_sided' is false here, then
+ // 'implicitly_not_two_sided' will be guaranteed to be true
+ // due to the previous two 'if' conditions.
+ // - If both are false, this means that this margin change does not
+ // affect lateral margins, and thus shouldn't make a difference on
+ // the 'two_sided' attribute of this margin.
+ let two_sided = (implicitly_two_sided || implicitly_not_two_sided)
+ .then_some(implicitly_two_sided);
+
+ dict.finish(&[
+ "left", "top", "right", "bottom", "outside", "inside", "x", "y", "rest",
+ ])?;
+
+ Margin {
+ sides: Sides {
+ left: inside.or(left).or(x),
+ top,
+ right: outside.or(right).or(x),
+ bottom,
+ },
+ two_sided,
+ }
+ }
+}
+
+/// Specification of the page's binding.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Binding {
+ /// Bound on the left, as customary in LTR languages.
+ Left,
+ /// Bound on the right, as customary in RTL languages.
+ Right,
+}
+
+impl Binding {
+ /// Whether to swap left and right margin for the page with this number.
+ fn swap(self, number: NonZeroUsize) -> bool {
+ match self {
+ // Left-bound must swap on even pages
+ // (because it is correct on the first page).
+ Self::Left => number.get() % 2 == 0,
+ // Right-bound must swap on odd pages
+ // (because it is wrong on the first page).
+ Self::Right => number.get() % 2 == 1,
+ }
+ }
+}
+
+cast! {
+ Binding,
+ self => match self {
+ Self::Left => GenAlign::Specific(Align::Left).into_value(),
+ Self::Right => GenAlign::Specific(Align::Right).into_value(),
+ },
+ v: GenAlign => match v {
+ GenAlign::Specific(Align::Left) => Self::Left,
+ GenAlign::Specific(Align::Right) => Self::Right,
+ _ => bail!("must be `left` or `right`"),
+ },
+}
+
+/// A header, footer, foreground or background definition.
+#[derive(Debug, Clone, Hash)]
+pub enum Marginal {
+ /// Bare content.
+ Content(Content),
+ /// A closure mapping from a page number to content.
+ Func(Func),
+}
+
+impl Marginal {
+ /// Resolve the marginal based on the page number.
+ pub fn resolve(&self, vt: &mut Vt, page: usize) -> SourceResult<Content> {
+ Ok(match self {
+ Self::Content(content) => content.clone(),
+ Self::Func(func) => func.call_vt(vt, [page])?.display(),
+ })
+ }
+}
+
+cast! {
+ Marginal,
+ self => match self {
+ Self::Content(v) => v.into_value(),
+ Self::Func(v) => v.into_value(),
+ },
+ v: Content => Self::Content(v),
+ v: Func => Self::Func(v),
+}
+
+/// A manual page break.
+///
+/// Must not be used inside any containers.
+///
+/// ## Example { #example }
+/// ```example
+/// The next page contains
+/// more details on compound theory.
+/// #pagebreak()
+///
+/// == Compound Theory
+/// In 1984, the first ...
+/// ```
+///
+/// Display: Page Break
+/// Category: layout
+#[element]
+pub struct PagebreakElem {
+ /// If `{true}`, the page break is skipped if the current page is already
+ /// empty.
+ #[default(false)]
+ pub weak: bool,
+
+ /// If given, ensures that the next page will be an even/odd page, with an
+ /// empty page in between if necessary.
+ ///
+ /// ```example
+ /// #set page(height: 30pt)
+ ///
+ /// First.
+ /// #pagebreak(to: "odd")
+ /// Third.
+ /// ```
+ pub to: Option<Parity>,
+}
+
+/// Whether something should be even or odd.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum Parity {
+ /// Next page will be an even page.
+ Even,
+ /// Next page will be an odd page.
+ Odd,
+}
+
+impl Parity {
+ /// Whether the given number matches the parity.
+ fn matches(self, number: usize) -> bool {
+ match self {
+ Self::Even => number % 2 == 0,
+ Self::Odd => number % 2 == 1,
+ }
+ }
+}
+
+/// Specification of a paper.
+#[derive(Debug, Copy, Clone, Hash)]
+pub struct Paper {
+ /// The name of the paper.
+ name: &'static str,
+ /// The width of the paper in millimeters.
+ width: Scalar,
+ /// The height of the paper in millimeters.
+ height: Scalar,
+}
+
+impl Paper {
+ /// The width of the paper.
+ pub fn width(self) -> Abs {
+ Abs::mm(self.width.0)
+ }
+
+ /// The height of the paper.
+ pub fn height(self) -> Abs {
+ Abs::mm(self.height.0)
+ }
+}
+
+/// Defines paper constants and a paper parsing implementation.
+macro_rules! papers {
+ ($(($var:ident: $width:expr, $height: expr, $name:literal))*) => {
+ /// Predefined papers.
+ ///
+ /// Each paper is parsable from its name in kebab-case.
+ impl Paper {
+ $(pub const $var: Self = Self {
+ name: $name,
+ width: Scalar($width),
+ height: Scalar($height),
+ };)*
+ }
+
+ impl FromStr for Paper {
+ type Err = &'static str;
+
+ fn from_str(name: &str) -> Result<Self, Self::Err> {
+ match name.to_lowercase().as_str() {
+ $($name => Ok(Self::$var),)*
+ _ => Err("unknown paper size"),
+ }
+ }
+ }
+
+ cast! {
+ Paper,
+ self => self.name.into_value(),
+ $(
+ /// Produces a paper of the respective size.
+ $name => Self::$var,
+ )*
+ }
+ };
+}
+
+// All paper sizes in mm.
+//
+// Resources:
+// - https://papersizes.io/
+// - https://en.wikipedia.org/wiki/Paper_size
+// - https://www.theedkins.co.uk/jo/units/oldunits/print.htm
+// - https://vintagepaper.co/blogs/news/traditional-paper-sizes
+papers! {
+ // ---------------------------------------------------------------------- //
+ // ISO 216 A Series
+ (A0: 841.0, 1189.0, "a0")
+ (A1: 594.0, 841.0, "a1")
+ (A2: 420.0, 594.0, "a2")
+ (A3: 297.0, 420.0, "a3")
+ (A4: 210.0, 297.0, "a4")
+ (A5: 148.0, 210.0, "a5")
+ (A6: 105.0, 148.0, "a6")
+ (A7: 74.0, 105.0, "a7")
+ (A8: 52.0, 74.0, "a8")
+ (A9: 37.0, 52.0, "a9")
+ (A10: 26.0, 37.0, "a10")
+ (A11: 18.0, 26.0, "a11")
+
+ // ISO 216 B Series
+ (ISO_B1: 707.0, 1000.0, "iso-b1")
+ (ISO_B2: 500.0, 707.0, "iso-b2")
+ (ISO_B3: 353.0, 500.0, "iso-b3")
+ (ISO_B4: 250.0, 353.0, "iso-b4")
+ (ISO_B5: 176.0, 250.0, "iso-b5")
+ (ISO_B6: 125.0, 176.0, "iso-b6")
+ (ISO_B7: 88.0, 125.0, "iso-b7")
+ (ISO_B8: 62.0, 88.0, "iso-b8")
+
+ // ISO 216 C Series
+ (ISO_C3: 324.0, 458.0, "iso-c3")
+ (ISO_C4: 229.0, 324.0, "iso-c4")
+ (ISO_C5: 162.0, 229.0, "iso-c5")
+ (ISO_C6: 114.0, 162.0, "iso-c6")
+ (ISO_C7: 81.0, 114.0, "iso-c7")
+ (ISO_C8: 57.0, 81.0, "iso-c8")
+
+ // DIN D Series (extension to ISO)
+ (DIN_D3: 272.0, 385.0, "din-d3")
+ (DIN_D4: 192.0, 272.0, "din-d4")
+ (DIN_D5: 136.0, 192.0, "din-d5")
+ (DIN_D6: 96.0, 136.0, "din-d6")
+ (DIN_D7: 68.0, 96.0, "din-d7")
+ (DIN_D8: 48.0, 68.0, "din-d8")
+
+ // SIS (used in academia)
+ (SIS_G5: 169.0, 239.0, "sis-g5")
+ (SIS_E5: 115.0, 220.0, "sis-e5")
+
+ // ANSI Extensions
+ (ANSI_A: 216.0, 279.0, "ansi-a")
+ (ANSI_B: 279.0, 432.0, "ansi-b")
+ (ANSI_C: 432.0, 559.0, "ansi-c")
+ (ANSI_D: 559.0, 864.0, "ansi-d")
+ (ANSI_E: 864.0, 1118.0, "ansi-e")
+
+ // ANSI Architectural Paper
+ (ARCH_A: 229.0, 305.0, "arch-a")
+ (ARCH_B: 305.0, 457.0, "arch-b")
+ (ARCH_C: 457.0, 610.0, "arch-c")
+ (ARCH_D: 610.0, 914.0, "arch-d")
+ (ARCH_E1: 762.0, 1067.0, "arch-e1")
+ (ARCH_E: 914.0, 1219.0, "arch-e")
+
+ // JIS B Series
+ (JIS_B0: 1030.0, 1456.0, "jis-b0")
+ (JIS_B1: 728.0, 1030.0, "jis-b1")
+ (JIS_B2: 515.0, 728.0, "jis-b2")
+ (JIS_B3: 364.0, 515.0, "jis-b3")
+ (JIS_B4: 257.0, 364.0, "jis-b4")
+ (JIS_B5: 182.0, 257.0, "jis-b5")
+ (JIS_B6: 128.0, 182.0, "jis-b6")
+ (JIS_B7: 91.0, 128.0, "jis-b7")
+ (JIS_B8: 64.0, 91.0, "jis-b8")
+ (JIS_B9: 45.0, 64.0, "jis-b9")
+ (JIS_B10: 32.0, 45.0, "jis-b10")
+ (JIS_B11: 22.0, 32.0, "jis-b11")
+
+ // SAC D Series
+ (SAC_D0: 764.0, 1064.0, "sac-d0")
+ (SAC_D1: 532.0, 760.0, "sac-d1")
+ (SAC_D2: 380.0, 528.0, "sac-d2")
+ (SAC_D3: 264.0, 376.0, "sac-d3")
+ (SAC_D4: 188.0, 260.0, "sac-d4")
+ (SAC_D5: 130.0, 184.0, "sac-d5")
+ (SAC_D6: 92.0, 126.0, "sac-d6")
+
+ // ISO 7810 ID
+ (ISO_ID_1: 85.6, 53.98, "iso-id-1")
+ (ISO_ID_2: 74.0, 105.0, "iso-id-2")
+ (ISO_ID_3: 88.0, 125.0, "iso-id-3")
+
+ // ---------------------------------------------------------------------- //
+ // Asia
+ (ASIA_F4: 210.0, 330.0, "asia-f4")
+
+ // Japan
+ (JP_SHIROKU_BAN_4: 264.0, 379.0, "jp-shiroku-ban-4")
+ (JP_SHIROKU_BAN_5: 189.0, 262.0, "jp-shiroku-ban-5")
+ (JP_SHIROKU_BAN_6: 127.0, 188.0, "jp-shiroku-ban-6")
+ (JP_KIKU_4: 227.0, 306.0, "jp-kiku-4")
+ (JP_KIKU_5: 151.0, 227.0, "jp-kiku-5")
+ (JP_BUSINESS_CARD: 91.0, 55.0, "jp-business-card")
+
+ // China
+ (CN_BUSINESS_CARD: 90.0, 54.0, "cn-business-card")
+
+ // Europe
+ (EU_BUSINESS_CARD: 85.0, 55.0, "eu-business-card")
+
+ // French Traditional (AFNOR)
+ (FR_TELLIERE: 340.0, 440.0, "fr-tellière")
+ (FR_COURONNE_ECRITURE: 360.0, 460.0, "fr-couronne-écriture")
+ (FR_COURONNE_EDITION: 370.0, 470.0, "fr-couronne-édition")
+ (FR_RAISIN: 500.0, 650.0, "fr-raisin")
+ (FR_CARRE: 450.0, 560.0, "fr-carré")
+ (FR_JESUS: 560.0, 760.0, "fr-jésus")
+
+ // United Kingdom Imperial
+ (UK_BRIEF: 406.4, 342.9, "uk-brief")
+ (UK_DRAFT: 254.0, 406.4, "uk-draft")
+ (UK_FOOLSCAP: 203.2, 330.2, "uk-foolscap")
+ (UK_QUARTO: 203.2, 254.0, "uk-quarto")
+ (UK_CROWN: 508.0, 381.0, "uk-crown")
+ (UK_BOOK_A: 111.0, 178.0, "uk-book-a")
+ (UK_BOOK_B: 129.0, 198.0, "uk-book-b")
+
+ // Unites States
+ (US_LETTER: 215.9, 279.4, "us-letter")
+ (US_LEGAL: 215.9, 355.6, "us-legal")
+ (US_TABLOID: 279.4, 431.8, "us-tabloid")
+ (US_EXECUTIVE: 84.15, 266.7, "us-executive")
+ (US_FOOLSCAP_FOLIO: 215.9, 342.9, "us-foolscap-folio")
+ (US_STATEMENT: 139.7, 215.9, "us-statement")
+ (US_LEDGER: 431.8, 279.4, "us-ledger")
+ (US_OFICIO: 215.9, 340.36, "us-oficio")
+ (US_GOV_LETTER: 203.2, 266.7, "us-gov-letter")
+ (US_GOV_LEGAL: 215.9, 330.2, "us-gov-legal")
+ (US_BUSINESS_CARD: 88.9, 50.8, "us-business-card")
+ (US_DIGEST: 139.7, 215.9, "us-digest")
+ (US_TRADE: 152.4, 228.6, "us-trade")
+
+ // ---------------------------------------------------------------------- //
+ // Other
+ (NEWSPAPER_COMPACT: 280.0, 430.0, "newspaper-compact")
+ (NEWSPAPER_BERLINER: 315.0, 470.0, "newspaper-berliner")
+ (NEWSPAPER_BROADSHEET: 381.0, 578.0, "newspaper-broadsheet")
+ (PRESENTATION_16_9: 297.0, 167.0625, "presentation-16-9")
+ (PRESENTATION_4_3: 280.0, 210.0, "presentation-4-3")
+}
diff --git a/crates/typst-library/src/layout/par.rs b/crates/typst-library/src/layout/par.rs
new file mode 100644
index 00000000..6b914e80
--- /dev/null
+++ b/crates/typst-library/src/layout/par.rs
@@ -0,0 +1,1566 @@
+use icu_properties::{maps::CodePointMapData, LineBreak};
+use icu_provider::AsDeserializingBufferProvider;
+use icu_provider_adapters::fork::ForkByKeyProvider;
+use icu_provider_blob::BlobDataProvider;
+use icu_segmenter::{LineBreakIteratorUtf8, LineSegmenter};
+use once_cell::sync::Lazy;
+use typst::eval::Tracer;
+use typst::model::DelayedErrors;
+use unicode_bidi::{BidiInfo, Level as BidiLevel};
+use unicode_script::{Script, UnicodeScript};
+
+use super::{BoxElem, HElem, Sizing, Spacing};
+use crate::layout::AlignElem;
+use crate::math::EquationElem;
+use crate::prelude::*;
+use crate::text::{
+ is_gb_style, shape, LinebreakElem, Quoter, Quotes, ShapedText, SmartQuoteElem,
+ SpaceElem, TextElem,
+};
+
+/// Arranges text, spacing and inline-level elements into a paragraph.
+///
+/// Although this function is primarily used in set rules to affect paragraph
+/// properties, it can also be used to explicitly render its argument onto a
+/// paragraph of its own.
+///
+/// ## Example { #example }
+/// ```example
+/// #show par: set block(spacing: 0.65em)
+/// #set par(
+/// first-line-indent: 1em,
+/// justify: true,
+/// )
+///
+/// We proceed by contradiction.
+/// Suppose that there exists a set
+/// of positive integers $a$, $b$, and
+/// $c$ that satisfies the equation
+/// $a^n + b^n = c^n$ for some
+/// integer value of $n > 2$.
+///
+/// Without loss of generality,
+/// let $a$ be the smallest of the
+/// three integers. Then, we ...
+/// ```
+///
+/// Display: Paragraph
+/// Category: layout
+#[element(Construct)]
+pub struct ParElem {
+ /// The spacing between lines.
+ #[resolve]
+ #[default(Em::new(0.65).into())]
+ pub leading: Length,
+
+ /// Whether to justify text in its line.
+ ///
+ /// Hyphenation will be enabled for justified paragraphs if the [text
+ /// property hyphenate]($func/text.hyphenate) is set to `{auto}` and the
+ /// current language is known.
+ ///
+ /// Note that the current [alignment]($func/align) still has an effect on
+ /// the placement of the last line except if it ends with a [justified line
+ /// break]($func/linebreak.justify).
+ #[default(false)]
+ pub justify: bool,
+
+ /// How to determine line breaks.
+ ///
+ /// When this property is set to `{auto}`, its default value, optimized line
+ /// breaks will be used for justified paragraphs. Enabling optimized line
+ /// breaks for ragged paragraphs may also be worthwhile to improve the
+ /// appearance of the text.
+ ///
+ /// ```example
+ /// #set page(width: 190pt)
+ /// #set par(linebreaks: "simple")
+ /// Some texts are frustratingly
+ /// challenging to break in a
+ /// visually pleasing way. This
+ /// very aesthetic example is one
+ /// of them.
+ ///
+ /// #set par(linebreaks: "optimized")
+ /// Some texts are frustratingly
+ /// challenging to break in a
+ /// visually pleasing way. This
+ /// very aesthetic example is one
+ /// of them.
+ /// ```
+ #[default]
+ pub linebreaks: Smart<Linebreaks>,
+
+ /// The indent the first line of a paragraph should have.
+ ///
+ /// Only the first line of a consecutive paragraph will be indented (not
+ /// the first one in a block or on the page).
+ ///
+ /// By typographic convention, paragraph breaks are indicated either by some
+ /// space between paragraphs or by indented first lines. Consider reducing
+ /// the [paragraph spacing]($func/block.spacing) to the [`leading`] when
+ /// using this property (e.g. using
+ /// `[#show par: set block(spacing: 0.65em)]`).
+ pub first_line_indent: Length,
+
+ /// The indent all but the first line of a paragraph should have.
+ #[resolve]
+ pub hanging_indent: Length,
+
+ /// The contents of the paragraph.
+ #[external]
+ #[required]
+ pub body: Content,
+
+ /// The paragraph's children.
+ #[internal]
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+impl Construct for ParElem {
+ fn construct(_: &mut Vm, args: &mut Args) -> SourceResult<Content> {
+ // The paragraph constructor is special: It doesn't create a paragraph
+ // element. Instead, it just ensures that the passed content lives in a
+ // separate paragraph and styles it.
+ let styles = Self::set(args)?;
+ let body = args.expect::<Content>("body")?;
+ Ok(Content::sequence([
+ ParbreakElem::new().pack(),
+ body.styled_with_map(styles),
+ ParbreakElem::new().pack(),
+ ]))
+ }
+}
+
+impl ParElem {
+ /// Layout the paragraph into a collection of lines.
+ #[tracing::instrument(name = "ParElement::layout", skip_all)]
+ pub fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ consecutive: bool,
+ region: Size,
+ expand: bool,
+ ) -> SourceResult<Fragment> {
+ #[comemo::memoize]
+ #[allow(clippy::too_many_arguments)]
+ fn cached(
+ par: &ParElem,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ locator: Tracked<Locator>,
+ delayed: TrackedMut<DelayedErrors>,
+ tracer: TrackedMut<Tracer>,
+ styles: StyleChain,
+ consecutive: bool,
+ region: Size,
+ expand: bool,
+ ) -> SourceResult<Fragment> {
+ let mut locator = Locator::chained(locator);
+ let mut vt = Vt {
+ world,
+ introspector,
+ locator: &mut locator,
+ delayed,
+ tracer,
+ };
+ let children = par.children();
+
+ // Collect all text into one string for BiDi analysis.
+ let (text, segments, spans) = collect(&children, &styles, consecutive)?;
+
+ // Perform BiDi analysis and then prepare paragraph layout by building a
+ // representation on which we can do line breaking without layouting
+ // each and every line from scratch.
+ let p = prepare(&mut vt, &children, &text, segments, spans, styles, region)?;
+
+ // Break the paragraph into lines.
+ let lines = linebreak(&vt, &p, region.x - p.hang);
+
+ // Stack the lines into one frame per region.
+ finalize(&mut vt, &p, &lines, region, expand)
+ }
+
+ let fragment = cached(
+ self,
+ vt.world,
+ vt.introspector,
+ vt.locator.track(),
+ TrackedMut::reborrow_mut(&mut vt.delayed),
+ TrackedMut::reborrow_mut(&mut vt.tracer),
+ styles,
+ consecutive,
+ region,
+ expand,
+ )?;
+
+ vt.locator.visit_frames(&fragment);
+ Ok(fragment)
+ }
+}
+
+/// How to determine line breaks in a paragraph.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum Linebreaks {
+ /// Determine the line breaks in a simple first-fit style.
+ Simple,
+ /// Optimize the line breaks for the whole paragraph.
+ ///
+ /// Typst will try to produce more evenly filled lines of text by
+ /// considering the whole paragraph when calculating line breaks.
+ Optimized,
+}
+
+/// A paragraph break.
+///
+/// This starts a new paragraph. Especially useful when used within code like
+/// [for loops]($scripting/#loops). Multiple consecutive
+/// paragraph breaks collapse into a single one.
+///
+/// ## Example { #example }
+/// ```example
+/// #for i in range(3) {
+/// [Blind text #i: ]
+/// lorem(5)
+/// parbreak()
+/// }
+/// ```
+///
+/// ## Syntax { #syntax }
+/// Instead of calling this function, you can insert a blank line into your
+/// markup to create a paragraph break.
+///
+/// Display: Paragraph Break
+/// Category: layout
+#[element(Unlabellable)]
+pub struct ParbreakElem {}
+
+impl Unlabellable for ParbreakElem {}
+
+/// Range of a substring of text.
+type Range = std::ops::Range<usize>;
+
+// The characters by which spacing, inline content and pins are replaced in the
+// paragraph's full text.
+const SPACING_REPLACE: char = ' '; // Space
+const OBJ_REPLACE: char = '\u{FFFC}'; // Object Replacement Character
+
+/// A paragraph representation in which children are already layouted and text
+/// is already preshaped.
+///
+/// In many cases, we can directly reuse these results when constructing a line.
+/// Only when a line break falls onto a text index that is not safe-to-break per
+/// rustybuzz, we have to reshape that portion.
+struct Preparation<'a> {
+ /// Bidirectional text embedding levels for the paragraph.
+ bidi: BidiInfo<'a>,
+ /// Text runs, spacing and layouted elements.
+ items: Vec<Item<'a>>,
+ /// The span mapper.
+ spans: SpanMapper,
+ /// The styles shared by all children.
+ styles: StyleChain<'a>,
+ /// Whether to hyphenate if it's the same for all children.
+ hyphenate: Option<bool>,
+ /// The text language if it's the same for all children.
+ lang: Option<Lang>,
+ /// The paragraph's resolved alignment.
+ align: Align,
+ /// Whether to justify the paragraph.
+ justify: bool,
+ /// The paragraph's hanging indent.
+ hang: Abs,
+}
+
+impl<'a> Preparation<'a> {
+ /// Find the item that contains the given `text_offset`.
+ fn find(&self, text_offset: usize) -> Option<&Item<'a>> {
+ let mut cursor = 0;
+ for item in &self.items {
+ let end = cursor + item.len();
+ if (cursor..end).contains(&text_offset) {
+ return Some(item);
+ }
+ cursor = end;
+ }
+ None
+ }
+
+ /// Return the items that intersect the given `text_range`.
+ ///
+ /// Returns the expanded range around the items and the items.
+ fn slice(&self, text_range: Range) -> (Range, &[Item<'a>]) {
+ let mut cursor = 0;
+ let mut start = 0;
+ let mut end = 0;
+ let mut expanded = text_range.clone();
+
+ for (i, item) in self.items.iter().enumerate() {
+ if cursor <= text_range.start {
+ start = i;
+ expanded.start = cursor;
+ }
+
+ let len = item.len();
+ if cursor < text_range.end || cursor + len <= text_range.end {
+ end = i + 1;
+ expanded.end = cursor + len;
+ } else {
+ break;
+ }
+
+ cursor += len;
+ }
+
+ (expanded, &self.items[start..end])
+ }
+}
+
+/// A segment of one or multiple collapsed children.
+#[derive(Debug, Copy, Clone)]
+enum Segment<'a> {
+ /// One or multiple collapsed text or text-equivalent children. Stores how
+ /// long the segment is (in bytes of the full text string).
+ Text(usize),
+ /// Horizontal spacing between other segments.
+ Spacing(Spacing),
+ /// A mathematical equation.
+ Equation(&'a EquationElem),
+ /// A box with arbitrary content.
+ Box(&'a BoxElem, bool),
+ /// Metadata.
+ Meta,
+}
+
+impl Segment<'_> {
+ /// The text length of the item.
+ fn len(&self) -> usize {
+ match *self {
+ Self::Text(len) => len,
+ Self::Spacing(_) => SPACING_REPLACE.len_utf8(),
+ Self::Box(_, true) => SPACING_REPLACE.len_utf8(),
+ Self::Equation(_) | Self::Box(_, _) => OBJ_REPLACE.len_utf8(),
+ Self::Meta => 0,
+ }
+ }
+}
+
+/// A prepared item in a paragraph layout.
+#[derive(Debug)]
+enum Item<'a> {
+ /// A shaped text run with consistent style and direction.
+ Text(ShapedText<'a>),
+ /// Absolute spacing between other items.
+ Absolute(Abs),
+ /// Fractional spacing between other items.
+ Fractional(Fr, Option<(&'a BoxElem, StyleChain<'a>)>),
+ /// Layouted inline-level content.
+ Frame(Frame),
+ /// Metadata.
+ Meta(Frame),
+}
+
+impl<'a> Item<'a> {
+ /// If this a text item, return it.
+ fn text(&self) -> Option<&ShapedText<'a>> {
+ match self {
+ Self::Text(shaped) => Some(shaped),
+ _ => None,
+ }
+ }
+
+ fn text_mut(&mut self) -> Option<&mut ShapedText<'a>> {
+ match self {
+ Self::Text(shaped) => Some(shaped),
+ _ => None,
+ }
+ }
+
+ /// The text length of the item.
+ fn len(&self) -> usize {
+ match self {
+ Self::Text(shaped) => shaped.text.len(),
+ Self::Absolute(_) | Self::Fractional(_, _) => SPACING_REPLACE.len_utf8(),
+ Self::Frame(_) => OBJ_REPLACE.len_utf8(),
+ Self::Meta(_) => 0,
+ }
+ }
+
+ /// The natural layouted width of the item.
+ fn width(&self) -> Abs {
+ match self {
+ Self::Text(shaped) => shaped.width,
+ Self::Absolute(v) => *v,
+ Self::Frame(frame) => frame.width(),
+ Self::Fractional(_, _) | Self::Meta(_) => Abs::zero(),
+ }
+ }
+}
+
+/// Maps byte offsets back to spans.
+#[derive(Default)]
+pub struct SpanMapper(Vec<(usize, Span)>);
+
+impl SpanMapper {
+ /// Create a new span mapper.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Push a span for a segment with the given length.
+ pub fn push(&mut self, len: usize, span: Span) {
+ self.0.push((len, span));
+ }
+
+ /// Determine the span at the given byte offset.
+ ///
+ /// May return a detached span.
+ pub fn span_at(&self, offset: usize) -> (Span, u16) {
+ let mut cursor = 0;
+ for &(len, span) in &self.0 {
+ if (cursor..=cursor + len).contains(&offset) {
+ return (span, u16::try_from(offset - cursor).unwrap_or(0));
+ }
+ cursor += len;
+ }
+ (Span::detached(), 0)
+ }
+}
+
+/// A layouted line, consisting of a sequence of layouted paragraph items that
+/// are mostly borrowed from the preparation phase. This type enables you to
+/// measure the size of a line in a range before committing to building the
+/// line's frame.
+///
+/// At most two paragraph items must be created individually for this line: The
+/// first and last one since they may be broken apart by the start or end of the
+/// line, respectively. But even those can partially reuse previous results when
+/// the break index is safe-to-break per rustybuzz.
+struct Line<'a> {
+ /// Bidi information about the paragraph.
+ bidi: &'a BidiInfo<'a>,
+ /// The trimmed range the line spans in the paragraph.
+ trimmed: Range,
+ /// The untrimmed end where the line ends.
+ end: usize,
+ /// A reshaped text item if the line sliced up a text item at the start.
+ first: Option<Item<'a>>,
+ /// Inner items which don't need to be reprocessed.
+ inner: &'a [Item<'a>],
+ /// A reshaped text item if the line sliced up a text item at the end. If
+ /// there is only one text item, this takes precedence over `first`.
+ last: Option<Item<'a>>,
+ /// The width of the line.
+ width: Abs,
+ /// Whether the line should be justified.
+ justify: bool,
+ /// Whether the line ends with a hyphen or dash, either naturally or through
+ /// hyphenation.
+ dash: bool,
+}
+
+impl<'a> Line<'a> {
+ /// Iterate over the line's items.
+ fn items(&self) -> impl Iterator<Item = &Item<'a>> {
+ self.first.iter().chain(self.inner).chain(&self.last)
+ }
+
+ /// Return items that intersect the given `text_range`.
+ fn slice(&self, text_range: Range) -> impl Iterator<Item = &Item<'a>> {
+ let mut cursor = self.trimmed.start;
+ let mut start = 0;
+ let mut end = 0;
+
+ for (i, item) in self.items().enumerate() {
+ if cursor <= text_range.start {
+ start = i;
+ }
+
+ let len = item.len();
+ if cursor < text_range.end || cursor + len <= text_range.end {
+ end = i + 1;
+ } else {
+ break;
+ }
+
+ cursor += len;
+ }
+
+ self.items().skip(start).take(end - start)
+ }
+
+ /// How many glyphs are in the text where we can insert additional
+ /// space when encountering underfull lines.
+ fn justifiables(&self) -> usize {
+ let mut count = 0;
+ for shaped in self.items().filter_map(Item::text) {
+ count += shaped.justifiables();
+ }
+ // CJK character at line end should not be adjusted.
+ if self
+ .items()
+ .last()
+ .and_then(Item::text)
+ .map(|s| s.cjk_justifiable_at_last())
+ .unwrap_or(false)
+ {
+ count -= 1;
+ }
+
+ count
+ }
+
+ /// How much can the line stretch
+ fn stretchability(&self) -> Abs {
+ self.items().filter_map(Item::text).map(|s| s.stretchability()).sum()
+ }
+
+ /// How much can the line shrink
+ fn shrinkability(&self) -> Abs {
+ self.items().filter_map(Item::text).map(|s| s.shrinkability()).sum()
+ }
+
+ /// The sum of fractions in the line.
+ fn fr(&self) -> Fr {
+ self.items()
+ .filter_map(|item| match item {
+ Item::Fractional(fr, _) => Some(*fr),
+ _ => None,
+ })
+ .sum()
+ }
+}
+
+/// Collect all text of the paragraph into one string. This also performs
+/// string-level preprocessing like case transformations.
+#[allow(clippy::type_complexity)]
+fn collect<'a>(
+ children: &'a [Content],
+ styles: &'a StyleChain<'a>,
+ consecutive: bool,
+) -> SourceResult<(String, Vec<(Segment<'a>, StyleChain<'a>)>, SpanMapper)> {
+ let mut full = String::new();
+ let mut quoter = Quoter::new();
+ let mut segments = vec![];
+ let mut spans = SpanMapper::new();
+ let mut iter = children.iter().peekable();
+
+ let first_line_indent = ParElem::first_line_indent_in(*styles);
+ if !first_line_indent.is_zero()
+ && consecutive
+ && AlignElem::alignment_in(*styles).x.resolve(*styles)
+ == TextElem::dir_in(*styles).start().into()
+ {
+ full.push(SPACING_REPLACE);
+ segments.push((Segment::Spacing(first_line_indent.into()), *styles));
+ }
+
+ let hang = ParElem::hanging_indent_in(*styles);
+ if !hang.is_zero() {
+ full.push(SPACING_REPLACE);
+ segments.push((Segment::Spacing((-hang).into()), *styles));
+ }
+
+ while let Some(mut child) = iter.next() {
+ let outer = styles;
+ let mut styles = *styles;
+ if let Some((elem, local)) = child.to_styled() {
+ child = elem;
+ styles = outer.chain(local);
+ }
+
+ let segment = if child.is::<SpaceElem>() {
+ full.push(' ');
+ Segment::Text(1)
+ } else if let Some(elem) = child.to::<TextElem>() {
+ let prev = full.len();
+ if let Some(case) = TextElem::case_in(styles) {
+ full.push_str(&case.apply(&elem.text()));
+ } else {
+ full.push_str(&elem.text());
+ }
+ Segment::Text(full.len() - prev)
+ } else if let Some(elem) = child.to::<HElem>() {
+ if elem.amount().is_zero() {
+ continue;
+ }
+
+ full.push(SPACING_REPLACE);
+ Segment::Spacing(elem.amount())
+ } else if let Some(elem) = child.to::<LinebreakElem>() {
+ let c = if elem.justify(styles) { '\u{2028}' } else { '\n' };
+ full.push(c);
+ Segment::Text(c.len_utf8())
+ } else if let Some(elem) = child.to::<SmartQuoteElem>() {
+ let prev = full.len();
+ if SmartQuoteElem::enabled_in(styles) {
+ let lang = TextElem::lang_in(styles);
+ let region = TextElem::region_in(styles);
+ let quotes = Quotes::from_lang(lang, region);
+ let peeked = iter.peek().and_then(|child| {
+ let child = if let Some((child, _)) = child.to_styled() {
+ child
+ } else {
+ child
+ };
+ if let Some(elem) = child.to::<TextElem>() {
+ elem.text().chars().next()
+ } else if child.is::<SmartQuoteElem>() {
+ Some('"')
+ } else if child.is::<SpaceElem>()
+ || child.is::<HElem>()
+ || child.is::<LinebreakElem>()
+ {
+ Some(SPACING_REPLACE)
+ } else {
+ Some(OBJ_REPLACE)
+ }
+ });
+
+ full.push_str(quoter.quote(&quotes, elem.double(styles), peeked));
+ } else {
+ full.push(if elem.double(styles) { '"' } else { '\'' });
+ }
+ Segment::Text(full.len() - prev)
+ } else if let Some(elem) = child.to::<EquationElem>() {
+ full.push(OBJ_REPLACE);
+ Segment::Equation(elem)
+ } else if let Some(elem) = child.to::<BoxElem>() {
+ let frac = elem.width(styles).is_fractional();
+ full.push(if frac { SPACING_REPLACE } else { OBJ_REPLACE });
+ Segment::Box(elem, frac)
+ } else if child.is::<MetaElem>() {
+ Segment::Meta
+ } else {
+ bail!(child.span(), "unexpected paragraph child");
+ };
+
+ if let Some(last) = full.chars().last() {
+ quoter.last(last);
+ }
+
+ spans.push(segment.len(), child.span());
+
+ if let (Some((Segment::Text(last_len), last_styles)), Segment::Text(len)) =
+ (segments.last_mut(), segment)
+ {
+ if *last_styles == styles {
+ *last_len += len;
+ continue;
+ }
+ }
+
+ segments.push((segment, styles));
+ }
+
+ Ok((full, segments, spans))
+}
+
+/// Prepare paragraph layout by shaping the whole paragraph and layouting all
+/// contained inline-level content.
+fn prepare<'a>(
+ vt: &mut Vt,
+ children: &'a [Content],
+ text: &'a str,
+ segments: Vec<(Segment<'a>, StyleChain<'a>)>,
+ spans: SpanMapper,
+ styles: StyleChain<'a>,
+ region: Size,
+) -> SourceResult<Preparation<'a>> {
+ let bidi = BidiInfo::new(
+ text,
+ match TextElem::dir_in(styles) {
+ Dir::LTR => Some(BidiLevel::ltr()),
+ Dir::RTL => Some(BidiLevel::rtl()),
+ _ => None,
+ },
+ );
+
+ let mut cursor = 0;
+ let mut items = vec![];
+
+ // Shape / layout the children and collect them into items.
+ for (segment, styles) in segments {
+ let end = cursor + segment.len();
+ match segment {
+ Segment::Text(_) => {
+ shape_range(&mut items, vt, &bidi, cursor..end, &spans, styles);
+ }
+ Segment::Spacing(spacing) => match spacing {
+ Spacing::Rel(v) => {
+ let resolved = v.resolve(styles).relative_to(region.x);
+ items.push(Item::Absolute(resolved));
+ }
+ Spacing::Fr(v) => {
+ items.push(Item::Fractional(v, None));
+ }
+ },
+ Segment::Equation(equation) => {
+ let pod = Regions::one(region, Axes::splat(false));
+ let mut frame = equation.layout(vt, styles, pod)?.into_frame();
+ frame.translate(Point::with_y(TextElem::baseline_in(styles)));
+ items.push(Item::Frame(frame));
+ }
+ Segment::Box(elem, _) => {
+ if let Sizing::Fr(v) = elem.width(styles) {
+ items.push(Item::Fractional(v, Some((elem, styles))));
+ } else {
+ let pod = Regions::one(region, Axes::splat(false));
+ let mut frame = elem.layout(vt, styles, pod)?.into_frame();
+ frame.translate(Point::with_y(TextElem::baseline_in(styles)));
+ items.push(Item::Frame(frame));
+ }
+ }
+ Segment::Meta => {
+ let mut frame = Frame::new(Size::zero());
+ frame.meta(styles, true);
+ items.push(Item::Meta(frame));
+ }
+ }
+
+ cursor = end;
+ }
+
+ Ok(Preparation {
+ bidi,
+ items,
+ spans,
+ styles,
+ hyphenate: shared_get(styles, children, TextElem::hyphenate_in),
+ lang: shared_get(styles, children, TextElem::lang_in),
+ align: AlignElem::alignment_in(styles).x.resolve(styles),
+ justify: ParElem::justify_in(styles),
+ hang: ParElem::hanging_indent_in(styles),
+ })
+}
+
+/// Group a range of text by BiDi level and script, shape the runs and generate
+/// items for them.
+fn shape_range<'a>(
+ items: &mut Vec<Item<'a>>,
+ vt: &Vt,
+ bidi: &BidiInfo<'a>,
+ range: Range,
+ spans: &SpanMapper,
+ styles: StyleChain<'a>,
+) {
+ let lang = TextElem::lang_in(styles);
+ let region = TextElem::region_in(styles);
+ let mut process = |range: Range, level: BidiLevel| {
+ let dir = if level.is_ltr() { Dir::LTR } else { Dir::RTL };
+ let shaped =
+ shape(vt, range.start, &bidi.text[range], spans, styles, dir, lang, region);
+ items.push(Item::Text(shaped));
+ };
+
+ let mut prev_level = BidiLevel::ltr();
+ let mut prev_script = Script::Unknown;
+ let mut cursor = range.start;
+
+ // Group by embedding level and script.
+ for i in range.clone() {
+ if !bidi.text.is_char_boundary(i) {
+ continue;
+ }
+
+ let level = bidi.levels[i];
+ let script =
+ bidi.text[i..].chars().next().map_or(Script::Unknown, |c| c.script());
+
+ if level != prev_level || !is_compatible(script, prev_script) {
+ if cursor < i {
+ process(cursor..i, prev_level);
+ }
+ cursor = i;
+ prev_level = level;
+ prev_script = script;
+ } else if is_generic_script(prev_script) {
+ prev_script = script;
+ }
+ }
+
+ process(cursor..range.end, prev_level);
+}
+
+/// Whether this is not a specific script.
+fn is_generic_script(script: Script) -> bool {
+ matches!(script, Script::Unknown | Script::Common | Script::Inherited)
+}
+
+/// Whether these script can be part of the same shape run.
+fn is_compatible(a: Script, b: Script) -> bool {
+ is_generic_script(a) || is_generic_script(b) || a == b
+}
+
+/// Get a style property, but only if it is the same for all children of the
+/// paragraph.
+fn shared_get<T: PartialEq>(
+ styles: StyleChain<'_>,
+ children: &[Content],
+ getter: fn(StyleChain) -> T,
+) -> Option<T> {
+ let value = getter(styles);
+ children
+ .iter()
+ .filter_map(|child| child.to_styled())
+ .all(|(_, local)| getter(styles.chain(local)) == value)
+ .then_some(value)
+}
+
+/// Find suitable linebreaks.
+fn linebreak<'a>(vt: &Vt, p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> {
+ let linebreaks = ParElem::linebreaks_in(p.styles).unwrap_or_else(|| {
+ if ParElem::justify_in(p.styles) {
+ Linebreaks::Optimized
+ } else {
+ Linebreaks::Simple
+ }
+ });
+
+ match linebreaks {
+ Linebreaks::Simple => linebreak_simple(vt, p, width),
+ Linebreaks::Optimized => linebreak_optimized(vt, p, width),
+ }
+}
+
+/// Perform line breaking in simple first-fit style. This means that we build
+/// lines greedily, always taking the longest possible line. This may lead to
+/// very unbalanced line, but is fast and simple.
+fn linebreak_simple<'a>(vt: &Vt, p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> {
+ let mut lines = vec![];
+ let mut start = 0;
+ let mut last = None;
+
+ for (end, mandatory, hyphen) in breakpoints(p) {
+ // Compute the line and its size.
+ let mut attempt = line(vt, p, start..end, mandatory, hyphen);
+
+ // If the line doesn't fit anymore, we push the last fitting attempt
+ // into the stack and rebuild the line from the attempt's end. The
+ // resulting line cannot be broken up further.
+ if !width.fits(attempt.width) {
+ if let Some((last_attempt, last_end)) = last.take() {
+ lines.push(last_attempt);
+ start = last_end;
+ attempt = line(vt, p, start..end, mandatory, hyphen);
+ }
+ }
+
+ // Finish the current line if there is a mandatory line break (i.e.
+ // due to "\n") or if the line doesn't fit horizontally already
+ // since then no shorter line will be possible.
+ if mandatory || !width.fits(attempt.width) {
+ lines.push(attempt);
+ start = end;
+ last = None;
+ } else {
+ last = Some((attempt, end));
+ }
+ }
+
+ if let Some((line, _)) = last {
+ lines.push(line);
+ }
+
+ lines
+}
+
+/// Perform line breaking in optimized Knuth-Plass style. Here, we use more
+/// context to determine the line breaks than in the simple first-fit style. For
+/// example, we might choose to cut a line short even though there is still a
+/// bit of space to improve the fit of one of the following lines. The
+/// Knuth-Plass algorithm is based on the idea of "cost". A line which has a
+/// very tight or very loose fit has a higher cost than one that is just right.
+/// Ending a line with a hyphen incurs extra cost and endings two successive
+/// lines with hyphens even more.
+///
+/// To find the layout with the minimal total cost the algorithm uses dynamic
+/// programming: For each possible breakpoint it determines the optimal
+/// paragraph layout _up to that point_. It walks over all possible start points
+/// for a line ending at that point and finds the one for which the cost of the
+/// line plus the cost of the optimal paragraph up to the start point (already
+/// computed and stored in dynamic programming table) is minimal. The final
+/// result is simply the layout determined for the last breakpoint at the end of
+/// text.
+fn linebreak_optimized<'a>(vt: &Vt, p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> {
+ /// The cost of a line or paragraph layout.
+ type Cost = f64;
+
+ /// An entry in the dynamic programming table.
+ struct Entry<'a> {
+ pred: usize,
+ total: Cost,
+ line: Line<'a>,
+ }
+
+ // Cost parameters.
+ const HYPH_COST: Cost = 0.5;
+ const CONSECUTIVE_DASH_COST: Cost = 300.0;
+ const MAX_COST: Cost = 1_000_000.0;
+ const MIN_RATIO: f64 = -1.0;
+
+ // Dynamic programming table.
+ let mut active = 0;
+ let mut table = vec![Entry {
+ pred: 0,
+ total: 0.0,
+ line: line(vt, p, 0..0, false, false),
+ }];
+
+ let em = TextElem::size_in(p.styles);
+
+ for (end, mandatory, hyphen) in breakpoints(p) {
+ let k = table.len();
+ let eof = end == p.bidi.text.len();
+ let mut best: Option<Entry> = None;
+
+ // Find the optimal predecessor.
+ for (i, pred) in table.iter_mut().enumerate().skip(active) {
+ // Layout the line.
+ let start = pred.line.end;
+ let attempt = line(vt, p, start..end, mandatory, hyphen);
+
+ // Determine how much the line's spaces would need to be stretched
+ // to make it the desired width.
+ let delta = width - attempt.width;
+ // Determine how much stretch are permitted.
+ let adjust = if delta >= Abs::zero() {
+ attempt.stretchability()
+ } else {
+ attempt.shrinkability()
+ };
+ // Ideally, the ratio should between -1.0 and 1.0, but sometimes a value above 1.0
+ // is possible, in which case the line is underfull.
+ let mut ratio = delta / adjust;
+ if ratio.is_nan() {
+ // The line is not stretchable, but it just fits.
+ // This often happens with monospace fonts and CJK texts.
+ ratio = 0.0;
+ }
+ if ratio > 1.0 {
+ // We should stretch the line above its stretchability. Now calculate the extra amount.
+ let extra_stretch = (delta - adjust) / attempt.justifiables() as f64;
+ // Normalize the amount by half Em size.
+ ratio = 1.0 + extra_stretch / (em / 2.0);
+ }
+
+ // Determine the cost of the line.
+ let min_ratio = if p.justify { MIN_RATIO } else { 0.0 };
+ let mut cost = if ratio < min_ratio {
+ // The line is overfull. This is the case if
+ // - justification is on, but we'd need to shrink too much
+ // - justification is off and the line just doesn't fit
+ // Since any longer line will also be overfull, we can deactivate
+ // this breakpoint.
+ active = i + 1;
+ MAX_COST
+ } else if mandatory || eof {
+ // This is a mandatory break and the line is not overfull, so
+ // all breakpoints before this one become inactive since no line
+ // can span above the mandatory break.
+ active = k;
+ // If ratio > 0, we need to stretch the line only when justify is needed.
+ // If ratio < 0, we always need to shrink the line.
+ if (ratio > 0.0 && attempt.justify) || ratio < 0.0 {
+ ratio.powi(3).abs()
+ } else {
+ 0.0
+ }
+ } else {
+ // Normal line with cost of |ratio^3|.
+ ratio.powi(3).abs()
+ };
+
+ // Penalize hyphens.
+ if hyphen {
+ cost += HYPH_COST;
+ }
+
+ // In Knuth paper, cost = (1 + 100|r|^3 + p)^2 + a,
+ // where r is the ratio, p=50 is penaty, and a=3000 is consecutive penaty.
+ // We divide the whole formula by 10, resulting (0.01 + |r|^3 + p)^2 + a,
+ // where p=0.5 and a=300
+ cost = (0.01 + cost).powi(2);
+
+ // Penalize two consecutive dashes (not necessarily hyphens) extra.
+ if attempt.dash && pred.line.dash {
+ cost += CONSECUTIVE_DASH_COST;
+ }
+
+ // The total cost of this line and its chain of predecessors.
+ let total = pred.total + cost;
+
+ // If this attempt is better than what we had before, take it!
+ if best.as_ref().map_or(true, |best| best.total >= total) {
+ best = Some(Entry { pred: i, total, line: attempt });
+ }
+ }
+
+ table.push(best.unwrap());
+ }
+
+ // Retrace the best path.
+ let mut lines = vec![];
+ let mut idx = table.len() - 1;
+ while idx != 0 {
+ table.truncate(idx + 1);
+ let entry = table.pop().unwrap();
+ lines.push(entry.line);
+ idx = entry.pred;
+ }
+
+ lines.reverse();
+ lines
+}
+
+/// Generated by the following command:
+///
+/// ```sh
+/// icu4x-datagen --locales full --keys-for-bin target/debug/typst \
+/// --format blob --out library/assets/icudata.postcard --overwrite
+/// ```
+///
+/// Install icu4x-datagen with `cargo install icu4x-datagen`.
+static ICU_DATA: &[u8] = include_bytes!("../../assets/icudata.postcard");
+
+/// Generated by the following command:
+///
+/// ```sh
+/// icu4x-datagen --locales zh ja --keys segmenter/line@1 --format blob \
+/// --out library/assets/cj_linebreak_data.postcard --overwrite
+/// ```
+///
+/// The used icu4x-datagen should be patched by
+/// https://github.com/peng1999/icu4x/commit/b9beb6cbf633d61fc3d7983e5baf7f4449fbfae5
+static CJ_LINEBREAK_DATA: &[u8] =
+ include_bytes!("../../assets/cj_linebreak_data.postcard");
+
+/// The general line break segmenter.
+static SEGMENTER: Lazy<LineSegmenter> = Lazy::new(|| {
+ let provider = BlobDataProvider::try_new_from_static_blob(ICU_DATA).unwrap();
+ LineSegmenter::try_new_lstm_with_buffer_provider(&provider).unwrap()
+});
+
+/// The Unicode line break properties for each code point.
+static CJ_SEGMENTER: Lazy<LineSegmenter> = Lazy::new(|| {
+ let provider = BlobDataProvider::try_new_from_static_blob(ICU_DATA).unwrap();
+ let cj_blob = BlobDataProvider::try_new_from_static_blob(CJ_LINEBREAK_DATA).unwrap();
+ let cj_provider = ForkByKeyProvider::new(cj_blob, provider);
+ LineSegmenter::try_new_lstm_with_buffer_provider(&cj_provider).unwrap()
+});
+
+/// The line break segmenter for Chinese/Jpanese text.
+static LINEBREAK_DATA: Lazy<CodePointMapData<LineBreak>> = Lazy::new(|| {
+ let provider = BlobDataProvider::try_new_from_static_blob(ICU_DATA).unwrap();
+ let deser_provider = provider.as_deserializing();
+ icu_properties::maps::load_line_break(&deser_provider).unwrap()
+});
+
+/// Determine all possible points in the text where lines can broken.
+///
+/// Returns for each breakpoint the text index, whether the break is mandatory
+/// (after `\n`) and whether a hyphen is required (when breaking inside of a
+/// word).
+fn breakpoints<'a>(p: &'a Preparation<'a>) -> Breakpoints<'a> {
+ let mut linebreaks = if matches!(p.lang, Some(Lang::CHINESE | Lang::JAPANESE)) {
+ CJ_SEGMENTER.segment_str(p.bidi.text)
+ } else {
+ SEGMENTER.segment_str(p.bidi.text)
+ };
+ // The iterator always yields a breakpoint at index 0, we want to ignore it
+ linebreaks.next();
+ Breakpoints {
+ p,
+ linebreaks,
+ syllables: None,
+ offset: 0,
+ suffix: 0,
+ end: 0,
+ mandatory: false,
+ }
+}
+
+/// An iterator over the line break opportunities in a text.
+struct Breakpoints<'a> {
+ /// The paragraph's items.
+ p: &'a Preparation<'a>,
+ /// The inner iterator over the unicode line break opportunities.
+ linebreaks: LineBreakIteratorUtf8<'a, 'a>,
+ /// Iterator over syllables of the current word.
+ syllables: Option<hypher::Syllables<'a>>,
+ /// The current text offset.
+ offset: usize,
+ /// The trimmed end of the current word.
+ suffix: usize,
+ /// The untrimmed end of the current word.
+ end: usize,
+ /// Whether the break after the current word is mandatory.
+ mandatory: bool,
+}
+
+impl Iterator for Breakpoints<'_> {
+ type Item = (usize, bool, bool);
+
+ fn next(&mut self) -> Option<Self::Item> {
+ // If we're currently in a hyphenated "word", process the next syllable.
+ if let Some(syllable) = self.syllables.as_mut().and_then(Iterator::next) {
+ self.offset += syllable.len();
+ if self.offset == self.suffix {
+ self.offset = self.end;
+ }
+
+ // Filter out hyphenation opportunities where hyphenation was
+ // actually disabled.
+ let hyphen = self.offset < self.end;
+ if hyphen && !self.hyphenate(self.offset) {
+ return self.next();
+ }
+
+ return Some((self.offset, self.mandatory && !hyphen, hyphen));
+ }
+
+ let lb = LINEBREAK_DATA.as_borrowed();
+
+ // Get the next "word".
+ self.end = self.linebreaks.next()?;
+ self.mandatory =
+ self.p.bidi.text[..self.end].chars().next_back().map_or(false, |c| {
+ matches!(
+ lb.get(c),
+ LineBreak::MandatoryBreak
+ | LineBreak::CarriageReturn
+ | LineBreak::LineFeed
+ | LineBreak::NextLine
+ ) || self.end == self.p.bidi.text.len()
+ });
+
+ // Hyphenate the next word.
+ if self.p.hyphenate != Some(false) {
+ if let Some(lang) = self.lang(self.offset) {
+ let word = &self.p.bidi.text[self.offset..self.end];
+ let trimmed = word.trim_end_matches(|c: char| !c.is_alphabetic());
+ if !trimmed.is_empty() {
+ self.suffix = self.offset + trimmed.len();
+ self.syllables = Some(hypher::hyphenate(trimmed, lang));
+ return self.next();
+ }
+ }
+ }
+
+ self.offset = self.end;
+ Some((self.end, self.mandatory, false))
+ }
+}
+
+impl Breakpoints<'_> {
+ /// Whether hyphenation is enabled at the given offset.
+ fn hyphenate(&self, offset: usize) -> bool {
+ self.p
+ .hyphenate
+ .or_else(|| {
+ let shaped = self.p.find(offset)?.text()?;
+ Some(TextElem::hyphenate_in(shaped.styles))
+ })
+ .unwrap_or(false)
+ }
+
+ /// The text language at the given offset.
+ fn lang(&self, offset: usize) -> Option<hypher::Lang> {
+ let lang = self.p.lang.or_else(|| {
+ let shaped = self.p.find(offset)?.text()?;
+ Some(TextElem::lang_in(shaped.styles))
+ })?;
+
+ let bytes = lang.as_str().as_bytes().try_into().ok()?;
+ hypher::Lang::from_iso(bytes)
+ }
+}
+
+/// Create a line which spans the given range.
+fn line<'a>(
+ vt: &Vt,
+ p: &'a Preparation,
+ mut range: Range,
+ mandatory: bool,
+ hyphen: bool,
+) -> Line<'a> {
+ let end = range.end;
+ let mut justify = p.justify && end < p.bidi.text.len() && !mandatory;
+
+ if range.is_empty() {
+ return Line {
+ bidi: &p.bidi,
+ end,
+ trimmed: range,
+ first: None,
+ inner: &[],
+ last: None,
+ width: Abs::zero(),
+ justify,
+ dash: false,
+ };
+ }
+
+ // Slice out the relevant items.
+ let (expanded, mut inner) = p.slice(range.clone());
+ let mut width = Abs::zero();
+
+ // Reshape the last item if it's split in half or hyphenated.
+ let mut last = None;
+ let mut dash = false;
+ if let Some((Item::Text(shaped), before)) = inner.split_last() {
+ // Compute the range we want to shape, trimming whitespace at the
+ // end of the line.
+ let base = expanded.end - shaped.text.len();
+ let start = range.start.max(base);
+ let text = &p.bidi.text[start..range.end];
+ // U+200B ZERO WIDTH SPACE is used to provide a line break opportunity,
+ // we want to trim it too.
+ let trimmed = text.trim_end().trim_end_matches('\u{200B}');
+ range.end = start + trimmed.len();
+
+ // Deal with hyphens, dashes and justification.
+ let shy = trimmed.ends_with('\u{ad}');
+ dash = hyphen || shy || trimmed.ends_with(['-', '–', '—']);
+ justify |= text.ends_with('\u{2028}');
+
+ // Deal with CJK punctuation at line ends.
+ let gb_style = is_gb_style(shaped.lang, shaped.region);
+ let end_cjk_punct = trimmed
+ .ends_with(['”', '’', ',', '。', '、', ':', ';', '》', ')', '』', '」']);
+
+ // Usually, we don't want to shape an empty string because:
+ // - We don't want the height of trimmed whitespace in a different
+ // font to be considered for the line height.
+ // - Even if it's in the same font, its unnecessary.
+ //
+ // There is one exception though. When the whole line is empty, we
+ // need the shaped empty string to make the line the appropriate
+ // height. That is the case exactly if the string is empty and there
+ // are no other items in the line.
+ if hyphen || start + shaped.text.len() > range.end || end_cjk_punct {
+ if hyphen || start < range.end || before.is_empty() {
+ let mut reshaped = shaped.reshape(vt, &p.spans, start..range.end);
+ if hyphen || shy {
+ reshaped.push_hyphen(vt);
+ }
+ let punct = reshaped.glyphs.last();
+ if let Some(punct) = punct {
+ if punct.is_cjk_left_aligned_punctuation(gb_style) {
+ let shrink_amount = punct.shrinkability().1;
+ let punct = reshaped.glyphs.to_mut().last_mut().unwrap();
+ punct.shrink_right(shrink_amount);
+ reshaped.width -= shrink_amount.at(reshaped.size);
+ }
+ }
+ width += reshaped.width;
+ last = Some(Item::Text(reshaped));
+ }
+
+ inner = before;
+ }
+ }
+
+ // Deal with CJK punctuation at line starts.
+ let text = &p.bidi.text[range.start..end];
+ let start_cjk_punct = text.starts_with(['“', '‘', '《', '(', '『', '「']);
+
+ // Reshape the start item if it's split in half.
+ let mut first = None;
+ if let Some((Item::Text(shaped), after)) = inner.split_first() {
+ // Compute the range we want to shape.
+ let base = expanded.start;
+ let end = range.end.min(base + shaped.text.len());
+
+ // Reshape if necessary.
+ if range.start + shaped.text.len() > end || start_cjk_punct {
+ if range.start < end || start_cjk_punct {
+ let reshaped = shaped.reshape(vt, &p.spans, range.start..end);
+ width += reshaped.width;
+ first = Some(Item::Text(reshaped));
+ }
+
+ inner = after;
+ }
+ }
+
+ if start_cjk_punct {
+ let reshaped = first.as_mut().or(last.as_mut()).and_then(Item::text_mut);
+ if let Some(reshaped) = reshaped {
+ if let Some(punct) = reshaped.glyphs.first() {
+ if punct.is_cjk_right_aligned_punctuation() {
+ let shrink_amount = punct.shrinkability().0;
+ let punct = reshaped.glyphs.to_mut().first_mut().unwrap();
+ punct.shrink_left(shrink_amount);
+ let amount_abs = shrink_amount.at(reshaped.size);
+ reshaped.width -= amount_abs;
+ width -= amount_abs;
+ }
+ }
+ }
+ }
+
+ // Measure the inner items.
+ for item in inner {
+ width += item.width();
+ }
+
+ Line {
+ bidi: &p.bidi,
+ trimmed: range,
+ end,
+ first,
+ inner,
+ last,
+ width,
+ justify,
+ dash,
+ }
+}
+
+/// Combine layouted lines into one frame per region.
+fn finalize(
+ vt: &mut Vt,
+ p: &Preparation,
+ lines: &[Line],
+ region: Size,
+ expand: bool,
+) -> SourceResult<Fragment> {
+ // Determine the paragraph's width: Full width of the region if we
+ // should expand or there's fractional spacing, fit-to-width otherwise.
+ let width = if !region.x.is_finite()
+ || (!expand && lines.iter().all(|line| line.fr().is_zero()))
+ {
+ p.hang + lines.iter().map(|line| line.width).max().unwrap_or_default()
+ } else {
+ region.x
+ };
+
+ // Stack the lines into one frame per region.
+ let mut frames: Vec<Frame> = lines
+ .iter()
+ .map(|line| commit(vt, p, line, width, region.y))
+ .collect::<SourceResult<_>>()?;
+
+ // Prevent orphans.
+ let leading = ParElem::leading_in(p.styles);
+ if frames.len() >= 2 && !frames[1].is_empty() {
+ let second = frames.remove(1);
+ let first = &mut frames[0];
+ merge(first, second, leading);
+ }
+
+ // Prevent widows.
+ let len = frames.len();
+ if len >= 2 && !frames[len - 2].is_empty() {
+ let second = frames.pop().unwrap();
+ let first = frames.last_mut().unwrap();
+ merge(first, second, leading);
+ }
+
+ Ok(Fragment::frames(frames))
+}
+
+/// Merge two line frames
+fn merge(first: &mut Frame, second: Frame, leading: Abs) {
+ let offset = first.height() + leading;
+ let total = offset + second.height();
+ first.push_frame(Point::with_y(offset), second);
+ first.size_mut().y = total;
+}
+
+/// Commit to a line and build its frame.
+fn commit(
+ vt: &mut Vt,
+ p: &Preparation,
+ line: &Line,
+ width: Abs,
+ full: Abs,
+) -> SourceResult<Frame> {
+ let mut remaining = width - line.width - p.hang;
+ let mut offset = Abs::zero();
+
+ // Reorder the line from logical to visual order.
+ let (reordered, starts_rtl) = reorder(line);
+ if !starts_rtl {
+ offset += p.hang;
+ }
+
+ // Handle hanging punctuation to the left.
+ if let Some(Item::Text(text)) = reordered.first() {
+ if let Some(glyph) = text.glyphs.first() {
+ if !text.dir.is_positive()
+ && TextElem::overhang_in(text.styles)
+ && (reordered.len() > 1 || text.glyphs.len() > 1)
+ {
+ let amount = overhang(glyph.c) * glyph.x_advance.at(text.size);
+ offset -= amount;
+ remaining += amount;
+ }
+ }
+ }
+
+ // Handle hanging punctuation to the right.
+ if let Some(Item::Text(text)) = reordered.last() {
+ if let Some(glyph) = text.glyphs.last() {
+ if text.dir.is_positive()
+ && TextElem::overhang_in(text.styles)
+ && (reordered.len() > 1 || text.glyphs.len() > 1)
+ {
+ let amount = overhang(glyph.c) * glyph.x_advance.at(text.size);
+ remaining += amount;
+ }
+ }
+ }
+
+ // Determine how much additional space is needed.
+ // The justicication_ratio is for the first step justification,
+ // extra_justification is for the last step.
+ // For more info on multi-step justification, see Procedures for Inter-
+ // Character Space Expansion in W3C document Chinese Layout Requirements.
+ let fr = line.fr();
+ let mut justification_ratio = 0.0;
+ let mut extra_justification = Abs::zero();
+
+ let shrink = line.shrinkability();
+ let stretch = line.stretchability();
+ if remaining < Abs::zero() && shrink > Abs::zero() {
+ // Attempt to reduce the length of the line, using shrinkability.
+ justification_ratio = (remaining / shrink).max(-1.0);
+ remaining = (remaining + shrink).min(Abs::zero());
+ } else if line.justify && fr.is_zero() {
+ // Attempt to increase the length of the line, using stretchability.
+ if stretch > Abs::zero() {
+ justification_ratio = (remaining / stretch).min(1.0);
+ remaining = (remaining - stretch).max(Abs::zero());
+ }
+
+ let justifiables = line.justifiables();
+ if justifiables > 0 && remaining > Abs::zero() {
+ // Underfull line, distribute the extra space.
+ extra_justification = remaining / justifiables as f64;
+ remaining = Abs::zero();
+ }
+ }
+
+ let mut top = Abs::zero();
+ let mut bottom = Abs::zero();
+
+ // Build the frames and determine the height and baseline.
+ let mut frames = vec![];
+ for item in reordered {
+ let mut push = |offset: &mut Abs, frame: Frame| {
+ let width = frame.width();
+ top.set_max(frame.baseline());
+ bottom.set_max(frame.size().y - frame.baseline());
+ frames.push((*offset, frame));
+ *offset += width;
+ };
+
+ match item {
+ Item::Absolute(v) => {
+ offset += *v;
+ }
+ Item::Fractional(v, elem) => {
+ let amount = v.share(fr, remaining);
+ if let Some((elem, styles)) = elem {
+ let region = Size::new(amount, full);
+ let pod = Regions::one(region, Axes::new(true, false));
+ let mut frame = elem.layout(vt, *styles, pod)?.into_frame();
+ frame.translate(Point::with_y(TextElem::baseline_in(*styles)));
+ push(&mut offset, frame);
+ } else {
+ offset += amount;
+ }
+ }
+ Item::Text(shaped) => {
+ let frame = shaped.build(vt, justification_ratio, extra_justification);
+ push(&mut offset, frame);
+ }
+ Item::Frame(frame) | Item::Meta(frame) => {
+ push(&mut offset, frame.clone());
+ }
+ }
+ }
+
+ // Remaining space is distributed now.
+ if !fr.is_zero() {
+ remaining = Abs::zero();
+ }
+
+ let size = Size::new(width, top + bottom);
+ let mut output = Frame::new(size);
+ output.set_baseline(top);
+
+ // Construct the line's frame.
+ for (offset, frame) in frames {
+ let x = offset + p.align.position(remaining);
+ let y = top - frame.baseline();
+ output.push_frame(Point::new(x, y), frame);
+ }
+
+ Ok(output)
+}
+
+/// Return a line's items in visual order.
+fn reorder<'a>(line: &'a Line<'a>) -> (Vec<&Item<'a>>, bool) {
+ let mut reordered = vec![];
+
+ // The bidi crate doesn't like empty lines.
+ if line.trimmed.is_empty() {
+ return (line.slice(line.trimmed.clone()).collect(), false);
+ }
+
+ // Find the paragraph that contains the line.
+ let para = line
+ .bidi
+ .paragraphs
+ .iter()
+ .find(|para| para.range.contains(&line.trimmed.start))
+ .unwrap();
+
+ // Compute the reordered ranges in visual order (left to right).
+ let (levels, runs) = line.bidi.visual_runs(para, line.trimmed.clone());
+ let starts_rtl = levels.first().map_or(false, |level| level.is_rtl());
+
+ // Collect the reordered items.
+ for run in runs {
+ // Skip reset L1 runs because handling them would require reshaping
+ // again in some cases.
+ if line.bidi.levels[run.start] != levels[run.start] {
+ continue;
+ }
+
+ let prev = reordered.len();
+ reordered.extend(line.slice(run.clone()));
+
+ if levels[run.start].is_rtl() {
+ reordered[prev..].reverse();
+ }
+ }
+
+ (reordered, starts_rtl)
+}
+
+/// How much a character should hang into the end margin.
+///
+/// For more discussion, see:
+/// https://recoveringphysicist.com/21/
+fn overhang(c: char) -> f64 {
+ match c {
+ // Dashes.
+ '–' | '—' => 0.2,
+ '-' => 0.55,
+
+ // Punctuation.
+ '.' | ',' => 0.8,
+ ':' | ';' => 0.3,
+
+ // Arabic
+ '\u{60C}' | '\u{6D4}' => 0.4,
+
+ _ => 0.0,
+ }
+}
diff --git a/crates/typst-library/src/layout/place.rs b/crates/typst-library/src/layout/place.rs
new file mode 100644
index 00000000..6602948c
--- /dev/null
+++ b/crates/typst-library/src/layout/place.rs
@@ -0,0 +1,103 @@
+use crate::prelude::*;
+
+/// Places content at an absolute position.
+///
+/// Placed content will not affect the position of other content. Place is
+/// always relative to its parent container and will be in the foreground of all
+/// other content in the container. Page margins will be respected.
+///
+///
+/// ## Example { #example }
+/// ```example
+/// #set page(height: 60pt)
+/// Hello, world!
+///
+/// #place(
+/// top + right,
+/// square(
+/// width: 20pt,
+/// stroke: 2pt + blue
+/// ),
+/// )
+/// ```
+///
+/// Display: Place
+/// Category: layout
+#[element(Layout, Behave)]
+pub struct PlaceElem {
+ /// Relative to which position in the parent container to place the content.
+ ///
+ /// When an axis of the page is `{auto}` sized, all alignments relative to that
+ /// axis will be ignored, instead, the item will be placed in the origin of the
+ /// axis.
+ #[positional]
+ #[default(Axes::with_x(Some(GenAlign::Start)))]
+ pub alignment: Axes<Option<GenAlign>>,
+
+ /// The horizontal displacement of the placed content.
+ ///
+ /// ```example
+ /// #set page(height: 100pt)
+ /// #for i in range(16) {
+ /// let amount = i * 4pt
+ /// place(center, dx: amount - 32pt, dy: amount)[A]
+ /// }
+ /// ```
+ pub dx: Rel<Length>,
+
+ /// The vertical displacement of the placed content.
+ pub dy: Rel<Length>,
+
+ /// The content to place.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for PlaceElem {
+ #[tracing::instrument(name = "PlaceElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let out_of_flow = self.out_of_flow(styles);
+
+ // The pod is the base area of the region because for absolute
+ // placement we don't really care about the already used area.
+ let pod = {
+ let finite = regions.base().map(Abs::is_finite);
+ let expand = finite & (regions.expand | out_of_flow);
+ Regions::one(regions.base(), expand)
+ };
+
+ let child = self
+ .body()
+ .moved(Axes::new(self.dx(styles), self.dy(styles)))
+ .aligned(self.alignment(styles));
+
+ let mut frame = child.layout(vt, styles, pod)?.into_frame();
+
+ // If expansion is off, zero all sizes so that we don't take up any
+ // space in our parent. Otherwise, respect the expand settings.
+ let target = regions.expand.select(regions.size, Size::zero());
+ frame.resize(target, Align::LEFT_TOP);
+
+ Ok(Fragment::frame(frame))
+ }
+}
+
+impl PlaceElem {
+ /// Whether this element wants to be placed relative to its its parent's
+ /// base origin. Instead of relative to the parent's current flow/cursor
+ /// position.
+ pub fn out_of_flow(&self, styles: StyleChain) -> bool {
+ self.alignment(styles).y.is_some()
+ }
+}
+
+impl Behave for PlaceElem {
+ fn behaviour(&self) -> Behaviour {
+ Behaviour::Ignorant
+ }
+}
diff --git a/crates/typst-library/src/layout/regions.rs b/crates/typst-library/src/layout/regions.rs
new file mode 100644
index 00000000..6dd549b0
--- /dev/null
+++ b/crates/typst-library/src/layout/regions.rs
@@ -0,0 +1,144 @@
+use std::fmt::{self, Debug, Formatter};
+
+use typst::geom::{Abs, Axes, Size};
+
+/// A sequence of regions to layout into.
+#[derive(Copy, Clone, Hash)]
+pub struct Regions<'a> {
+ /// The remaining size of the first region.
+ pub size: Size,
+ /// The full height of the region for relative sizing.
+ pub full: Abs,
+ /// The height of followup regions. The width is the same for all regions.
+ pub backlog: &'a [Abs],
+ /// The height of the final region that is repeated once the backlog is
+ /// drained. The width is the same for all regions.
+ pub last: Option<Abs>,
+ /// Whether elements should expand to fill the regions instead of shrinking
+ /// to fit the content.
+ pub expand: Axes<bool>,
+ /// Whether these are the root regions or direct descendants.
+ ///
+ /// True for the padded page regions and columns directly in the page,
+ /// false otherwise.
+ pub root: bool,
+}
+
+impl Regions<'_> {
+ /// Create a new region sequence with exactly one region.
+ pub fn one(size: Size, expand: Axes<bool>) -> Self {
+ Self {
+ size,
+ full: size.y,
+ backlog: &[],
+ last: None,
+ expand,
+ root: false,
+ }
+ }
+
+ /// Create a new sequence of same-size regions that repeats indefinitely.
+ pub fn repeat(size: Size, expand: Axes<bool>) -> Self {
+ Self {
+ size,
+ full: size.y,
+ backlog: &[],
+ last: Some(size.y),
+ expand,
+ root: false,
+ }
+ }
+
+ /// The base size, which doesn't take into account that the regions is
+ /// already partially used up.
+ ///
+ /// This is also used for relative sizing.
+ pub fn base(&self) -> Size {
+ Size::new(self.size.x, self.full)
+ }
+
+ /// Create new regions where all sizes are mapped with `f`.
+ ///
+ /// Note that since all regions must have the same width, the width returned
+ /// by `f` is ignored for the backlog and the final region.
+ pub fn map<'v, F>(&self, backlog: &'v mut Vec<Abs>, mut f: F) -> Regions<'v>
+ where
+ F: FnMut(Size) -> Size,
+ {
+ let x = self.size.x;
+ backlog.clear();
+ backlog.extend(self.backlog.iter().map(|&y| f(Size::new(x, y)).y));
+ Regions {
+ size: f(self.size),
+ full: f(Size::new(x, self.full)).y,
+ backlog,
+ last: self.last.map(|y| f(Size::new(x, y)).y),
+ expand: self.expand,
+ root: false,
+ }
+ }
+
+ /// Whether the first region is full and a region break is called for.
+ pub fn is_full(&self) -> bool {
+ Abs::zero().fits(self.size.y) && !self.in_last()
+ }
+
+ /// Whether the first region is the last usable region.
+ ///
+ /// If this is true, calling `next()` will have no effect.
+ pub fn in_last(&self) -> bool {
+ self.backlog.is_empty() && self.last.map_or(true, |height| self.size.y == height)
+ }
+
+ /// The same regions, but with different `root` configuration.
+ pub fn with_root(self, root: bool) -> Self {
+ Self { root, ..self }
+ }
+
+ /// Advance to the next region if there is any.
+ pub fn next(&mut self) {
+ if let Some(height) = self
+ .backlog
+ .split_first()
+ .map(|(first, tail)| {
+ self.backlog = tail;
+ *first
+ })
+ .or(self.last)
+ {
+ self.size.y = height;
+ self.full = height;
+ }
+ }
+
+ /// An iterator that returns the sizes of the first and all following
+ /// regions, equivalently to what would be produced by calling
+ /// [`next()`](Self::next) repeatedly until all regions are exhausted.
+ /// This iterator may be infinite.
+ pub fn iter(&self) -> impl Iterator<Item = Size> + '_ {
+ let first = std::iter::once(self.size);
+ let backlog = self.backlog.iter();
+ let last = self.last.iter().cycle();
+ first.chain(backlog.chain(last).map(|&h| Size::new(self.size.x, h)))
+ }
+}
+
+impl Debug for Regions<'_> {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ f.write_str("Regions ")?;
+ let mut list = f.debug_list();
+ let mut prev = self.size.y;
+ list.entry(&self.size);
+ for &height in self.backlog {
+ list.entry(&Size::new(self.size.x, height));
+ prev = height;
+ }
+ if let Some(last) = self.last {
+ if last != prev {
+ list.entry(&Size::new(self.size.x, last));
+ }
+ list.entry(&(..));
+ }
+ list.finish()
+ }
+}
diff --git a/crates/typst-library/src/layout/repeat.rs b/crates/typst-library/src/layout/repeat.rs
new file mode 100644
index 00000000..646eb991
--- /dev/null
+++ b/crates/typst-library/src/layout/repeat.rs
@@ -0,0 +1,79 @@
+use crate::prelude::*;
+
+use super::AlignElem;
+
+/// Repeats content to the available space.
+///
+/// This can be useful when implementing a custom index, reference, or outline.
+///
+/// Space may be inserted between the instances of the body parameter, so be
+/// sure to include negative space if you need the instances to overlap.
+///
+/// Errors if there no bounds on the available space, as it would create
+/// infinite content.
+///
+/// ## Example { #example }
+/// ```example
+/// Sign on the dotted line:
+/// #box(width: 1fr, repeat[.])
+///
+/// #set text(10pt)
+/// #v(8pt, weak: true)
+/// #align(right)[
+/// Berlin, the 22nd of December, 2022
+/// ]
+/// ```
+///
+/// Display: Repeat
+/// Category: layout
+#[element(Layout)]
+pub struct RepeatElem {
+ /// The content to repeat.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for RepeatElem {
+ #[tracing::instrument(name = "RepeatElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let pod = Regions::one(regions.size, Axes::new(false, false));
+ let piece = self.body().layout(vt, styles, pod)?.into_frame();
+ let align = AlignElem::alignment_in(styles).x.resolve(styles);
+
+ let fill = regions.size.x;
+ let width = piece.width();
+ let count = (fill / width).floor();
+ let remaining = fill % width;
+ let apart = remaining / (count - 1.0);
+
+ let size = Size::new(regions.size.x, piece.height());
+
+ if !size.is_finite() {
+ bail!(self.span(), "repeat with no size restrictions");
+ }
+
+ let mut frame = Frame::new(size);
+ if piece.has_baseline() {
+ frame.set_baseline(piece.baseline());
+ }
+
+ let mut offset = Abs::zero();
+ if count == 1.0 {
+ offset += align.position(remaining);
+ }
+
+ if width > Abs::zero() {
+ for _ in 0..(count as usize).min(1000) {
+ frame.push_frame(Point::with_x(offset), piece.clone());
+ offset += piece.width() + apart;
+ }
+ }
+
+ Ok(Fragment::frame(frame))
+ }
+}
diff --git a/crates/typst-library/src/layout/spacing.rs b/crates/typst-library/src/layout/spacing.rs
new file mode 100644
index 00000000..e7dc24fb
--- /dev/null
+++ b/crates/typst-library/src/layout/spacing.rs
@@ -0,0 +1,240 @@
+use std::cmp::Ordering;
+
+use crate::prelude::*;
+
+/// Inserts horizontal spacing into a paragraph.
+///
+/// The spacing can be absolute, relative, or fractional. In the last case, the
+/// remaining space on the line is distributed among all fractional spacings
+/// according to their relative fractions.
+///
+/// ## Example { #example }
+/// ```example
+/// First #h(1cm) Second \
+/// First #h(30%) Second \
+/// First #h(2fr) Second #h(1fr) Third
+/// ```
+///
+/// ## Mathematical Spacing { #math-spacing }
+/// In [mathematical formulas]($category/math), you can additionally use these
+/// constants to add spacing between elements: `thin`, `med`, `thick`, `quad`.
+///
+/// Display: Spacing (H)
+/// Category: layout
+#[element(Behave)]
+pub struct HElem {
+ /// How much spacing to insert.
+ #[required]
+ pub amount: Spacing,
+
+ /// If `{true}`, the spacing collapses at the start or end of a paragraph.
+ /// Moreover, from multiple adjacent weak spacings all but the largest one
+ /// collapse.
+ ///
+ /// ```example
+ /// #h(1cm, weak: true)
+ /// We identified a group of
+ /// _weak_ specimens that fail to
+ /// manifest in most cases. However,
+ /// when #h(8pt, weak: true)
+ /// supported
+ /// #h(8pt, weak: true) on both
+ /// sides, they do show up.
+ /// ```
+ #[default(false)]
+ pub weak: bool,
+}
+
+impl Behave for HElem {
+ fn behaviour(&self) -> Behaviour {
+ if self.amount().is_fractional() {
+ Behaviour::Destructive
+ } else if self.weak(StyleChain::default()) {
+ Behaviour::Weak(1)
+ } else {
+ Behaviour::Ignorant
+ }
+ }
+
+ fn larger(&self, prev: &Content) -> bool {
+ let Some(prev) = prev.to::<Self>() else { return false };
+ self.amount() > prev.amount()
+ }
+}
+
+/// Inserts vertical spacing into a flow of blocks.
+///
+/// The spacing can be absolute, relative, or fractional. In the last case,
+/// the remaining space on the page is distributed among all fractional spacings
+/// according to their relative fractions.
+///
+/// ## Example { #example }
+/// ```example
+/// #grid(
+/// rows: 3cm,
+/// columns: 6,
+/// gutter: 1fr,
+/// [A #parbreak() B],
+/// [A #v(0pt) B],
+/// [A #v(10pt) B],
+/// [A #v(0pt, weak: true) B],
+/// [A #v(40%, weak: true) B],
+/// [A #v(1fr) B],
+/// )
+/// ```
+///
+/// Display: Spacing (V)
+/// Category: layout
+#[element(Behave)]
+pub struct VElem {
+ /// How much spacing to insert.
+ #[required]
+ pub amount: Spacing,
+
+ /// If `{true}`, the spacing collapses at the start or end of a flow.
+ /// Moreover, from multiple adjacent weak spacings all but the largest one
+ /// collapse. Weak spacings will always collapse adjacent paragraph spacing,
+ /// even if the paragraph spacing is larger.
+ ///
+ /// ```example
+ /// The following theorem is
+ /// foundational to the field:
+ /// #v(4pt, weak: true)
+ /// $ x^2 + y^2 = r^2 $
+ /// #v(4pt, weak: true)
+ /// The proof is simple:
+ /// ```
+ #[external]
+ pub weak: bool,
+
+ /// The element's weakness level, see also [`Behaviour`].
+ #[internal]
+ #[parse(args.named("weak")?.map(|v: bool| v as usize))]
+ pub weakness: usize,
+}
+
+impl VElem {
+ /// Normal strong spacing.
+ pub fn strong(amount: Spacing) -> Self {
+ Self::new(amount).with_weakness(0)
+ }
+
+ /// User-created weak spacing.
+ pub fn weak(amount: Spacing) -> Self {
+ Self::new(amount).with_weakness(1)
+ }
+
+ /// Weak spacing with list attach weakness.
+ pub fn list_attach(amount: Spacing) -> Self {
+ Self::new(amount).with_weakness(2)
+ }
+
+ /// Weak spacing with BlockElem::ABOVE/BELOW weakness.
+ pub fn block_around(amount: Spacing) -> Self {
+ Self::new(amount).with_weakness(3)
+ }
+
+ /// Weak spacing with BlockElem::SPACING weakness.
+ pub fn block_spacing(amount: Spacing) -> Self {
+ Self::new(amount).with_weakness(4)
+ }
+}
+
+impl Behave for VElem {
+ fn behaviour(&self) -> Behaviour {
+ if self.amount().is_fractional() {
+ Behaviour::Destructive
+ } else if self.weakness(StyleChain::default()) > 0 {
+ Behaviour::Weak(self.weakness(StyleChain::default()))
+ } else {
+ Behaviour::Ignorant
+ }
+ }
+
+ fn larger(&self, prev: &Content) -> bool {
+ let Some(prev) = prev.to::<Self>() else { return false };
+ self.amount() > prev.amount()
+ }
+}
+
+cast! {
+ VElem,
+ v: Content => v.to::<Self>().cloned().ok_or("expected `v` element")?,
+}
+
+/// Kinds of spacing.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Spacing {
+ /// Spacing specified in absolute terms and relative to the parent's size.
+ Rel(Rel<Length>),
+ /// Spacing specified as a fraction of the remaining free space in the
+ /// parent.
+ Fr(Fr),
+}
+
+impl Spacing {
+ /// Whether this is fractional spacing.
+ pub fn is_fractional(self) -> bool {
+ matches!(self, Self::Fr(_))
+ }
+
+ /// Whether the spacing is actually no spacing.
+ pub fn is_zero(&self) -> bool {
+ match self {
+ Self::Rel(rel) => rel.is_zero(),
+ Self::Fr(fr) => fr.is_zero(),
+ }
+ }
+}
+
+impl From<Abs> for Spacing {
+ fn from(abs: Abs) -> Self {
+ Self::Rel(abs.into())
+ }
+}
+
+impl From<Em> for Spacing {
+ fn from(em: Em) -> Self {
+ Self::Rel(Rel::new(Ratio::zero(), em.into()))
+ }
+}
+
+impl From<Length> for Spacing {
+ fn from(length: Length) -> Self {
+ Self::Rel(length.into())
+ }
+}
+
+impl From<Fr> for Spacing {
+ fn from(fr: Fr) -> Self {
+ Self::Fr(fr)
+ }
+}
+
+impl PartialOrd for Spacing {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ match (self, other) {
+ (Self::Rel(a), Self::Rel(b)) => a.partial_cmp(b),
+ (Self::Fr(a), Self::Fr(b)) => a.partial_cmp(b),
+ _ => None,
+ }
+ }
+}
+
+cast! {
+ Spacing,
+ self => match self {
+ Self::Rel(rel) => {
+ if rel.rel.is_zero() {
+ rel.abs.into_value()
+ } else if rel.abs.is_zero() {
+ rel.rel.into_value()
+ } else {
+ rel.into_value()
+ }
+ }
+ Self::Fr(fr) => fr.into_value(),
+ },
+ v: Rel<Length> => Self::Rel(v),
+ v: Fr => Self::Fr(v),
+}
diff --git a/crates/typst-library/src/layout/stack.rs b/crates/typst-library/src/layout/stack.rs
new file mode 100644
index 00000000..97305ddf
--- /dev/null
+++ b/crates/typst-library/src/layout/stack.rs
@@ -0,0 +1,339 @@
+use super::{AlignElem, Spacing};
+use crate::prelude::*;
+
+/// Arranges content and spacing horizontally or vertically.
+///
+/// The stack places a list of items along an axis, with optional spacing
+/// between each item.
+///
+/// ## Example { #example }
+/// ```example
+/// #stack(
+/// dir: ttb,
+/// rect(width: 40pt),
+/// rect(width: 120pt),
+/// rect(width: 90pt),
+/// )
+/// ```
+///
+/// Display: Stack
+/// Category: layout
+#[element(Layout)]
+pub struct StackElem {
+ /// The direction along which the items are stacked. Possible values are:
+ ///
+ /// - `{ltr}`: Left to right.
+ /// - `{rtl}`: Right to left.
+ /// - `{ttb}`: Top to bottom.
+ /// - `{btt}`: Bottom to top.
+ #[default(Dir::TTB)]
+ pub dir: Dir,
+
+ /// Spacing to insert between items where no explicit spacing was provided.
+ pub spacing: Option<Spacing>,
+
+ /// The children to stack along the axis.
+ #[variadic]
+ pub children: Vec<StackChild>,
+}
+
+impl Layout for StackElem {
+ #[tracing::instrument(name = "StackElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let mut layouter = StackLayouter::new(self.dir(styles), regions, styles);
+
+ // Spacing to insert before the next block.
+ let spacing = self.spacing(styles);
+ let mut deferred = None;
+
+ for child in self.children() {
+ match child {
+ StackChild::Spacing(kind) => {
+ layouter.layout_spacing(kind);
+ deferred = None;
+ }
+ StackChild::Block(block) => {
+ if let Some(kind) = deferred {
+ layouter.layout_spacing(kind);
+ }
+
+ layouter.layout_block(vt, &block, styles)?;
+ deferred = spacing;
+ }
+ }
+ }
+
+ Ok(layouter.finish())
+ }
+}
+
+/// A child of a stack element.
+#[derive(Hash)]
+pub enum StackChild {
+ /// Spacing between other children.
+ Spacing(Spacing),
+ /// Arbitrary block-level content.
+ Block(Content),
+}
+
+impl Debug for StackChild {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Spacing(kind) => kind.fmt(f),
+ Self::Block(block) => block.fmt(f),
+ }
+ }
+}
+
+cast! {
+ StackChild,
+ self => match self {
+ Self::Spacing(spacing) => spacing.into_value(),
+ Self::Block(content) => content.into_value(),
+ },
+ v: Spacing => Self::Spacing(v),
+ v: Content => Self::Block(v),
+}
+
+/// Performs stack layout.
+struct StackLayouter<'a> {
+ /// The stacking direction.
+ dir: Dir,
+ /// The axis of the stacking direction.
+ axis: Axis,
+ /// The regions to layout children into.
+ regions: Regions<'a>,
+ /// The inherited styles.
+ styles: StyleChain<'a>,
+ /// Whether the stack itself should expand to fill the region.
+ expand: Axes<bool>,
+ /// The initial size of the current region before we started subtracting.
+ initial: Size,
+ /// The generic size used by the frames for the current region.
+ used: Gen<Abs>,
+ /// The sum of fractions in the current region.
+ fr: Fr,
+ /// Already layouted items whose exact positions are not yet known due to
+ /// fractional spacing.
+ items: Vec<StackItem>,
+ /// Finished frames for previous regions.
+ finished: Vec<Frame>,
+}
+
+/// A prepared item in a stack layout.
+enum StackItem {
+ /// Absolute spacing between other items.
+ Absolute(Abs),
+ /// Fractional spacing between other items.
+ Fractional(Fr),
+ /// A frame for a layouted block.
+ Frame(Frame, Axes<Align>),
+}
+
+impl<'a> StackLayouter<'a> {
+ /// Create a new stack layouter.
+ fn new(dir: Dir, mut regions: Regions<'a>, styles: StyleChain<'a>) -> Self {
+ let axis = dir.axis();
+ let expand = regions.expand;
+
+ // Disable expansion along the block axis for children.
+ regions.expand.set(axis, false);
+
+ Self {
+ dir,
+ axis,
+ regions,
+ styles,
+ expand,
+ initial: regions.size,
+ used: Gen::zero(),
+ fr: Fr::zero(),
+ items: vec![],
+ finished: vec![],
+ }
+ }
+
+ /// Add spacing along the spacing direction.
+ #[tracing::instrument(name = "StackLayouter::layout_spacing", skip_all)]
+ fn layout_spacing(&mut self, spacing: Spacing) {
+ match spacing {
+ Spacing::Rel(v) => {
+ // Resolve the spacing and limit it to the remaining space.
+ let resolved = v
+ .resolve(self.styles)
+ .relative_to(self.regions.base().get(self.axis));
+ let remaining = self.regions.size.get_mut(self.axis);
+ let limited = resolved.min(*remaining);
+ if self.dir.axis() == Axis::Y {
+ *remaining -= limited;
+ }
+ self.used.main += limited;
+ self.items.push(StackItem::Absolute(resolved));
+ }
+ Spacing::Fr(v) => {
+ self.fr += v;
+ self.items.push(StackItem::Fractional(v));
+ }
+ }
+ }
+
+ /// Layout an arbitrary block.
+ #[tracing::instrument(name = "StackLayouter::layout_block", skip_all)]
+ fn layout_block(
+ &mut self,
+ vt: &mut Vt,
+ block: &Content,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ if self.regions.is_full() {
+ self.finish_region();
+ }
+
+ // Block-axis alignment of the `AlignElement` is respected by stacks.
+ let aligns = if let Some(align) = block.to::<AlignElem>() {
+ align.alignment(styles)
+ } else if let Some((_, local)) = block.to_styled() {
+ AlignElem::alignment_in(styles.chain(local))
+ } else {
+ AlignElem::alignment_in(styles)
+ }
+ .resolve(styles);
+
+ let fragment = block.layout(vt, styles, self.regions)?;
+ let len = fragment.len();
+ for (i, frame) in fragment.into_iter().enumerate() {
+ // Grow our size, shrink the region and save the frame for later.
+ let size = frame.size();
+ if self.dir.axis() == Axis::Y {
+ self.regions.size.y -= size.y;
+ }
+
+ let gen = match self.axis {
+ Axis::X => Gen::new(size.y, size.x),
+ Axis::Y => Gen::new(size.x, size.y),
+ };
+
+ self.used.main += gen.main;
+ self.used.cross.set_max(gen.cross);
+
+ self.items.push(StackItem::Frame(frame, aligns));
+
+ if i + 1 < len {
+ self.finish_region();
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Advance to the next region.
+ fn finish_region(&mut self) {
+ // Determine the size of the stack in this region depending on whether
+ // the region expands.
+ let mut size = self
+ .expand
+ .select(self.initial, self.used.into_axes(self.axis))
+ .min(self.initial);
+
+ // Expand fully if there are fr spacings.
+ let full = self.initial.get(self.axis);
+ let remaining = full - self.used.main;
+ if self.fr.get() > 0.0 && full.is_finite() {
+ self.used.main = full;
+ size.set(self.axis, full);
+ }
+
+ let mut output = Frame::new(size);
+ let mut cursor = Abs::zero();
+ let mut ruler: Align = self.dir.start().into();
+
+ // Place all frames.
+ for item in self.items.drain(..) {
+ match item {
+ StackItem::Absolute(v) => cursor += v,
+ StackItem::Fractional(v) => cursor += v.share(self.fr, remaining),
+ StackItem::Frame(frame, aligns) => {
+ if self.dir.is_positive() {
+ ruler = ruler.max(aligns.get(self.axis));
+ } else {
+ ruler = ruler.min(aligns.get(self.axis));
+ }
+
+ // Align along the main axis.
+ let parent = size.get(self.axis);
+ let child = frame.size().get(self.axis);
+ let main = ruler.position(parent - self.used.main)
+ + if self.dir.is_positive() {
+ cursor
+ } else {
+ self.used.main - child - cursor
+ };
+
+ // Align along the cross axis.
+ let other = self.axis.other();
+ let cross = aligns
+ .get(other)
+ .position(size.get(other) - frame.size().get(other));
+
+ let pos = Gen::new(cross, main).to_point(self.axis);
+ cursor += child;
+ output.push_frame(pos, frame);
+ }
+ }
+ }
+
+ // Advance to the next region.
+ self.regions.next();
+ self.initial = self.regions.size;
+ self.used = Gen::zero();
+ self.fr = Fr::zero();
+ self.finished.push(output);
+ }
+
+ /// Finish layouting and return the resulting frames.
+ fn finish(mut self) -> Fragment {
+ self.finish_region();
+ Fragment::frames(self.finished)
+ }
+}
+
+/// A container with a main and cross component.
+#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)]
+struct Gen<T> {
+ /// The main component.
+ pub cross: T,
+ /// The cross component.
+ pub main: T,
+}
+
+impl<T> Gen<T> {
+ /// Create a new instance from the two components.
+ const fn new(cross: T, main: T) -> Self {
+ Self { cross, main }
+ }
+
+ /// Convert to the specific representation, given the current main axis.
+ fn into_axes(self, main: Axis) -> Axes<T> {
+ match main {
+ Axis::X => Axes::new(self.main, self.cross),
+ Axis::Y => Axes::new(self.cross, self.main),
+ }
+ }
+}
+
+impl Gen<Abs> {
+ /// The zero value.
+ fn zero() -> Self {
+ Self { cross: Abs::zero(), main: Abs::zero() }
+ }
+
+ /// Convert to a point.
+ fn to_point(self, main: Axis) -> Point {
+ self.into_axes(main).to_point()
+ }
+}
diff --git a/crates/typst-library/src/layout/table.rs b/crates/typst-library/src/layout/table.rs
new file mode 100644
index 00000000..c2faedba
--- /dev/null
+++ b/crates/typst-library/src/layout/table.rs
@@ -0,0 +1,334 @@
+use typst::eval::{CastInfo, Reflect};
+
+use crate::layout::{AlignElem, GridLayouter, TrackSizings};
+use crate::meta::{Figurable, LocalName};
+use crate::prelude::*;
+
+/// A table of items.
+///
+/// Tables are used to arrange content in cells. Cells can contain arbitrary
+/// content, including multiple paragraphs and are specified in row-major order.
+/// Because tables are just grids with configurable cell properties, refer to
+/// the [grid documentation]($func/grid) for more information on how to size the
+/// table tracks.
+///
+/// To give a table a caption and make it [referenceable]($func/ref), put it
+/// into a [figure]($func/figure).
+///
+/// ## Example { #example }
+/// ```example
+/// #table(
+/// columns: (1fr, auto, auto),
+/// inset: 10pt,
+/// align: horizon,
+/// [], [*Area*], [*Parameters*],
+/// image("cylinder.svg"),
+/// $ pi h (D^2 - d^2) / 4 $,
+/// [
+/// $h$: height \
+/// $D$: outer radius \
+/// $d$: inner radius
+/// ],
+/// image("tetrahedron.svg"),
+/// $ sqrt(2) / 12 a^3 $,
+/// [$a$: edge length]
+/// )
+/// ```
+///
+/// Display: Table
+/// Category: layout
+#[element(Layout, LocalName, Figurable)]
+pub struct TableElem {
+ /// The column sizes. See the [grid documentation]($func/grid) for more
+ /// information on track sizing.
+ pub columns: TrackSizings,
+
+ /// The row sizes. See the [grid documentation]($func/grid) for more
+ /// information on track sizing.
+ pub rows: TrackSizings,
+
+ /// The gaps between rows & columns. See the [grid
+ /// documentation]($func/grid) for more information on gutters.
+ #[external]
+ pub gutter: TrackSizings,
+
+ /// The gaps between columns. Takes precedence over `gutter`. See the [grid
+ /// documentation]($func/grid) for more information on gutters.
+ #[parse(
+ let gutter = args.named("gutter")?;
+ args.named("column-gutter")?.or_else(|| gutter.clone())
+ )]
+ pub column_gutter: TrackSizings,
+
+ /// The gaps between rows. Takes precedence over `gutter`. See the [grid
+ /// documentation]($func/grid) for more information on gutters.
+ #[parse(args.named("row-gutter")?.or_else(|| gutter.clone()))]
+ pub row_gutter: TrackSizings,
+
+ /// How to fill the cells.
+ ///
+ /// This can be a color or a function that returns a color. The function is
+ /// passed the cells' column and row index, starting at zero. This can be
+ /// used to implement striped tables.
+ ///
+ /// ```example
+ /// #table(
+ /// fill: (col, _) => if calc.odd(col) { luma(240) } else { white },
+ /// align: (col, row) =>
+ /// if row == 0 { center }
+ /// else if col == 0 { left }
+ /// else { right },
+ /// columns: 4,
+ /// [], [*Q1*], [*Q2*], [*Q3*],
+ /// [Revenue:], [1000 €], [2000 €], [3000 €],
+ /// [Expenses:], [500 €], [1000 €], [1500 €],
+ /// [Profit:], [500 €], [1000 €], [1500 €],
+ /// )
+ /// ```
+ pub fill: Celled<Option<Paint>>,
+
+ /// How to align the cells' content.
+ ///
+ /// This can either be a single alignment, an array of alignments
+ /// (corresponding to each column) or a function that returns an alignment.
+ /// The function is passed the cells' column and row index, starting at zero.
+ /// If set to `{auto}`, the outer alignment is used.
+ ///
+ /// ```example
+ /// #table(
+ /// columns: 3,
+ /// align: (x, y) => (left, center, right).at(x),
+ /// [Hello], [Hello], [Hello],
+ /// [A], [B], [C],
+ /// )
+ /// ```
+ pub align: Celled<Smart<Axes<Option<GenAlign>>>>,
+
+ /// How to stroke the cells.
+ ///
+ /// See the [line's documentation]($func/line.stroke) for more details.
+ /// Strokes can be disabled by setting this to `{none}`.
+ ///
+ /// _Note:_ Richer stroke customization for individual cells is not yet
+ /// implemented, but will be in the future. In the meantime, you can use
+ /// the third-party [tablex library](https://github.com/PgBiel/typst-tablex/).
+ #[resolve]
+ #[fold]
+ #[default(Some(PartialStroke::default()))]
+ pub stroke: Option<PartialStroke>,
+
+ /// How much to pad the cells' content.
+ #[default(Abs::pt(5.0).into())]
+ pub inset: Rel<Length>,
+
+ /// The contents of the table cells.
+ #[variadic]
+ pub children: Vec<Content>,
+}
+
+impl Layout for TableElem {
+ #[tracing::instrument(name = "TableElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let inset = self.inset(styles);
+ let align = self.align(styles);
+
+ let tracks = Axes::new(self.columns(styles).0, self.rows(styles).0);
+ let gutter = Axes::new(self.column_gutter(styles).0, self.row_gutter(styles).0);
+ let cols = tracks.x.len().max(1);
+ let cells: Vec<_> = self
+ .children()
+ .into_iter()
+ .enumerate()
+ .map(|(i, child)| {
+ let mut child = child.padded(Sides::splat(inset));
+
+ let x = i % cols;
+ let y = i / cols;
+ if let Smart::Custom(alignment) = align.resolve(vt, x, y)? {
+ child = child.styled(AlignElem::set_alignment(alignment));
+ }
+
+ Ok(child)
+ })
+ .collect::<SourceResult<_>>()?;
+
+ let fill = self.fill(styles);
+ let stroke = self.stroke(styles).map(PartialStroke::unwrap_or_default);
+
+ // Prepare grid layout by unifying content and gutter tracks.
+ let layouter = GridLayouter::new(
+ tracks.as_deref(),
+ gutter.as_deref(),
+ &cells,
+ regions,
+ styles,
+ );
+
+ // Measure the columns and layout the grid row-by-row.
+ let mut layout = layouter.layout(vt)?;
+
+ // Add lines and backgrounds.
+ for (frame, rows) in layout.fragment.iter_mut().zip(&layout.rows) {
+ if layout.cols.is_empty() || rows.is_empty() {
+ continue;
+ }
+
+ // Render table lines.
+ if let Some(stroke) = &stroke {
+ let thickness = stroke.thickness;
+ let half = thickness / 2.0;
+
+ // Render horizontal lines.
+ for offset in points(rows.iter().map(|piece| piece.height)) {
+ let target = Point::with_x(frame.width() + thickness);
+ let hline = Geometry::Line(target).stroked(stroke.clone());
+ frame.prepend(
+ Point::new(-half, offset),
+ FrameItem::Shape(hline, self.span()),
+ );
+ }
+
+ // Render vertical lines.
+ for offset in points(layout.cols.iter().copied()) {
+ let target = Point::with_y(frame.height() + thickness);
+ let vline = Geometry::Line(target).stroked(stroke.clone());
+ frame.prepend(
+ Point::new(offset, -half),
+ FrameItem::Shape(vline, self.span()),
+ );
+ }
+ }
+
+ // Render cell backgrounds.
+ let mut dx = Abs::zero();
+ for (x, &col) in layout.cols.iter().enumerate() {
+ let mut dy = Abs::zero();
+ for row in rows {
+ if let Some(fill) = fill.resolve(vt, x, row.y)? {
+ let pos = Point::new(dx, dy);
+ let size = Size::new(col, row.height);
+ let rect = Geometry::Rect(size).filled(fill);
+ frame.prepend(pos, FrameItem::Shape(rect, self.span()));
+ }
+ dy += row.height;
+ }
+ dx += col;
+ }
+ }
+
+ Ok(layout.fragment)
+ }
+}
+
+/// Turn an iterator of extents into an iterator of offsets before, in between,
+/// and after the extents, e.g. [10mm, 5mm] -> [0mm, 10mm, 15mm].
+fn points(extents: impl IntoIterator<Item = Abs>) -> impl Iterator<Item = Abs> {
+ let mut offset = Abs::zero();
+ std::iter::once(Abs::zero())
+ .chain(extents.into_iter())
+ .map(move |extent| {
+ offset += extent;
+ offset
+ })
+}
+
+/// A value that can be configured per cell.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum Celled<T> {
+ /// A bare value, the same for all cells.
+ Value(T),
+ /// A closure mapping from cell coordinates to a value.
+ Func(Func),
+ /// An array of alignment values corresponding to each column.
+ Array(Vec<T>),
+}
+
+impl<T: Default + Clone + FromValue> Celled<T> {
+ /// Resolve the value based on the cell position.
+ pub fn resolve(&self, vt: &mut Vt, x: usize, y: usize) -> SourceResult<T> {
+ Ok(match self {
+ Self::Value(value) => value.clone(),
+ Self::Func(func) => func.call_vt(vt, [x, y])?.cast().at(func.span())?,
+ Self::Array(array) => x
+ .checked_rem(array.len())
+ .and_then(|i| array.get(i))
+ .cloned()
+ .unwrap_or_default(),
+ })
+ }
+}
+
+impl<T: Default> Default for Celled<T> {
+ fn default() -> Self {
+ Self::Value(T::default())
+ }
+}
+
+impl<T: Reflect> Reflect for Celled<T> {
+ fn describe() -> CastInfo {
+ T::describe() + Array::describe() + Func::describe()
+ }
+
+ fn castable(value: &Value) -> bool {
+ Array::castable(value) || Func::castable(value) || T::castable(value)
+ }
+}
+
+impl<T: IntoValue> IntoValue for Celled<T> {
+ fn into_value(self) -> Value {
+ match self {
+ Self::Value(value) => value.into_value(),
+ Self::Func(func) => func.into_value(),
+ Self::Array(arr) => arr.into_value(),
+ }
+ }
+}
+
+impl<T: FromValue> FromValue for Celled<T> {
+ fn from_value(value: Value) -> StrResult<Self> {
+ match value {
+ Value::Func(v) => Ok(Self::Func(v)),
+ Value::Array(array) => Ok(Self::Array(
+ array.into_iter().map(T::from_value).collect::<StrResult<_>>()?,
+ )),
+ v if T::castable(&v) => Ok(Self::Value(T::from_value(v)?)),
+ v => Err(Self::error(&v)),
+ }
+ }
+}
+
+impl LocalName for TableElem {
+ fn local_name(&self, lang: Lang, _: Option<Region>) -> &'static str {
+ match lang {
+ Lang::ALBANIAN => "Tabel",
+ Lang::ARABIC => "جدول",
+ Lang::BOKMÅL => "Tabell",
+ Lang::CHINESE => "表",
+ Lang::CZECH => "Tabulka",
+ Lang::DANISH => "Tabel",
+ Lang::DUTCH => "Tabel",
+ Lang::FILIPINO => "Talaan",
+ Lang::FRENCH => "Tableau",
+ Lang::GERMAN => "Tabelle",
+ Lang::ITALIAN => "Tabella",
+ Lang::NYNORSK => "Tabell",
+ Lang::POLISH => "Tabela",
+ Lang::PORTUGUESE => "Tabela",
+ Lang::RUSSIAN => "Таблица",
+ Lang::SLOVENIAN => "Tabela",
+ Lang::SPANISH => "Tabla",
+ Lang::SWEDISH => "Tabell",
+ Lang::TURKISH => "Tablo",
+ Lang::UKRAINIAN => "Таблиця",
+ Lang::VIETNAMESE => "Bảng",
+ Lang::ENGLISH | _ => "Table",
+ }
+ }
+}
+
+impl Figurable for TableElem {}
diff --git a/crates/typst-library/src/layout/terms.rs b/crates/typst-library/src/layout/terms.rs
new file mode 100644
index 00000000..d693f100
--- /dev/null
+++ b/crates/typst-library/src/layout/terms.rs
@@ -0,0 +1,166 @@
+use super::{HElem, VElem};
+use crate::layout::{BlockElem, ParElem, Spacing};
+use crate::prelude::*;
+
+/// A list of terms and their descriptions.
+///
+/// Displays a sequence of terms and their descriptions vertically. When the
+/// descriptions span over multiple lines, they use hanging indent to
+/// communicate the visual hierarchy.
+///
+/// ## Example { #example }
+/// ```example
+/// / Ligature: A merged glyph.
+/// / Kerning: A spacing adjustment
+/// between two adjacent letters.
+/// ```
+///
+/// ## Syntax { #syntax }
+/// This function also has dedicated syntax: Starting a line with a slash,
+/// followed by a term, a colon and a description creates a term list item.
+///
+/// Display: Term List
+/// Category: layout
+#[element(Layout)]
+#[scope(
+ scope.define("item", TermItem::func());
+ scope
+)]
+pub struct TermsElem {
+ /// If this is `{false}`, the items are spaced apart with [term list
+ /// spacing]($func/terms.spacing). If it is `{true}`, they use normal
+ /// [leading]($func/par.leading) instead. This makes the term list more
+ /// compact, which can look better if the items are short.
+ ///
+ /// In markup mode, the value of this parameter is determined based on
+ /// whether items are separated with a blank line. If items directly follow
+ /// each other, this is set to `{true}`; if items are separated by a blank
+ /// line, this is set to `{false}`.
+ ///
+ /// ```example
+ /// / Fact: If a term list has a lot
+ /// of text, and maybe other inline
+ /// content, it should not be tight
+ /// anymore.
+ ///
+ /// / Tip: To make it wide, simply
+ /// insert a blank line between the
+ /// items.
+ /// ```
+ #[default(true)]
+ pub tight: bool,
+
+ /// The separator between the item and the description.
+ ///
+ /// If you want to just separate them with a certain amount of space, use
+ /// `{h(2cm, weak: true)}` as the separator and replace `{2cm}` with your
+ /// desired amount of space.
+ ///
+ /// ```example
+ /// #set terms(separator: [: ])
+ ///
+ /// / Colon: A nice separator symbol.
+ /// ```
+ #[default(HElem::new(Em::new(0.6).into()).with_weak(true).pack())]
+ pub separator: Content,
+
+ /// The indentation of each item.
+ pub indent: Length,
+
+ /// The hanging indent of the description.
+ ///
+ /// This is in addition to the whole item's `indent`.
+ ///
+ /// ```example
+ /// #set terms(hanging-indent: 0pt)
+ /// / Term: This term list does not
+ /// make use of hanging indents.
+ /// ```
+ #[default(Em::new(2.0).into())]
+ pub hanging_indent: Length,
+
+ /// The spacing between the items of a wide (non-tight) term list.
+ ///
+ /// If set to `{auto}`, uses the spacing [below blocks]($func/block.below).
+ pub spacing: Smart<Spacing>,
+
+ /// The term list's children.
+ ///
+ /// When using the term list syntax, adjacent items are automatically
+ /// collected into term lists, even through constructs like for loops.
+ ///
+ /// ```example
+ /// #for (year, product) in (
+ /// "1978": "TeX",
+ /// "1984": "LaTeX",
+ /// "2019": "Typst",
+ /// ) [/ #product: Born in #year.]
+ /// ```
+ #[variadic]
+ pub children: Vec<TermItem>,
+}
+
+impl Layout for TermsElem {
+ #[tracing::instrument(name = "TermsElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let separator = self.separator(styles);
+ let indent = self.indent(styles);
+ let hanging_indent = self.hanging_indent(styles);
+ let gutter = if self.tight(styles) {
+ ParElem::leading_in(styles).into()
+ } else {
+ self.spacing(styles)
+ .unwrap_or_else(|| BlockElem::below_in(styles).amount())
+ };
+
+ let mut seq = vec![];
+ for (i, child) in self.children().into_iter().enumerate() {
+ if i > 0 {
+ seq.push(VElem::new(gutter).with_weakness(1).pack());
+ }
+ if !indent.is_zero() {
+ seq.push(HElem::new(indent.into()).pack());
+ }
+ seq.push(child.term().strong());
+ seq.push(separator.clone());
+ seq.push(child.description());
+ }
+
+ Content::sequence(seq)
+ .styled(ParElem::set_hanging_indent(hanging_indent + indent))
+ .layout(vt, styles, regions)
+ }
+}
+
+/// A term list item.
+///
+/// Display: Term List Item
+/// Category: layout
+#[element]
+pub struct TermItem {
+ /// The term described by the list item.
+ #[required]
+ pub term: Content,
+
+ /// The description of the term.
+ #[required]
+ pub description: Content,
+}
+
+cast! {
+ TermItem,
+ array: Array => {
+ let mut iter = array.into_iter();
+ let (term, description) = match (iter.next(), iter.next(), iter.next()) {
+ (Some(a), Some(b), None) => (a.cast()?, b.cast()?),
+ _ => bail!("array must contain exactly two entries"),
+ };
+ Self::new(term, description)
+ },
+ v: Content => v.to::<Self>().cloned().ok_or("expected term item or array")?,
+}
diff --git a/crates/typst-library/src/layout/transform.rs b/crates/typst-library/src/layout/transform.rs
new file mode 100644
index 00000000..a57a5edc
--- /dev/null
+++ b/crates/typst-library/src/layout/transform.rs
@@ -0,0 +1,194 @@
+use typst::geom::Transform;
+
+use crate::prelude::*;
+
+/// Moves content without affecting layout.
+///
+/// The `move` function allows you to move content while the layout still 'sees'
+/// it at the original positions. Containers will still be sized as if the
+/// content was not moved.
+///
+/// ## Example { #example }
+/// ```example
+/// #rect(inset: 0pt, move(
+/// dx: 6pt, dy: 6pt,
+/// rect(
+/// inset: 8pt,
+/// fill: white,
+/// stroke: black,
+/// [Abra cadabra]
+/// )
+/// ))
+/// ```
+///
+/// Display: Move
+/// Category: layout
+#[element(Layout)]
+pub struct MoveElem {
+ /// The horizontal displacement of the content.
+ pub dx: Rel<Length>,
+
+ /// The vertical displacement of the content.
+ pub dy: Rel<Length>,
+
+ /// The content to move.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for MoveElem {
+ #[tracing::instrument(name = "MoveElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let pod = Regions::one(regions.base(), Axes::splat(false));
+ let mut frame = self.body().layout(vt, styles, pod)?.into_frame();
+ let delta = Axes::new(self.dx(styles), self.dy(styles)).resolve(styles);
+ let delta = delta.zip(regions.base()).map(|(d, s)| d.relative_to(s));
+ frame.translate(delta.to_point());
+ Ok(Fragment::frame(frame))
+ }
+}
+
+/// Rotates content without affecting layout.
+///
+/// Rotates an element by a given angle. The layout will act as if the element
+/// was not rotated.
+///
+/// ## Example { #example }
+/// ```example
+/// #stack(
+/// dir: ltr,
+/// spacing: 1fr,
+/// ..range(16)
+/// .map(i => rotate(24deg * i)[X]),
+/// )
+/// ```
+///
+/// Display: Rotate
+/// Category: layout
+#[element(Layout)]
+pub struct RotateElem {
+ /// The amount of rotation.
+ ///
+ /// ```example
+ /// #rotate(-1.571rad)[Space!]
+ /// ```
+ ///
+ #[positional]
+ pub angle: Angle,
+
+ /// The origin of the rotation.
+ ///
+ /// If, for instance, you wanted the bottom left corner of the rotated
+ /// element to stay aligned with the baseline, you would set it to `bottom +
+ /// left` instead.
+ ///
+ /// ```example
+ /// #set text(spacing: 8pt)
+ /// #let square = square.with(width: 8pt)
+ ///
+ /// #box(square())
+ /// #box(rotate(30deg, origin: center, square()))
+ /// #box(rotate(30deg, origin: top + left, square()))
+ /// #box(rotate(30deg, origin: bottom + right, square()))
+ /// ```
+ #[resolve]
+ #[fold]
+ #[default(Align::CENTER_HORIZON)]
+ pub origin: Axes<Option<GenAlign>>,
+
+ /// The content to rotate.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for RotateElem {
+ #[tracing::instrument(name = "RotateElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let pod = Regions::one(regions.base(), Axes::splat(false));
+ let mut frame = self.body().layout(vt, styles, pod)?.into_frame();
+ let Axes { x, y } =
+ self.origin(styles).zip(frame.size()).map(|(o, s)| o.position(s));
+ let ts = Transform::translate(x, y)
+ .pre_concat(Transform::rotate(self.angle(styles)))
+ .pre_concat(Transform::translate(-x, -y));
+ frame.transform(ts);
+ Ok(Fragment::frame(frame))
+ }
+}
+
+/// Scales content without affecting layout.
+///
+/// Lets you mirror content by specifying a negative scale on a single axis.
+///
+/// ## Example { #example }
+/// ```example
+/// #set align(center)
+/// #scale(x: -100%)[This is mirrored.]
+/// ```
+///
+/// Display: Scale
+/// Category: layout
+#[element(Layout)]
+pub struct ScaleElem {
+ /// The horizontal scaling factor.
+ ///
+ /// The body will be mirrored horizontally if the parameter is negative.
+ #[parse(
+ let all = args.find()?;
+ args.named("x")?.or(all)
+ )]
+ #[default(Ratio::one())]
+ pub x: Ratio,
+
+ /// The vertical scaling factor.
+ ///
+ /// The body will be mirrored vertically if the parameter is negative.
+ #[parse(args.named("y")?.or(all))]
+ #[default(Ratio::one())]
+ pub y: Ratio,
+
+ /// The origin of the transformation.
+ ///
+ /// ```example
+ /// A#box(scale(75%)[A])A \
+ /// B#box(scale(75%, origin: bottom + left)[B])B
+ /// ```
+ #[resolve]
+ #[fold]
+ #[default(Align::CENTER_HORIZON)]
+ pub origin: Axes<Option<GenAlign>>,
+
+ /// The content to scale.
+ #[required]
+ pub body: Content,
+}
+
+impl Layout for ScaleElem {
+ #[tracing::instrument(name = "ScaleElem::layout", skip_all)]
+ fn layout(
+ &self,
+ vt: &mut Vt,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let pod = Regions::one(regions.base(), Axes::splat(false));
+ let mut frame = self.body().layout(vt, styles, pod)?.into_frame();
+ let Axes { x, y } =
+ self.origin(styles).zip(frame.size()).map(|(o, s)| o.position(s));
+ let transform = Transform::translate(x, y)
+ .pre_concat(Transform::scale(self.x(styles), self.y(styles)))
+ .pre_concat(Transform::translate(-x, -y));
+ frame.transform(transform);
+ Ok(Fragment::frame(frame))
+ }
+}