summaryrefslogtreecommitdiff
path: root/crates/typst-layout
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2024-10-27 19:04:55 +0100
committerGitHub <noreply@github.com>2024-10-27 18:04:55 +0000
commitbe7cfc85d08c545abfac08098b7b33b4bd71f37e (patch)
treef4137fa2aaa57babae1f7603a9b2ed7e688f43d8 /crates/typst-layout
parentb8034a343831e8609aec2ec81eb7eeda57aa5d81 (diff)
Split out four new crates (#5302)
Diffstat (limited to 'crates/typst-layout')
-rw-r--r--crates/typst-layout/Cargo.toml43
-rw-r--r--crates/typst-layout/src/flow/block.rs401
-rw-r--r--crates/typst-layout/src/flow/collect.rs648
-rw-r--r--crates/typst-layout/src/flow/compose.rs877
-rw-r--r--crates/typst-layout/src/flow/distribute.rs525
-rw-r--r--crates/typst-layout/src/flow/mod.rs381
-rw-r--r--crates/typst-layout/src/grid/cells.rs1349
-rw-r--r--crates/typst-layout/src/grid/layouter.rs1582
-rw-r--r--crates/typst-layout/src/grid/lines.rs1548
-rw-r--r--crates/typst-layout/src/grid/mod.rs416
-rw-r--r--crates/typst-layout/src/grid/repeated.rs192
-rw-r--r--crates/typst-layout/src/grid/rowspans.rs1217
-rw-r--r--crates/typst-layout/src/image.rs142
-rw-r--r--crates/typst-layout/src/inline/box.rs87
-rw-r--r--crates/typst-layout/src/inline/collect.rs328
-rw-r--r--crates/typst-layout/src/inline/deco.rs213
-rw-r--r--crates/typst-layout/src/inline/finalize.rs36
-rw-r--r--crates/typst-layout/src/inline/line.rs750
-rw-r--r--crates/typst-layout/src/inline/linebreak.rs980
-rw-r--r--crates/typst-layout/src/inline/mod.rs105
-rw-r--r--crates/typst-layout/src/inline/prepare.rs196
-rw-r--r--crates/typst-layout/src/inline/shaping.rs1175
-rw-r--r--crates/typst-layout/src/lib.rs30
-rw-r--r--crates/typst-layout/src/lists.rs146
-rw-r--r--crates/typst-layout/src/math/accent.rs75
-rw-r--r--crates/typst-layout/src/math/attach.rs515
-rw-r--r--crates/typst-layout/src/math/cancel.rs144
-rw-r--r--crates/typst-layout/src/math/frac.rs136
-rw-r--r--crates/typst-layout/src/math/fragment.rs704
-rw-r--r--crates/typst-layout/src/math/lr.rs135
-rw-r--r--crates/typst-layout/src/math/mat.rs333
-rw-r--r--crates/typst-layout/src/math/mod.rs703
-rw-r--r--crates/typst-layout/src/math/root.rs126
-rw-r--r--crates/typst-layout/src/math/run.rs469
-rw-r--r--crates/typst-layout/src/math/shared.rs207
-rw-r--r--crates/typst-layout/src/math/stretch.rs291
-rw-r--r--crates/typst-layout/src/math/text.rs344
-rw-r--r--crates/typst-layout/src/math/underover.rs327
-rw-r--r--crates/typst-layout/src/pad.rs93
-rw-r--r--crates/typst-layout/src/pages/collect.rs165
-rw-r--r--crates/typst-layout/src/pages/finalize.rs73
-rw-r--r--crates/typst-layout/src/pages/mod.rs159
-rw-r--r--crates/typst-layout/src/pages/run.rs267
-rw-r--r--crates/typst-layout/src/repeat.rs60
-rw-r--r--crates/typst-layout/src/shapes.rs1028
-rw-r--r--crates/typst-layout/src/stack.rs324
-rw-r--r--crates/typst-layout/src/transforms.rs246
47 files changed, 20291 insertions, 0 deletions
diff --git a/crates/typst-layout/Cargo.toml b/crates/typst-layout/Cargo.toml
new file mode 100644
index 00000000..4c133a4e
--- /dev/null
+++ b/crates/typst-layout/Cargo.toml
@@ -0,0 +1,43 @@
+[package]
+name = "typst-layout"
+description = "Typst's layout engine."
+version = { workspace = true }
+rust-version = { workspace = true }
+authors = { workspace = true }
+edition = { workspace = true }
+homepage = { workspace = true }
+repository = { workspace = true }
+license = { workspace = true }
+categories = { workspace = true }
+keywords = { workspace = true }
+readme = { workspace = true }
+
+[dependencies]
+typst-assets = { workspace = true }
+typst-library = { workspace = true }
+typst-macros = { workspace = true }
+typst-syntax = { workspace = true }
+typst-timing = { workspace = true }
+typst-utils = { workspace = true }
+az = { workspace = true }
+bumpalo = { workspace = true }
+comemo = { workspace = true }
+ecow = { workspace = true }
+hypher = { workspace = true }
+icu_properties = { workspace = true }
+icu_provider = { workspace = true }
+icu_provider_adapters = { workspace = true }
+icu_provider_blob = { workspace = true }
+icu_segmenter = { workspace = true }
+kurbo = { workspace = true }
+once_cell = { workspace = true }
+rustybuzz = { workspace = true }
+smallvec = { workspace = true }
+ttf-parser = { workspace = true }
+unicode-bidi = { workspace = true }
+unicode-math-class = { workspace = true }
+unicode-script = { workspace = true }
+unicode-segmentation = { workspace = true }
+
+[lints]
+workspace = true
diff --git a/crates/typst-layout/src/flow/block.rs b/crates/typst-layout/src/flow/block.rs
new file mode 100644
index 00000000..1dd98812
--- /dev/null
+++ b/crates/typst-layout/src/flow/block.rs
@@ -0,0 +1,401 @@
+use once_cell::unsync::Lazy;
+use smallvec::SmallVec;
+use typst_library::diag::SourceResult;
+use typst_library::engine::Engine;
+use typst_library::foundations::{Packed, Resolve, StyleChain};
+use typst_library::introspection::Locator;
+use typst_library::layout::{
+ Abs, Axes, BlockBody, BlockElem, Fragment, Frame, FrameKind, Region, Regions, Rel,
+ Sides, Size, Sizing,
+};
+use typst_library::visualize::Stroke;
+use typst_utils::Numeric;
+
+use crate::shapes::{clip_rect, fill_and_stroke};
+
+/// Lay this out as an unbreakable block.
+#[typst_macros::time(name = "block", span = elem.span())]
+pub fn layout_single_block(
+ elem: &Packed<BlockElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ // Fetch sizing properties.
+ let width = elem.width(styles);
+ let height = elem.height(styles);
+ let inset = elem.inset(styles).unwrap_or_default();
+
+ // Build the pod regions.
+ let pod = unbreakable_pod(&width.into(), &height, &inset, styles, region.size);
+
+ // Layout the body.
+ let body = elem.body(styles);
+ let mut frame = match body {
+ // If we have no body, just create one frame. Its size will be
+ // adjusted below.
+ None => Frame::hard(Size::zero()),
+
+ // If we have content as our body, just layout it.
+ Some(BlockBody::Content(body)) => {
+ crate::layout_frame(engine, body, locator.relayout(), styles, pod)?
+ }
+
+ // If we have a child that wants to layout with just access to the
+ // base region, give it that.
+ Some(BlockBody::SingleLayouter(callback)) => {
+ callback.call(engine, locator, styles, pod)?
+ }
+
+ // If we have a child that wants to layout with full region access,
+ // we layout it.
+ Some(BlockBody::MultiLayouter(callback)) => {
+ let expand = (pod.expand | region.expand) & pod.size.map(Abs::is_finite);
+ let pod = Region { expand, ..pod };
+ callback.call(engine, locator, styles, pod.into())?.into_frame()
+ }
+ };
+
+ // Explicit blocks are boundaries for gradient relativeness.
+ if matches!(body, None | Some(BlockBody::Content(_))) {
+ frame.set_kind(FrameKind::Hard);
+ }
+
+ // Enforce a correct frame size on the expanded axes. Do this before
+ // applying the inset, since the pod shrunk.
+ frame.set_size(pod.expand.select(pod.size, frame.size()));
+
+ // Apply the inset.
+ if !inset.is_zero() {
+ crate::pad::grow(&mut frame, &inset);
+ }
+
+ // Prepare fill and stroke.
+ let fill = elem.fill(styles);
+ let stroke = elem
+ .stroke(styles)
+ .unwrap_or_default()
+ .map(|s| s.map(Stroke::unwrap_or_default));
+
+ // Only fetch these if necessary (for clipping or filling/stroking).
+ let outset = Lazy::new(|| elem.outset(styles).unwrap_or_default());
+ let radius = Lazy::new(|| elem.radius(styles).unwrap_or_default());
+
+ // Clip the contents, if requested.
+ if elem.clip(styles) {
+ let size = frame.size() + outset.relative_to(frame.size()).sum_by_axis();
+ frame.clip(clip_rect(size, &radius, &stroke));
+ }
+
+ // Add fill and/or stroke.
+ if fill.is_some() || stroke.iter().any(Option::is_some) {
+ fill_and_stroke(&mut frame, fill, &stroke, &outset, &radius, elem.span());
+ }
+
+ // Assign label to each frame in the fragment.
+ if let Some(label) = elem.label() {
+ frame.label(label);
+ }
+
+ Ok(frame)
+}
+
+/// Lay this out as a breakable block.
+#[typst_macros::time(name = "block", span = elem.span())]
+pub fn layout_multi_block(
+ elem: &Packed<BlockElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+) -> SourceResult<Fragment> {
+ // Fetch sizing properties.
+ let width = elem.width(styles);
+ let height = elem.height(styles);
+ let inset = elem.inset(styles).unwrap_or_default();
+
+ // Allocate a small vector for backlogs.
+ let mut buf = SmallVec::<[Abs; 2]>::new();
+
+ // Build the pod regions.
+ let pod = breakable_pod(&width.into(), &height, &inset, styles, regions, &mut buf);
+
+ // Layout the body.
+ let body = elem.body(styles);
+ let mut fragment = match body {
+ // If we have no body, just create one frame plus one per backlog
+ // region. We create them zero-sized; if necessary, their size will
+ // be adjusted below.
+ None => {
+ let mut frames = vec![];
+ frames.push(Frame::hard(Size::zero()));
+ if pod.expand.y {
+ let mut iter = pod;
+ while !iter.backlog.is_empty() {
+ frames.push(Frame::hard(Size::zero()));
+ iter.next();
+ }
+ }
+ Fragment::frames(frames)
+ }
+
+ // If we have content as our body, just layout it.
+ Some(BlockBody::Content(body)) => {
+ let mut fragment =
+ crate::layout_fragment(engine, body, locator.relayout(), styles, pod)?;
+
+ // If the body is automatically sized and produced more than one
+ // fragment, ensure that the width was consistent across all
+ // regions. If it wasn't, we need to relayout with expansion.
+ if !pod.expand.x
+ && fragment
+ .as_slice()
+ .windows(2)
+ .any(|w| !w[0].width().approx_eq(w[1].width()))
+ {
+ let max_width =
+ fragment.iter().map(|frame| frame.width()).max().unwrap_or_default();
+ let pod = Regions {
+ size: Size::new(max_width, pod.size.y),
+ expand: Axes::new(true, pod.expand.y),
+ ..pod
+ };
+ fragment = crate::layout_fragment(engine, body, locator, styles, pod)?;
+ }
+
+ fragment
+ }
+
+ // If we have a child that wants to layout with just access to the
+ // base region, give it that.
+ Some(BlockBody::SingleLayouter(callback)) => {
+ let pod = Region::new(pod.base(), pod.expand);
+ callback.call(engine, locator, styles, pod).map(Fragment::frame)?
+ }
+
+ // If we have a child that wants to layout with full region access,
+ // we layout it.
+ //
+ // For auto-sized multi-layouters, we propagate the outer expansion
+ // so that they can decide for themselves. We also ensure again to
+ // only expand if the size is finite.
+ Some(BlockBody::MultiLayouter(callback)) => {
+ let expand = (pod.expand | regions.expand) & pod.size.map(Abs::is_finite);
+ let pod = Regions { expand, ..pod };
+ callback.call(engine, locator, styles, pod)?
+ }
+ };
+
+ // Prepare fill and stroke.
+ let fill = elem.fill(styles);
+ let stroke = elem
+ .stroke(styles)
+ .unwrap_or_default()
+ .map(|s| s.map(Stroke::unwrap_or_default));
+
+ // Only fetch these if necessary (for clipping or filling/stroking).
+ let outset = Lazy::new(|| elem.outset(styles).unwrap_or_default());
+ let radius = Lazy::new(|| elem.radius(styles).unwrap_or_default());
+
+ // Fetch/compute these outside of the loop.
+ let clip = elem.clip(styles);
+ let has_fill_or_stroke = fill.is_some() || stroke.iter().any(Option::is_some);
+ let has_inset = !inset.is_zero();
+ let is_explicit = matches!(body, None | Some(BlockBody::Content(_)));
+
+ // Skip filling/stroking the first frame if it is empty and a non-empty
+ // one follows.
+ let mut skip_first = false;
+ if let [first, rest @ ..] = fragment.as_slice() {
+ skip_first = has_fill_or_stroke
+ && first.is_empty()
+ && rest.iter().any(|frame| !frame.is_empty());
+ }
+
+ // Post-process to apply insets, clipping, fills, and strokes.
+ for (i, (frame, region)) in fragment.iter_mut().zip(pod.iter()).enumerate() {
+ // Explicit blocks are boundaries for gradient relativeness.
+ if is_explicit {
+ frame.set_kind(FrameKind::Hard);
+ }
+
+ // Enforce a correct frame size on the expanded axes. Do this before
+ // applying the inset, since the pod shrunk.
+ frame.set_size(pod.expand.select(region, frame.size()));
+
+ // Apply the inset.
+ if has_inset {
+ crate::pad::grow(frame, &inset);
+ }
+
+ // Clip the contents, if requested.
+ if clip {
+ let size = frame.size() + outset.relative_to(frame.size()).sum_by_axis();
+ frame.clip(clip_rect(size, &radius, &stroke));
+ }
+
+ // Add fill and/or stroke.
+ if has_fill_or_stroke && (i > 0 || !skip_first) {
+ fill_and_stroke(frame, fill.clone(), &stroke, &outset, &radius, elem.span());
+ }
+ }
+
+ // Assign label to each frame in the fragment.
+ if let Some(label) = elem.label() {
+ for frame in fragment.iter_mut() {
+ frame.label(label);
+ }
+ }
+
+ Ok(fragment)
+}
+
+/// Builds the pod region for an unbreakable sized container.
+pub(crate) fn unbreakable_pod(
+ width: &Sizing,
+ height: &Sizing,
+ inset: &Sides<Rel<Abs>>,
+ styles: StyleChain,
+ base: Size,
+) -> Region {
+ // Resolve the size.
+ let mut size = Size::new(
+ match width {
+ // - For auto, the whole region is available.
+ // - Fr is handled outside and already factored into the `region`,
+ // so we can treat it equivalently to 100%.
+ Sizing::Auto | Sizing::Fr(_) => base.x,
+ // Resolve the relative sizing.
+ Sizing::Rel(rel) => rel.resolve(styles).relative_to(base.x),
+ },
+ match height {
+ Sizing::Auto | Sizing::Fr(_) => base.y,
+ Sizing::Rel(rel) => rel.resolve(styles).relative_to(base.y),
+ },
+ );
+
+ // Take the inset, if any, into account.
+ if !inset.is_zero() {
+ size = crate::pad::shrink(size, inset);
+ }
+
+ // If the child is manually, the size is forced and we should enable
+ // expansion.
+ let expand = Axes::new(
+ *width != Sizing::Auto && size.x.is_finite(),
+ *height != Sizing::Auto && size.y.is_finite(),
+ );
+
+ Region::new(size, expand)
+}
+
+/// Builds the pod regions for a breakable sized container.
+fn breakable_pod<'a>(
+ width: &Sizing,
+ height: &Sizing,
+ inset: &Sides<Rel<Abs>>,
+ styles: StyleChain,
+ regions: Regions,
+ buf: &'a mut SmallVec<[Abs; 2]>,
+) -> Regions<'a> {
+ let base = regions.base();
+
+ // The vertical region sizes we're about to build.
+ let first;
+ let full;
+ let backlog: &mut [Abs];
+ let last;
+
+ // If the block has a fixed height, things are very different, so we
+ // handle that case completely separately.
+ match height {
+ Sizing::Auto | Sizing::Fr(_) => {
+ // If the block is automatically sized, we can just inherit the
+ // regions.
+ first = regions.size.y;
+ full = regions.full;
+ buf.extend_from_slice(regions.backlog);
+ backlog = buf;
+ last = regions.last;
+ }
+
+ Sizing::Rel(rel) => {
+ // Resolve the sizing to a concrete size.
+ let resolved = rel.resolve(styles).relative_to(base.y);
+
+ // Since we're manually sized, the resolved size is the base height.
+ full = resolved;
+
+ // Distribute the fixed height across a start region and a backlog.
+ (first, backlog) = distribute(resolved, regions, buf);
+
+ // If the height is manually sized, we don't want a final repeatable
+ // region.
+ last = None;
+ }
+ };
+
+ // Resolve the horizontal sizing to a concrete width and combine
+ // `width` and `first` into `size`.
+ let mut size = Size::new(
+ match width {
+ Sizing::Auto | Sizing::Fr(_) => regions.size.x,
+ Sizing::Rel(rel) => rel.resolve(styles).relative_to(base.x),
+ },
+ first,
+ );
+
+ // Take the inset, if any, into account, applying it to the
+ // individual region components.
+ let (mut full, mut last) = (full, last);
+ if !inset.is_zero() {
+ crate::pad::shrink_multiple(&mut size, &mut full, backlog, &mut last, inset);
+ }
+
+ // If the child is manually, the size is forced and we should enable
+ // expansion.
+ let expand = Axes::new(
+ *width != Sizing::Auto && size.x.is_finite(),
+ *height != Sizing::Auto && size.y.is_finite(),
+ );
+
+ Regions { size, full, backlog, last, expand }
+}
+
+/// Distribute a fixed height spread over existing regions into a new first
+/// height and a new backlog.
+fn distribute<'a>(
+ height: Abs,
+ mut regions: Regions,
+ buf: &'a mut SmallVec<[Abs; 2]>,
+) -> (Abs, &'a mut [Abs]) {
+ // Build new region heights from old regions.
+ let mut remaining = height;
+ loop {
+ let limited = regions.size.y.clamp(Abs::zero(), remaining);
+ buf.push(limited);
+ remaining -= limited;
+ if remaining.approx_empty()
+ || !regions.may_break()
+ || (!regions.may_progress() && limited.approx_empty())
+ {
+ break;
+ }
+ regions.next();
+ }
+
+ // If there is still something remaining, apply it to the
+ // last region (it will overflow, but there's nothing else
+ // we can do).
+ if !remaining.approx_empty() {
+ if let Some(last) = buf.last_mut() {
+ *last += remaining;
+ }
+ }
+
+ // Distribute the heights to the first region and the
+ // backlog. There is no last region, since the height is
+ // fixed.
+ (buf[0], &mut buf[1..])
+}
diff --git a/crates/typst-layout/src/flow/collect.rs b/crates/typst-layout/src/flow/collect.rs
new file mode 100644
index 00000000..aee5d508
--- /dev/null
+++ b/crates/typst-layout/src/flow/collect.rs
@@ -0,0 +1,648 @@
+use std::cell::RefCell;
+use std::fmt::{self, Debug, Formatter};
+use std::hash::Hash;
+
+use bumpalo::boxed::Box as BumpBox;
+use bumpalo::Bump;
+use comemo::{Track, Tracked, TrackedMut};
+use once_cell::unsync::Lazy;
+use typst_library::diag::{bail, SourceResult};
+use typst_library::engine::{Engine, Route, Sink, Traced};
+use typst_library::foundations::{Packed, Resolve, Smart, StyleChain};
+use typst_library::introspection::{
+ Introspector, Location, Locator, LocatorLink, SplitLocator, Tag, TagElem,
+};
+use typst_library::layout::{
+ Abs, AlignElem, Alignment, Axes, BlockElem, ColbreakElem, FixedAlignment, FlushElem,
+ Fr, Fragment, Frame, PagebreakElem, PlaceElem, PlacementScope, Ratio, Region,
+ Regions, Rel, Size, Sizing, Spacing, VElem,
+};
+use typst_library::model::ParElem;
+use typst_library::routines::{Pair, Routines};
+use typst_library::text::TextElem;
+use typst_library::World;
+
+use super::{layout_multi_block, layout_single_block};
+
+/// Collects all elements of the flow into prepared children. These are much
+/// simpler to handle than the raw elements.
+#[typst_macros::time]
+pub fn collect<'a>(
+ engine: &mut Engine,
+ bump: &'a Bump,
+ children: &[Pair<'a>],
+ locator: Locator<'a>,
+ base: Size,
+ expand: bool,
+) -> SourceResult<Vec<Child<'a>>> {
+ Collector {
+ engine,
+ bump,
+ children,
+ locator: locator.split(),
+ base,
+ expand,
+ output: Vec::with_capacity(children.len()),
+ last_was_par: false,
+ }
+ .run()
+}
+
+/// State for collection.
+struct Collector<'a, 'x, 'y> {
+ engine: &'x mut Engine<'y>,
+ bump: &'a Bump,
+ children: &'x [Pair<'a>],
+ base: Size,
+ expand: bool,
+ locator: SplitLocator<'a>,
+ output: Vec<Child<'a>>,
+ last_was_par: bool,
+}
+
+impl<'a> Collector<'a, '_, '_> {
+ /// Perform the collection.
+ fn run(mut self) -> SourceResult<Vec<Child<'a>>> {
+ for &(child, styles) in self.children {
+ if let Some(elem) = child.to_packed::<TagElem>() {
+ self.output.push(Child::Tag(&elem.tag));
+ } else if let Some(elem) = child.to_packed::<VElem>() {
+ self.v(elem, styles);
+ } else if let Some(elem) = child.to_packed::<ParElem>() {
+ self.par(elem, styles)?;
+ } else if let Some(elem) = child.to_packed::<BlockElem>() {
+ self.block(elem, styles);
+ } else if let Some(elem) = child.to_packed::<PlaceElem>() {
+ self.place(elem, styles)?;
+ } else if child.is::<FlushElem>() {
+ self.output.push(Child::Flush);
+ } else if let Some(elem) = child.to_packed::<ColbreakElem>() {
+ self.output.push(Child::Break(elem.weak(styles)));
+ } else if child.is::<PagebreakElem>() {
+ bail!(
+ child.span(), "pagebreaks are not allowed inside of containers";
+ hint: "try using a `#colbreak()` instead",
+ );
+ } else {
+ bail!(child.span(), "{} is not allowed here", child.func().name());
+ }
+ }
+
+ Ok(self.output)
+ }
+
+ /// Collect vertical spacing into a relative or fractional child.
+ fn v(&mut self, elem: &'a Packed<VElem>, styles: StyleChain<'a>) {
+ self.output.push(match elem.amount {
+ Spacing::Rel(rel) => Child::Rel(rel.resolve(styles), elem.weak(styles) as u8),
+ Spacing::Fr(fr) => Child::Fr(fr),
+ });
+ }
+
+ /// Collect a paragraph into [`LineChild`]ren. This already performs line
+ /// layout since it is not dependent on the concrete regions.
+ fn par(
+ &mut self,
+ elem: &'a Packed<ParElem>,
+ styles: StyleChain<'a>,
+ ) -> SourceResult<()> {
+ let align = AlignElem::alignment_in(styles).resolve(styles);
+ let leading = ParElem::leading_in(styles);
+ let spacing = ParElem::spacing_in(styles);
+ let costs = TextElem::costs_in(styles);
+
+ let lines = crate::layout_inline(
+ self.engine,
+ &elem.children,
+ self.locator.next(&elem.span()),
+ styles,
+ self.last_was_par,
+ self.base,
+ self.expand,
+ )?
+ .into_frames();
+
+ self.output.push(Child::Rel(spacing.into(), 4));
+
+ // Determine whether to prevent widow and orphans.
+ let len = lines.len();
+ let prevent_orphans =
+ costs.orphan() > Ratio::zero() && len >= 2 && !lines[1].is_empty();
+ let prevent_widows =
+ costs.widow() > Ratio::zero() && len >= 2 && !lines[len - 2].is_empty();
+ let prevent_all = len == 3 && prevent_orphans && prevent_widows;
+
+ // Store the heights of lines at the edges because we'll potentially
+ // need these later when `lines` is already moved.
+ let height_at = |i| lines.get(i).map(Frame::height).unwrap_or_default();
+ let front_1 = height_at(0);
+ let front_2 = height_at(1);
+ let back_2 = height_at(len.saturating_sub(2));
+ let back_1 = height_at(len.saturating_sub(1));
+
+ for (i, frame) in lines.into_iter().enumerate() {
+ if i > 0 {
+ self.output.push(Child::Rel(leading.into(), 5));
+ }
+
+ // To prevent widows and orphans, we require enough space for
+ // - all lines if it's just three
+ // - the first two lines if we're at the first line
+ // - the last two lines if we're at the second to last line
+ let need = if prevent_all && i == 0 {
+ front_1 + leading + front_2 + leading + back_1
+ } else if prevent_orphans && i == 0 {
+ front_1 + leading + front_2
+ } else if prevent_widows && i >= 2 && i + 2 == len {
+ back_2 + leading + back_1
+ } else {
+ frame.height()
+ };
+
+ self.output
+ .push(Child::Line(self.boxed(LineChild { frame, align, need })));
+ }
+
+ self.output.push(Child::Rel(spacing.into(), 4));
+ self.last_was_par = true;
+
+ Ok(())
+ }
+
+ /// Collect a block into a [`SingleChild`] or [`MultiChild`] depending on
+ /// whether it is breakable.
+ fn block(&mut self, elem: &'a Packed<BlockElem>, styles: StyleChain<'a>) {
+ let locator = self.locator.next(&elem.span());
+ let align = AlignElem::alignment_in(styles).resolve(styles);
+ let alone = self.children.len() == 1;
+ let sticky = elem.sticky(styles);
+ let breakable = elem.breakable(styles);
+ let fr = match elem.height(styles) {
+ Sizing::Fr(fr) => Some(fr),
+ _ => None,
+ };
+
+ let fallback = Lazy::new(|| ParElem::spacing_in(styles));
+ let spacing = |amount| match amount {
+ Smart::Auto => Child::Rel((*fallback).into(), 4),
+ Smart::Custom(Spacing::Rel(rel)) => Child::Rel(rel.resolve(styles), 3),
+ Smart::Custom(Spacing::Fr(fr)) => Child::Fr(fr),
+ };
+
+ self.output.push(spacing(elem.above(styles)));
+
+ if !breakable || fr.is_some() {
+ self.output.push(Child::Single(self.boxed(SingleChild {
+ align,
+ sticky,
+ alone,
+ fr,
+ elem,
+ styles,
+ locator,
+ cell: CachedCell::new(),
+ })));
+ } else {
+ self.output.push(Child::Multi(self.boxed(MultiChild {
+ align,
+ sticky,
+ alone,
+ elem,
+ styles,
+ locator,
+ cell: CachedCell::new(),
+ })));
+ };
+
+ self.output.push(spacing(elem.below(styles)));
+ self.last_was_par = false;
+ }
+
+ /// Collects a placed element into a [`PlacedChild`].
+ fn place(
+ &mut self,
+ elem: &'a Packed<PlaceElem>,
+ styles: StyleChain<'a>,
+ ) -> SourceResult<()> {
+ let alignment = elem.alignment(styles);
+ let align_x = alignment.map_or(FixedAlignment::Center, |align| {
+ align.x().unwrap_or_default().resolve(styles)
+ });
+ let align_y = alignment.map(|align| align.y().map(|y| y.resolve(styles)));
+ let scope = elem.scope(styles);
+ let float = elem.float(styles);
+
+ match (float, align_y) {
+ (true, Smart::Custom(None | Some(FixedAlignment::Center))) => bail!(
+ elem.span(),
+ "vertical floating placement must be `auto`, `top`, or `bottom`"
+ ),
+ (false, Smart::Auto) => bail!(
+ elem.span(),
+ "automatic positioning is only available for floating placement";
+ hint: "you can enable floating placement with `place(float: true, ..)`"
+ ),
+ _ => {}
+ }
+
+ if !float && scope == PlacementScope::Parent {
+ bail!(
+ elem.span(),
+ "parent-scoped positioning is currently only available for floating placement";
+ hint: "you can enable floating placement with `place(float: true, ..)`"
+ );
+ }
+
+ let locator = self.locator.next(&elem.span());
+ let clearance = elem.clearance(styles);
+ let delta = Axes::new(elem.dx(styles), elem.dy(styles)).resolve(styles);
+ self.output.push(Child::Placed(self.boxed(PlacedChild {
+ align_x,
+ align_y,
+ scope,
+ float,
+ clearance,
+ delta,
+ elem,
+ styles,
+ locator,
+ alignment,
+ cell: CachedCell::new(),
+ })));
+
+ Ok(())
+ }
+
+ /// Wraps a value in a bump-allocated box to reduce its footprint in the
+ /// [`Child`] enum.
+ fn boxed<T>(&self, value: T) -> BumpBox<'a, T> {
+ BumpBox::new_in(value, self.bump)
+ }
+}
+
+/// A prepared child in flow layout.
+///
+/// The larger variants are bump-boxed to keep the enum size down.
+#[derive(Debug)]
+pub enum Child<'a> {
+ /// An introspection tag.
+ Tag(&'a Tag),
+ /// Relative spacing with a specific weakness level.
+ Rel(Rel<Abs>, u8),
+ /// Fractional spacing.
+ Fr(Fr),
+ /// An already layouted line of a paragraph.
+ Line(BumpBox<'a, LineChild>),
+ /// An unbreakable block.
+ Single(BumpBox<'a, SingleChild<'a>>),
+ /// A breakable block.
+ Multi(BumpBox<'a, MultiChild<'a>>),
+ /// An absolutely or floatingly placed element.
+ Placed(BumpBox<'a, PlacedChild<'a>>),
+ /// A place flush.
+ Flush,
+ /// An explicit column break.
+ Break(bool),
+}
+
+/// A child that encapsulates a layouted line of a paragraph.
+#[derive(Debug)]
+pub struct LineChild {
+ pub frame: Frame,
+ pub align: Axes<FixedAlignment>,
+ pub need: Abs,
+}
+
+/// A child that encapsulates a prepared unbreakable block.
+#[derive(Debug)]
+pub struct SingleChild<'a> {
+ pub align: Axes<FixedAlignment>,
+ pub sticky: bool,
+ pub alone: bool,
+ pub fr: Option<Fr>,
+ elem: &'a Packed<BlockElem>,
+ styles: StyleChain<'a>,
+ locator: Locator<'a>,
+ cell: CachedCell<SourceResult<Frame>>,
+}
+
+impl SingleChild<'_> {
+ /// Build the child's frame given the region's base size.
+ pub fn layout(&self, engine: &mut Engine, region: Region) -> SourceResult<Frame> {
+ self.cell.get_or_init(region, |mut region| {
+ // Vertical expansion is only kept if this block is the only child.
+ region.expand.y &= self.alone;
+ layout_single_impl(
+ engine.routines,
+ engine.world,
+ engine.introspector,
+ engine.traced,
+ TrackedMut::reborrow_mut(&mut engine.sink),
+ engine.route.track(),
+ self.elem,
+ self.locator.track(),
+ self.styles,
+ region,
+ )
+ })
+ }
+}
+
+/// The cached, internal implementation of [`SingleChild::layout`].
+#[comemo::memoize]
+#[allow(clippy::too_many_arguments)]
+fn layout_single_impl(
+ routines: &Routines,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ traced: Tracked<Traced>,
+ sink: TrackedMut<Sink>,
+ route: Tracked<Route>,
+ elem: &Packed<BlockElem>,
+ locator: Tracked<Locator>,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ let link = LocatorLink::new(locator);
+ let locator = Locator::link(&link);
+ let mut engine = Engine {
+ routines,
+ world,
+ introspector,
+ traced,
+ sink,
+ route: Route::extend(route),
+ };
+
+ layout_single_block(elem, &mut engine, locator, styles, region)
+ .map(|frame| frame.post_processed(styles))
+}
+
+/// A child that encapsulates a prepared breakable block.
+#[derive(Debug)]
+pub struct MultiChild<'a> {
+ pub align: Axes<FixedAlignment>,
+ pub sticky: bool,
+ alone: bool,
+ elem: &'a Packed<BlockElem>,
+ styles: StyleChain<'a>,
+ locator: Locator<'a>,
+ cell: CachedCell<SourceResult<Fragment>>,
+}
+
+impl<'a> MultiChild<'a> {
+ /// Build the child's frames given regions.
+ pub fn layout<'b>(
+ &'b self,
+ engine: &mut Engine,
+ regions: Regions,
+ ) -> SourceResult<(Frame, Option<MultiSpill<'a, 'b>>)> {
+ let fragment = self.layout_full(engine, regions)?;
+
+ // Extract the first frame.
+ let mut frames = fragment.into_iter();
+ let frame = frames.next().unwrap();
+
+ // If there's more, return a `spill`.
+ let mut spill = None;
+ if frames.next().is_some() {
+ spill = Some(MultiSpill {
+ multi: self,
+ full: regions.full,
+ first: regions.size.y,
+ backlog: vec![],
+ min_backlog_len: regions.backlog.len(),
+ });
+ }
+
+ Ok((frame, spill))
+ }
+
+ /// The shared internal implementation of [`Self::layout`] and
+ /// [`MultiSpill::layout`].
+ fn layout_full(
+ &self,
+ engine: &mut Engine,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ self.cell.get_or_init(regions, |mut regions| {
+ // Vertical expansion is only kept if this block is the only child.
+ regions.expand.y &= self.alone;
+ layout_multi_impl(
+ engine.routines,
+ engine.world,
+ engine.introspector,
+ engine.traced,
+ TrackedMut::reborrow_mut(&mut engine.sink),
+ engine.route.track(),
+ self.elem,
+ self.locator.track(),
+ self.styles,
+ regions,
+ )
+ })
+ }
+}
+
+/// The cached, internal implementation of [`MultiChild::layout_full`].
+#[comemo::memoize]
+#[allow(clippy::too_many_arguments)]
+fn layout_multi_impl(
+ routines: &Routines,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ traced: Tracked<Traced>,
+ sink: TrackedMut<Sink>,
+ route: Tracked<Route>,
+ elem: &Packed<BlockElem>,
+ locator: Tracked<Locator>,
+ styles: StyleChain,
+ regions: Regions,
+) -> SourceResult<Fragment> {
+ let link = LocatorLink::new(locator);
+ let locator = Locator::link(&link);
+ let mut engine = Engine {
+ routines,
+ world,
+ introspector,
+ traced,
+ sink,
+ route: Route::extend(route),
+ };
+
+ layout_multi_block(elem, &mut engine, locator, styles, regions).map(|mut fragment| {
+ for frame in &mut fragment {
+ frame.post_process(styles);
+ }
+ fragment
+ })
+}
+
+/// The spilled remains of a `MultiChild` that broke across two regions.
+#[derive(Debug, Clone)]
+pub struct MultiSpill<'a, 'b> {
+ multi: &'b MultiChild<'a>,
+ first: Abs,
+ full: Abs,
+ backlog: Vec<Abs>,
+ min_backlog_len: usize,
+}
+
+impl MultiSpill<'_, '_> {
+ /// Build the spill's frames given regions.
+ pub fn layout(
+ mut self,
+ engine: &mut Engine,
+ regions: Regions,
+ ) -> SourceResult<(Frame, Option<Self>)> {
+ // The first region becomes unchangable and committed to our backlog.
+ self.backlog.push(regions.size.y);
+
+ // The remaining regions are ephemeral and may be replaced.
+ let mut backlog: Vec<_> =
+ self.backlog.iter().chain(regions.backlog).copied().collect();
+
+ // Remove unnecessary backlog items to prevent it from growing
+ // unnecessarily, changing the region's hash.
+ while backlog.len() > self.min_backlog_len
+ && backlog.last().copied() == regions.last
+ {
+ backlog.pop();
+ }
+
+ // Build the pod with the merged regions.
+ let pod = Regions {
+ size: Size::new(regions.size.x, self.first),
+ expand: regions.expand,
+ full: self.full,
+ backlog: &backlog,
+ last: regions.last,
+ };
+
+ // Extract the not-yet-processed frames.
+ let mut frames = self
+ .multi
+ .layout_full(engine, pod)?
+ .into_iter()
+ .skip(self.backlog.len());
+
+ // Ensure that the backlog never shrinks, so that unwrapping below is at
+ // least fairly safe. Note that the whole region juggling here is
+ // fundamentally not ideal: It is a compatibility layer between the old
+ // (all regions provided upfront) & new (each region provided on-demand,
+ // like an iterator) layout model. This approach is not 100% correct, as
+ // in the old model later regions could have an effect on earlier
+ // frames, but it's the best we can do for now, until the multi
+ // layouters are refactored to the new model.
+ self.min_backlog_len = self.min_backlog_len.max(backlog.len());
+
+ // Save the first frame.
+ let frame = frames.next().unwrap();
+
+ // If there's more, return a `spill`.
+ let mut spill = None;
+ if frames.next().is_some() {
+ spill = Some(self);
+ }
+
+ Ok((frame, spill))
+ }
+
+ /// The alignment of the breakable block.
+ pub fn align(&self) -> Axes<FixedAlignment> {
+ self.multi.align
+ }
+}
+
+/// A child that encapsulates a prepared placed element.
+#[derive(Debug)]
+pub struct PlacedChild<'a> {
+ pub align_x: FixedAlignment,
+ pub align_y: Smart<Option<FixedAlignment>>,
+ pub scope: PlacementScope,
+ pub float: bool,
+ pub clearance: Abs,
+ pub delta: Axes<Rel<Abs>>,
+ elem: &'a Packed<PlaceElem>,
+ styles: StyleChain<'a>,
+ locator: Locator<'a>,
+ alignment: Smart<Alignment>,
+ cell: CachedCell<SourceResult<Frame>>,
+}
+
+impl PlacedChild<'_> {
+ /// Build the child's frame given the region's base size.
+ pub fn layout(&self, engine: &mut Engine, base: Size) -> SourceResult<Frame> {
+ self.cell.get_or_init(base, |base| {
+ let align = self.alignment.unwrap_or_else(|| Alignment::CENTER);
+ let aligned = AlignElem::set_alignment(align).wrap();
+
+ let mut frame = crate::layout_frame(
+ engine,
+ &self.elem.body,
+ self.locator.relayout(),
+ self.styles.chain(&aligned),
+ Region::new(base, Axes::splat(false)),
+ )?;
+
+ if self.float {
+ frame.set_parent(self.elem.location().unwrap());
+ }
+
+ Ok(frame.post_processed(self.styles))
+ })
+ }
+
+ /// The element's location.
+ pub fn location(&self) -> Location {
+ self.elem.location().unwrap()
+ }
+}
+
+/// Wraps a parameterized computation and caches its latest output.
+///
+/// - When the computation is performed multiple times consecutively with the
+/// same argument, reuses the cache.
+/// - When the argument changes, the new output is cached.
+#[derive(Clone)]
+struct CachedCell<T>(RefCell<Option<(u128, T)>>);
+
+impl<T> CachedCell<T> {
+ /// Create an empty cached cell.
+ fn new() -> Self {
+ Self(RefCell::new(None))
+ }
+
+ /// Perform the computation `f` with caching.
+ fn get_or_init<F, I>(&self, input: I, f: F) -> T
+ where
+ I: Hash,
+ T: Clone,
+ F: FnOnce(I) -> T,
+ {
+ let input_hash = typst_utils::hash128(&input);
+
+ let mut slot = self.0.borrow_mut();
+ if let Some((hash, output)) = &*slot {
+ if *hash == input_hash {
+ return output.clone();
+ }
+ }
+
+ let output = f(input);
+ *slot = Some((input_hash, output.clone()));
+ output
+ }
+}
+
+impl<T> Default for CachedCell<T> {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl<T> Debug for CachedCell<T> {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ f.pad("CachedCell(..)")
+ }
+}
diff --git a/crates/typst-layout/src/flow/compose.rs b/crates/typst-layout/src/flow/compose.rs
new file mode 100644
index 00000000..932ccc9a
--- /dev/null
+++ b/crates/typst-layout/src/flow/compose.rs
@@ -0,0 +1,877 @@
+use std::num::NonZeroUsize;
+
+use typst_library::diag::SourceResult;
+use typst_library::engine::Engine;
+use typst_library::foundations::{Content, NativeElement, Packed, Resolve, Smart};
+use typst_library::introspection::{
+ Counter, CounterDisplayElem, CounterState, CounterUpdate, Location, Locator,
+ SplitLocator, Tag,
+};
+use typst_library::layout::{
+ Abs, Axes, Dir, FixedAlignment, Fragment, Frame, FrameItem, OuterHAlignment,
+ PlacementScope, Point, Region, Regions, Rel, Size,
+};
+use typst_library::model::{
+ FootnoteElem, FootnoteEntry, LineNumberingScope, Numbering, ParLineMarker,
+};
+use typst_syntax::Span;
+use typst_utils::NonZeroExt;
+
+use super::{distribute, Config, FlowResult, LineNumberConfig, PlacedChild, Stop, Work};
+
+/// Composes the contents of a single page/region. A region can have multiple
+/// columns/subregions.
+///
+/// The composer is primarily concerned with layout of out-of-flow insertions
+/// (floats and footnotes). It does this in per-page and per-column loops that
+/// rerun when a new float is added (since it affects the regions available to
+/// the distributor).
+///
+/// To lay out the in-flow contents of individual subregions, the composer
+/// invokes [distribution](distribute).
+pub fn compose(
+ engine: &mut Engine,
+ work: &mut Work,
+ config: &Config,
+ locator: Locator,
+ regions: Regions,
+) -> SourceResult<Frame> {
+ Composer {
+ engine,
+ config,
+ page_base: regions.base(),
+ column: 0,
+ page_insertions: Insertions::default(),
+ column_insertions: Insertions::default(),
+ work,
+ footnote_spill: None,
+ footnote_queue: vec![],
+ }
+ .page(locator, regions)
+}
+
+/// State for composition.
+///
+/// Sadly, we need that many lifetimes because &mut references are invariant and
+/// it would force the lifetimes of various things to be equal if they
+/// shared a lifetime.
+///
+/// The only interesting lifetimes are 'a and 'b. See [Work] for more details
+/// about them.
+pub struct Composer<'a, 'b, 'x, 'y> {
+ pub engine: &'x mut Engine<'y>,
+ pub work: &'x mut Work<'a, 'b>,
+ pub config: &'x Config<'x>,
+ column: usize,
+ page_base: Size,
+ page_insertions: Insertions<'a, 'b>,
+ column_insertions: Insertions<'a, 'b>,
+ // These are here because they have to survive relayout (we could lose the
+ // footnotes otherwise). For floats, we revisit them anyway, so it's okay to
+ // use `work.floats` directly. This is not super clean; probably there's a
+ // better way.
+ footnote_spill: Option<std::vec::IntoIter<Frame>>,
+ footnote_queue: Vec<Packed<FootnoteElem>>,
+}
+
+impl<'a, 'b> Composer<'a, 'b, '_, '_> {
+ /// Lay out a container/page region, including container/page insertions.
+ fn page(mut self, locator: Locator, regions: Regions) -> SourceResult<Frame> {
+ // This loop can restart region layout when requested to do so by a
+ // `Stop`. This happens when there is a parent-scoped float.
+ let checkpoint = self.work.clone();
+ let output = loop {
+ // Shrink the available space by the space used by page
+ // insertions.
+ let mut pod = regions;
+ pod.size.y -= self.page_insertions.height();
+
+ match self.page_contents(locator.relayout(), pod) {
+ Ok(frame) => break frame,
+ Err(Stop::Finish(_)) => unreachable!(),
+ Err(Stop::Relayout(PlacementScope::Column)) => unreachable!(),
+ Err(Stop::Relayout(PlacementScope::Parent)) => {
+ *self.work = checkpoint.clone();
+ continue;
+ }
+ Err(Stop::Error(err)) => return Err(err),
+ };
+ };
+ drop(checkpoint);
+
+ Ok(self.page_insertions.finalize(self.work, self.config, output))
+ }
+
+ /// Lay out the inner contents of a container/page.
+ fn page_contents(&mut self, locator: Locator, regions: Regions) -> FlowResult<Frame> {
+ // No point in create column regions, if there's just one!
+ if self.config.columns.count == 1 {
+ return self.column(locator, regions);
+ }
+
+ // Create a backlog for multi-column layout.
+ let column_height = regions.size.y;
+ let backlog: Vec<_> = std::iter::once(&column_height)
+ .chain(regions.backlog)
+ .flat_map(|&h| std::iter::repeat(h).take(self.config.columns.count))
+ .skip(1)
+ .collect();
+
+ // Subregions for column layout.
+ let mut inner = Regions {
+ size: Size::new(self.config.columns.width, column_height),
+ backlog: &backlog,
+ expand: Axes::new(true, regions.expand.y),
+ ..regions
+ };
+
+ // The size of the merged frame hosting multiple columns.
+ let size = Size::new(
+ regions.size.x,
+ if regions.expand.y { regions.size.y } else { Abs::zero() },
+ );
+
+ let mut output = Frame::hard(size);
+ let mut offset = Abs::zero();
+ let mut locator = locator.split();
+
+ // Lay out the columns and stitch them together.
+ for i in 0..self.config.columns.count {
+ self.column = i;
+ let frame = self.column(locator.next(&()), inner)?;
+
+ if !regions.expand.y {
+ output.size_mut().y.set_max(frame.height());
+ }
+
+ let width = frame.width();
+ let x = if self.config.columns.dir == Dir::LTR {
+ offset
+ } else {
+ regions.size.x - offset - width
+ };
+ offset += width + self.config.columns.gutter;
+
+ output.push_frame(Point::with_x(x), frame);
+ inner.next();
+ }
+
+ Ok(output)
+ }
+
+ /// Lay out a column, including column insertions.
+ fn column(&mut self, locator: Locator, regions: Regions) -> FlowResult<Frame> {
+ // Reset column insertion when starting a new column.
+ self.column_insertions = Insertions::default();
+
+ // Process footnote spill.
+ if let Some(spill) = self.work.footnote_spill.take() {
+ self.footnote_spill(spill, regions.base())?;
+ }
+
+ // This loop can restart column layout when requested to do so by a
+ // `Stop`. This happens when there is a column-scoped float.
+ let checkpoint = self.work.clone();
+ let inner = loop {
+ // Shrink the available space by the space used by column
+ // insertions.
+ let mut pod = regions;
+ pod.size.y -= self.column_insertions.height();
+
+ match self.column_contents(pod) {
+ Ok(frame) => break frame,
+ Err(Stop::Finish(_)) => unreachable!(),
+ Err(Stop::Relayout(PlacementScope::Column)) => {
+ *self.work = checkpoint.clone();
+ continue;
+ }
+ err => return err,
+ }
+ };
+ drop(checkpoint);
+
+ self.work.footnotes.extend(self.footnote_queue.drain(..));
+ if let Some(spill) = self.footnote_spill.take() {
+ self.work.footnote_spill = Some(spill);
+ }
+
+ let insertions = std::mem::take(&mut self.column_insertions);
+ let mut output = insertions.finalize(self.work, self.config, inner);
+
+ // Lay out per-column line numbers.
+ if let Some(line_config) = &self.config.line_numbers {
+ layout_line_numbers(
+ self.engine,
+ self.config,
+ line_config,
+ locator,
+ self.column,
+ &mut output,
+ )?;
+ }
+
+ Ok(output)
+ }
+
+ /// Lay out the inner contents of a column.
+ fn column_contents(&mut self, regions: Regions) -> FlowResult<Frame> {
+ // Process pending footnotes.
+ for note in std::mem::take(&mut self.work.footnotes) {
+ self.footnote(note, &mut regions.clone(), Abs::zero(), false)?;
+ }
+
+ // Process pending floats.
+ for placed in std::mem::take(&mut self.work.floats) {
+ self.float(placed, &regions, false)?;
+ }
+
+ distribute(self, regions)
+ }
+
+ /// Lays out an item with floating placement.
+ ///
+ /// This is called from within [`distribute`]. When the float fits, this
+ /// returns an `Err(Stop::Relayout(..))`, which bubbles all the way through
+ /// distribution and is handled in [`Self::page`] or [`Self::column`]
+ /// (depending on `placed.scope`).
+ ///
+ /// When the float does not fit, it is queued into `work.floats`. The
+ /// value of `clearance` that between the float and flow content is needed
+ /// --- it is set if there are already distributed items.
+ pub fn float(
+ &mut self,
+ placed: &'b PlacedChild<'a>,
+ regions: &Regions,
+ clearance: bool,
+ ) -> FlowResult<()> {
+ // If the float is already processed, skip it.
+ let loc = placed.location();
+ if self.skipped(loc) {
+ return Ok(());
+ }
+
+ // If there is already a queued float, queue this one as well. We
+ // don't want to disrupt the order.
+ if !self.work.floats.is_empty() {
+ self.work.floats.push(placed);
+ return Ok(());
+ }
+
+ // Determine the base size of the chosen scope.
+ let base = match placed.scope {
+ PlacementScope::Column => regions.base(),
+ PlacementScope::Parent => self.page_base,
+ };
+
+ // Lay out the placed element.
+ let frame = placed.layout(self.engine, base)?;
+
+ // Determine the remaining space in the scope. This is exact for column
+ // placement, but only an approximation for page placement.
+ let remaining = match placed.scope {
+ PlacementScope::Column => regions.size.y,
+ PlacementScope::Parent => {
+ let remaining: Abs = regions
+ .iter()
+ .map(|size| size.y)
+ .take(self.config.columns.count - self.column)
+ .sum();
+ remaining / self.config.columns.count as f64
+ }
+ };
+
+ // We only require clearance if there is other content.
+ let clearance = if clearance { placed.clearance } else { Abs::zero() };
+ let need = frame.height() + clearance;
+
+ // If the float doesn't fit, queue it for the next region.
+ if !remaining.fits(need) && regions.may_progress() {
+ self.work.floats.push(placed);
+ return Ok(());
+ }
+
+ // Handle footnotes in the float.
+ self.footnotes(regions, &frame, need, false)?;
+
+ // Determine the float's vertical alignment. We can unwrap the inner
+ // `Option` because `Custom(None)` is checked for during collection.
+ let align_y = placed.align_y.map(Option::unwrap).unwrap_or_else(|| {
+ // When the float's vertical midpoint would be above the middle of
+ // the page if it were layouted in-flow, we use top alignment.
+ // Otherwise, we use bottom alignment.
+ let used = base.y - remaining;
+ let half = need / 2.0;
+ let ratio = (used + half) / base.y;
+ if ratio <= 0.5 {
+ FixedAlignment::Start
+ } else {
+ FixedAlignment::End
+ }
+ });
+
+ // Select the insertion area where we'll put this float.
+ let area = match placed.scope {
+ PlacementScope::Column => &mut self.column_insertions,
+ PlacementScope::Parent => &mut self.page_insertions,
+ };
+
+ // Put the float there.
+ area.push_float(placed, frame, align_y);
+ area.skips.push(loc);
+
+ // Trigger relayout.
+ Err(Stop::Relayout(placed.scope))
+ }
+
+ /// Lays out footnotes in the `frame` if this is the root flow and there are
+ /// any. The value of `breakable` indicates whether the element that
+ /// produced the frame is breakable. If not, the frame is treated as atomic.
+ pub fn footnotes(
+ &mut self,
+ regions: &Regions,
+ frame: &Frame,
+ flow_need: Abs,
+ breakable: bool,
+ ) -> FlowResult<()> {
+ // Footnotes are only supported at the root level.
+ if !self.config.root {
+ return Ok(());
+ }
+
+ // Search for footnotes.
+ let mut notes = vec![];
+ for tag in &self.work.tags {
+ let Tag::Start(elem) = tag else { continue };
+ let Some(note) = elem.to_packed::<FootnoteElem>() else { continue };
+ notes.push((Abs::zero(), note.clone()));
+ }
+ find_in_frame_impl::<FootnoteElem>(&mut notes, frame, Abs::zero());
+ if notes.is_empty() {
+ return Ok(());
+ }
+
+ let mut relayout = false;
+ let mut regions = *regions;
+ let mut migratable = !breakable && regions.may_progress();
+
+ for (y, elem) in notes {
+ // The amount of space used by the in-flow content that contains the
+ // footnote marker. For a breakable frame, it's the y position of
+ // the marker. For an unbreakable frame, it's the full height.
+ let flow_need = if breakable { y } else { flow_need };
+
+ // Process the footnote.
+ match self.footnote(elem, &mut regions, flow_need, migratable) {
+ // The footnote was already processed or queued.
+ Ok(()) => {}
+ // First handle more footnotes before relayouting.
+ Err(Stop::Relayout(_)) => relayout = true,
+ // Either of
+ // - A `Stop::Finish` indicating that the frame's origin element
+ // should migrate to uphold the footnote invariant.
+ // - A fatal error.
+ err => return err,
+ }
+
+ // We only migrate the origin frame if the first footnote's first
+ // line didn't fit.
+ migratable = false;
+ }
+
+ // If this is set, we laid out at least one footnote, so we need a
+ // relayout.
+ if relayout {
+ return Err(Stop::Relayout(PlacementScope::Column));
+ }
+
+ Ok(())
+ }
+
+ /// Handles a single footnote.
+ fn footnote(
+ &mut self,
+ elem: Packed<FootnoteElem>,
+ regions: &mut Regions,
+ flow_need: Abs,
+ migratable: bool,
+ ) -> FlowResult<()> {
+ // Ignore reference footnotes and already processed ones.
+ let loc = elem.location().unwrap();
+ if elem.is_ref() || self.skipped(loc) {
+ return Ok(());
+ }
+
+ // If there is already a queued spill or footnote, queue this one as
+ // well. We don't want to disrupt the order.
+ let area = &mut self.column_insertions;
+ if self.footnote_spill.is_some() || !self.footnote_queue.is_empty() {
+ self.footnote_queue.push(elem);
+ return Ok(());
+ }
+
+ // If there weren't any footnotes so far, account for the footnote
+ // separator.
+ let mut separator = None;
+ let mut separator_need = Abs::zero();
+ if area.footnotes.is_empty() {
+ let frame =
+ layout_footnote_separator(self.engine, self.config, regions.base())?;
+ separator_need += self.config.footnote.clearance + frame.height();
+ separator = Some(frame);
+ }
+
+ // Prepare regions for the footnote.
+ let mut pod = *regions;
+ pod.expand.y = false;
+ pod.size.y -= flow_need + separator_need + self.config.footnote.gap;
+
+ // Layout the footnote entry.
+ let frames = layout_footnote(self.engine, self.config, &elem, pod)?.into_frames();
+
+ // Find nested footnotes in the entry.
+ let nested = find_in_frames::<FootnoteElem>(&frames);
+
+ // Extract the first frame.
+ let mut iter = frames.into_iter();
+ let first = iter.next().unwrap();
+ let note_need = self.config.footnote.gap + first.height();
+
+ // If the first frame is empty, then none of its content fit. If
+ // possible, we then migrate the origin frame to the next region to
+ // uphold the footnote invariant (that marker and entry are on the same
+ // page). If not, we just queue the footnote for the next page.
+ if first.is_empty() {
+ if migratable {
+ return Err(Stop::Finish(false));
+ } else {
+ self.footnote_queue.push(elem);
+ return Ok(());
+ }
+ }
+
+ // Save the separator.
+ if let Some(frame) = separator {
+ area.push_footnote_separator(self.config, frame);
+ regions.size.y -= separator_need;
+ }
+
+ // Save the footnote's frame.
+ area.push_footnote(self.config, first);
+ area.skips.push(loc);
+ regions.size.y -= note_need;
+
+ // Save the spill.
+ if !iter.as_slice().is_empty() {
+ self.footnote_spill = Some(iter);
+ }
+
+ // Lay out nested footnotes.
+ for (_, note) in nested {
+ self.footnote(note, regions, flow_need, migratable)?;
+ }
+
+ // Since we laid out a footnote, we need a relayout.
+ Err(Stop::Relayout(PlacementScope::Column))
+ }
+
+ /// Handles spillover from a footnote.
+ fn footnote_spill(
+ &mut self,
+ mut iter: std::vec::IntoIter<Frame>,
+ base: Size,
+ ) -> SourceResult<()> {
+ let area = &mut self.column_insertions;
+
+ // Create and save the separator.
+ let separator = layout_footnote_separator(self.engine, self.config, base)?;
+ area.push_footnote_separator(self.config, separator);
+
+ // Save the footnote's frame.
+ let frame = iter.next().unwrap();
+ area.push_footnote(self.config, frame);
+
+ // Save the spill.
+ if !iter.as_slice().is_empty() {
+ self.footnote_spill = Some(iter);
+ }
+
+ Ok(())
+ }
+
+ /// Checks whether an insertion was already processed and doesn't need to be
+ /// handled again.
+ fn skipped(&self, loc: Location) -> bool {
+ self.work.skips.contains(&loc)
+ || self.page_insertions.skips.contains(&loc)
+ || self.column_insertions.skips.contains(&loc)
+ }
+
+ /// The amount of width needed by insertions.
+ pub fn insertion_width(&self) -> Abs {
+ self.column_insertions.width.max(self.page_insertions.width)
+ }
+}
+
+/// Lay out the footnote separator, typically a line.
+fn layout_footnote_separator(
+ engine: &mut Engine,
+ config: &Config,
+ base: Size,
+) -> SourceResult<Frame> {
+ crate::layout_frame(
+ engine,
+ &config.footnote.separator,
+ Locator::root(),
+ config.shared,
+ Region::new(base, Axes::new(config.footnote.expand, false)),
+ )
+}
+
+/// Lay out a footnote.
+fn layout_footnote(
+ engine: &mut Engine,
+ config: &Config,
+ elem: &Packed<FootnoteElem>,
+ pod: Regions,
+) -> SourceResult<Fragment> {
+ let loc = elem.location().unwrap();
+ crate::layout_fragment(
+ engine,
+ &FootnoteEntry::new(elem.clone()).pack(),
+ Locator::synthesize(loc),
+ config.shared,
+ pod,
+ )
+ .map(|mut fragment| {
+ for frame in &mut fragment {
+ frame.set_parent(loc);
+ }
+ fragment
+ })
+}
+
+/// An additive list of insertions.
+#[derive(Default)]
+struct Insertions<'a, 'b> {
+ top_floats: Vec<(&'b PlacedChild<'a>, Frame)>,
+ bottom_floats: Vec<(&'b PlacedChild<'a>, Frame)>,
+ footnotes: Vec<Frame>,
+ footnote_separator: Option<Frame>,
+ top_size: Abs,
+ bottom_size: Abs,
+ width: Abs,
+ skips: Vec<Location>,
+}
+
+impl<'a, 'b> Insertions<'a, 'b> {
+ /// Add a float to the top or bottom area.
+ fn push_float(
+ &mut self,
+ placed: &'b PlacedChild<'a>,
+ frame: Frame,
+ align_y: FixedAlignment,
+ ) {
+ self.width.set_max(frame.width());
+
+ let amount = frame.height() + placed.clearance;
+ let pair = (placed, frame);
+
+ if align_y == FixedAlignment::Start {
+ self.top_size += amount;
+ self.top_floats.push(pair);
+ } else {
+ self.bottom_size += amount;
+ self.bottom_floats.push(pair);
+ }
+ }
+
+ /// Add a footnote to the bottom area.
+ fn push_footnote(&mut self, config: &Config, frame: Frame) {
+ self.width.set_max(frame.width());
+ self.bottom_size += config.footnote.gap + frame.height();
+ self.footnotes.push(frame);
+ }
+
+ /// Add a footnote separator to the bottom area.
+ fn push_footnote_separator(&mut self, config: &Config, frame: Frame) {
+ self.width.set_max(frame.width());
+ self.bottom_size += config.footnote.clearance + frame.height();
+ self.footnote_separator = Some(frame);
+ }
+
+ /// The combined height of the top and bottom area (includings clearances).
+ /// Subtracting this from the total region size yields the available space
+ /// for distribution.
+ fn height(&self) -> Abs {
+ self.top_size + self.bottom_size
+ }
+
+ /// Produce a frame for the full region based on the `inner` frame produced
+ /// by distribution or column layout.
+ fn finalize(self, work: &mut Work, config: &Config, inner: Frame) -> Frame {
+ work.extend_skips(&self.skips);
+
+ if self.top_floats.is_empty()
+ && self.bottom_floats.is_empty()
+ && self.footnote_separator.is_none()
+ && self.footnotes.is_empty()
+ {
+ return inner;
+ }
+
+ let size = inner.size() + Size::with_y(self.height());
+
+ let mut output = Frame::soft(size);
+ let mut offset_top = Abs::zero();
+ let mut offset_bottom = size.y - self.bottom_size;
+
+ for (placed, frame) in self.top_floats {
+ let x = placed.align_x.position(size.x - frame.width());
+ let y = offset_top;
+ let delta = placed.delta.zip_map(size, Rel::relative_to).to_point();
+ offset_top += frame.height() + placed.clearance;
+ output.push_frame(Point::new(x, y) + delta, frame);
+ }
+
+ output.push_frame(Point::with_y(self.top_size), inner);
+
+ // We put floats first and then footnotes. This differs from what LaTeX
+ // does and is a little inconsistent w.r.t column vs page floats (page
+ // floats are below footnotes because footnotes are per column), but
+ // it's what most people (including myself) seem to intuitively expect.
+ // We experimented with the LaTeX ordering in 0.12.0-rc1, but folks were
+ // surprised and considered this strange. In LaTeX, it can be changed
+ // with `\usepackage[bottom]{footmisc}`. We could also consider adding
+ // configuration in the future.
+ for (placed, frame) in self.bottom_floats {
+ offset_bottom += placed.clearance;
+ let x = placed.align_x.position(size.x - frame.width());
+ let y = offset_bottom;
+ let delta = placed.delta.zip_map(size, Rel::relative_to).to_point();
+ offset_bottom += frame.height();
+ output.push_frame(Point::new(x, y) + delta, frame);
+ }
+
+ if let Some(frame) = self.footnote_separator {
+ offset_bottom += config.footnote.clearance;
+ let y = offset_bottom;
+ offset_bottom += frame.height();
+ output.push_frame(Point::with_y(y), frame);
+ }
+
+ for frame in self.footnotes {
+ offset_bottom += config.footnote.gap;
+ let y = offset_bottom;
+ offset_bottom += frame.height();
+ output.push_frame(Point::with_y(y), frame);
+ }
+
+ output
+ }
+}
+
+/// Lay out the given collected lines' line numbers to an output frame.
+///
+/// The numbers are placed either on the left margin (left border of the frame)
+/// or on the right margin (right border). Before they are placed, a line number
+/// counter reset is inserted if we're in the first column of the page being
+/// currently laid out and the user requested for line numbers to be reset at
+/// the start of every page.
+fn layout_line_numbers(
+ engine: &mut Engine,
+ config: &Config,
+ line_config: &LineNumberConfig,
+ locator: Locator,
+ column: usize,
+ output: &mut Frame,
+) -> SourceResult<()> {
+ let mut locator = locator.split();
+
+ // Reset page-scoped line numbers if currently at the first column.
+ if column == 0 && line_config.scope == LineNumberingScope::Page {
+ let reset = layout_line_number_reset(engine, config, &mut locator)?;
+ output.push_frame(Point::zero(), reset);
+ }
+
+ // Find all line markers.
+ let mut lines = find_in_frame::<ParLineMarker>(output);
+ if lines.is_empty() {
+ return Ok(());
+ }
+
+ // Assume the line numbers aren't sorted by height. They must be sorted so
+ // we can deduplicate line numbers below based on vertical proximity.
+ lines.sort_by_key(|&(y, _)| y);
+
+ // Used for horizontal alignment.
+ let mut max_number_width = Abs::zero();
+
+ // This is used to skip lines that are too close together.
+ let mut prev_bottom = None;
+
+ // Buffer line number frames so we can align them horizontally later before
+ // placing, based on the width of the largest line number.
+ let mut line_numbers = vec![];
+
+ // Layout the lines.
+ for &(y, ref marker) in &lines {
+ if prev_bottom.is_some_and(|bottom| y < bottom) {
+ // Lines are too close together. Display as the same line number.
+ continue;
+ }
+
+ // Layout the number and record its width in search of the maximium.
+ let frame = layout_line_number(engine, config, &mut locator, &marker.numbering)?;
+
+ // Note that this line.y is larger than the previous due to sorting.
+ // Therefore, the check at the top of the loop ensures no line numbers
+ // will reasonably intersect with each other. We enforce a minimum
+ // spacing of 1pt between consecutive line numbers in case a zero-height
+ // frame is used.
+ prev_bottom = Some(y + frame.height().max(Abs::pt(1.0)));
+ max_number_width.set_max(frame.width());
+ line_numbers.push((y, marker, frame));
+ }
+
+ for (y, marker, frame) in line_numbers {
+ // The last column will always place line numbers at the end
+ // margin. This should become configurable in the future.
+ let margin = {
+ let opposite =
+ config.columns.count >= 2 && column + 1 == config.columns.count;
+ if opposite { OuterHAlignment::End } else { marker.number_margin }
+ .resolve(config.shared)
+ };
+
+ // Determine how much space to leave between the column and the number.
+ let clearance = match marker.number_clearance {
+ Smart::Auto => line_config.default_clearance,
+ Smart::Custom(rel) => rel.resolve(config.shared),
+ };
+
+ // Compute the base X position.
+ let x = match margin {
+ // Move the number to the left of the left edge (at 0pt) by the maximum
+ // width and the clearance.
+ FixedAlignment::Start => -max_number_width - clearance,
+ // Move the number to the right edge and add clearance.
+ FixedAlignment::End => output.width() + clearance,
+ // Can't happen due to `OuterHAlignment`.
+ FixedAlignment::Center => unreachable!(),
+ };
+
+ // Determine how much to shift the number due to its alignment.
+ let shift = {
+ let align = marker
+ .number_align
+ .map(|align| align.resolve(config.shared))
+ .unwrap_or_else(|| margin.inv());
+ align.position(max_number_width - frame.width())
+ };
+
+ // Compute the final position of the number and add it to the output.
+ let pos = Point::new(x + shift, y);
+ output.push_frame(pos, frame);
+ }
+
+ Ok(())
+}
+
+/// Creates a frame that resets the line number counter.
+fn layout_line_number_reset(
+ engine: &mut Engine,
+ config: &Config,
+ locator: &mut SplitLocator,
+) -> SourceResult<Frame> {
+ let counter = Counter::of(ParLineMarker::elem());
+ let update = CounterUpdate::Set(CounterState::init(false));
+ let content = counter.update(Span::detached(), update);
+ crate::layout_frame(
+ engine,
+ &content,
+ locator.next(&()),
+ config.shared,
+ Region::new(Axes::splat(Abs::zero()), Axes::splat(false)),
+ )
+}
+
+/// Layout the line number associated with the given line marker.
+///
+/// Produces a counter update and counter display with counter key
+/// `ParLineMarker`. We use `ParLineMarker` as it is an element which is not
+/// exposed to the user and we don't want to expose the line number counter at
+/// the moment, given that its semantics are inconsistent with that of normal
+/// counters (the counter is updated based on height and not on frame order /
+/// layer). When we find a solution to this, we should switch to a counter on
+/// `ParLine` instead, thus exposing the counter as `counter(par.line)` to the
+/// user.
+fn layout_line_number(
+ engine: &mut Engine,
+ config: &Config,
+ locator: &mut SplitLocator,
+ numbering: &Numbering,
+) -> SourceResult<Frame> {
+ let counter = Counter::of(ParLineMarker::elem());
+ let update = CounterUpdate::Step(NonZeroUsize::ONE);
+ let numbering = Smart::Custom(numbering.clone());
+
+ // Combine counter update and display into the content we'll layout.
+ let content = Content::sequence(vec![
+ counter.clone().update(Span::detached(), update),
+ CounterDisplayElem::new(counter, numbering, false).pack(),
+ ]);
+
+ // Layout the number.
+ let mut frame = crate::layout_frame(
+ engine,
+ &content,
+ locator.next(&()),
+ config.shared,
+ Region::new(Axes::splat(Abs::inf()), Axes::splat(false)),
+ )?;
+
+ // Ensure the baseline of the line number aligns with the line's baseline.
+ frame.translate(Point::with_y(-frame.baseline()));
+
+ Ok(frame)
+}
+
+/// Collect all matching elements and their vertical positions in the frame.
+///
+/// On each subframe we encounter, we add that subframe's position to `prev_y`,
+/// until we reach a tag, at which point we add the tag's position and finish.
+/// That gives us the absolute height of the tag from the start of the root
+/// frame.
+fn find_in_frame<T: NativeElement>(frame: &Frame) -> Vec<(Abs, Packed<T>)> {
+ let mut output = vec![];
+ find_in_frame_impl(&mut output, frame, Abs::zero());
+ output
+}
+
+/// Collect all matching elements and their vertical positions in the frames.
+fn find_in_frames<T: NativeElement>(frames: &[Frame]) -> Vec<(Abs, Packed<T>)> {
+ let mut output = vec![];
+ for frame in frames {
+ find_in_frame_impl(&mut output, frame, Abs::zero());
+ }
+ output
+}
+
+fn find_in_frame_impl<T: NativeElement>(
+ output: &mut Vec<(Abs, Packed<T>)>,
+ frame: &Frame,
+ y_offset: Abs,
+) {
+ for (pos, item) in frame.items() {
+ let y = y_offset + pos.y;
+ match item {
+ FrameItem::Group(group) => find_in_frame_impl(output, &group.frame, y),
+ FrameItem::Tag(Tag::Start(elem)) => {
+ if let Some(elem) = elem.to_packed::<T>() {
+ output.push((y, elem.clone()));
+ }
+ }
+ _ => {}
+ }
+ }
+}
diff --git a/crates/typst-layout/src/flow/distribute.rs b/crates/typst-layout/src/flow/distribute.rs
new file mode 100644
index 00000000..1852f7ca
--- /dev/null
+++ b/crates/typst-layout/src/flow/distribute.rs
@@ -0,0 +1,525 @@
+use typst_library::introspection::Tag;
+use typst_library::layout::{
+ Abs, Axes, FixedAlignment, Fr, Frame, FrameItem, Point, Region, Regions, Rel, Size,
+};
+use typst_utils::Numeric;
+
+use super::{
+ Child, Composer, FlowResult, LineChild, MultiChild, MultiSpill, PlacedChild,
+ SingleChild, Stop, Work,
+};
+
+/// Distributes as many children as fit from `composer.work` into the first
+/// region and returns the resulting frame.
+pub fn distribute(composer: &mut Composer, regions: Regions) -> FlowResult<Frame> {
+ let mut distributor = Distributor {
+ composer,
+ regions,
+ items: vec![],
+ sticky: None,
+ stickable: false,
+ };
+ let init = distributor.snapshot();
+ let forced = match distributor.run() {
+ Ok(()) => distributor.composer.work.done(),
+ Err(Stop::Finish(forced)) => forced,
+ Err(err) => return Err(err),
+ };
+ let region = Region::new(regions.size, regions.expand);
+ distributor.finalize(region, init, forced)
+}
+
+/// State for distribution.
+///
+/// See [Composer] regarding lifetimes.
+struct Distributor<'a, 'b, 'x, 'y, 'z> {
+ /// The composer that is used to handle insertions.
+ composer: &'z mut Composer<'a, 'b, 'x, 'y>,
+ /// Regions which are continously shrunk as new items are added.
+ regions: Regions<'z>,
+ /// Already laid out items, not yet aligned.
+ items: Vec<Item<'a, 'b>>,
+ /// A snapshot which can be restored to migrate a suffix of sticky blocks to
+ /// the next region.
+ sticky: Option<DistributionSnapshot<'a, 'b>>,
+ /// Whether there was at least one proper block. Otherwise, sticky blocks
+ /// are disabled (or else they'd keep being migrated).
+ stickable: bool,
+}
+
+/// A snapshot of the distribution state.
+struct DistributionSnapshot<'a, 'b> {
+ work: Work<'a, 'b>,
+ items: usize,
+}
+
+/// A laid out item in a distribution.
+enum Item<'a, 'b> {
+ /// An introspection tag.
+ Tag(&'a Tag),
+ /// Absolute spacing and its weakness level.
+ Abs(Abs, u8),
+ /// Fractional spacing or a fractional block.
+ Fr(Fr, Option<&'b SingleChild<'a>>),
+ /// A frame for a laid out line or block.
+ Frame(Frame, Axes<FixedAlignment>),
+ /// A frame for an absolutely (not floatingly) placed child.
+ Placed(Frame, &'b PlacedChild<'a>),
+}
+
+impl Item<'_, '_> {
+ /// Whether this item should be migrated to the next region if the region
+ /// consists solely of such items.
+ fn migratable(&self) -> bool {
+ match self {
+ Self::Tag(_) => true,
+ Self::Frame(frame, _) => {
+ frame.size().is_zero()
+ && frame.items().all(|(_, item)| {
+ matches!(item, FrameItem::Link(_, _) | FrameItem::Tag(_))
+ })
+ }
+ Self::Placed(_, placed) => !placed.float,
+ _ => false,
+ }
+ }
+}
+
+impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> {
+ /// Distributes content into the region.
+ fn run(&mut self) -> FlowResult<()> {
+ // First, handle spill of a breakable block.
+ if let Some(spill) = self.composer.work.spill.take() {
+ self.multi_spill(spill)?;
+ }
+
+ // If spill are taken care of, process children until no space is left
+ // or no children are left.
+ while let Some(child) = self.composer.work.head() {
+ self.child(child)?;
+ self.composer.work.advance();
+ }
+
+ Ok(())
+ }
+
+ /// Processes a single child.
+ ///
+ /// - Returns `Ok(())` if the child was successfully processed.
+ /// - Returns `Err(Stop::Finish)` if a region break should be triggered.
+ /// - Returns `Err(Stop::Relayout(_))` if the region needs to be relayouted
+ /// due to an insertion (float/footnote).
+ /// - Returns `Err(Stop::Error(_))` if there was a fatal error.
+ fn child(&mut self, child: &'b Child<'a>) -> FlowResult<()> {
+ match child {
+ Child::Tag(tag) => self.tag(tag),
+ Child::Rel(amount, weakness) => self.rel(*amount, *weakness),
+ Child::Fr(fr) => self.fr(*fr),
+ Child::Line(line) => self.line(line)?,
+ Child::Single(single) => self.single(single)?,
+ Child::Multi(multi) => self.multi(multi)?,
+ Child::Placed(placed) => self.placed(placed)?,
+ Child::Flush => self.flush()?,
+ Child::Break(weak) => self.break_(*weak)?,
+ }
+ Ok(())
+ }
+
+ /// Processes a tag.
+ fn tag(&mut self, tag: &'a Tag) {
+ self.composer.work.tags.push(tag);
+ }
+
+ /// Generate items for pending tags.
+ fn flush_tags(&mut self) {
+ if !self.composer.work.tags.is_empty() {
+ let tags = &mut self.composer.work.tags;
+ self.items.extend(tags.iter().copied().map(Item::Tag));
+ tags.clear();
+ }
+ }
+
+ /// Processes relative spacing.
+ fn rel(&mut self, amount: Rel<Abs>, weakness: u8) {
+ let amount = amount.relative_to(self.regions.base().y);
+ if weakness > 0 && !self.keep_spacing(amount, weakness) {
+ return;
+ }
+
+ self.regions.size.y -= amount;
+ self.items.push(Item::Abs(amount, weakness));
+ }
+
+ /// Processes fractional spacing.
+ fn fr(&mut self, fr: Fr) {
+ self.trim_spacing();
+ self.items.push(Item::Fr(fr, None));
+ }
+
+ /// Decides whether to keep weak spacing based on previous items. If there
+ /// is a preceding weak spacing, it might be patched in place.
+ fn keep_spacing(&mut self, amount: Abs, weakness: u8) -> bool {
+ for item in self.items.iter_mut().rev() {
+ match *item {
+ Item::Abs(prev_amount, prev_weakness @ 1..) => {
+ if weakness <= prev_weakness
+ && (weakness < prev_weakness || amount > prev_amount)
+ {
+ self.regions.size.y -= amount - prev_amount;
+ *item = Item::Abs(amount, weakness);
+ }
+ return false;
+ }
+ Item::Tag(_) | Item::Abs(..) | Item::Placed(..) => {}
+ Item::Fr(.., None) => return false,
+ Item::Frame(..) | Item::Fr(.., Some(_)) => return true,
+ }
+ }
+ false
+ }
+
+ /// Trims trailing weak spacing from the items.
+ fn trim_spacing(&mut self) {
+ for (i, item) in self.items.iter().enumerate().rev() {
+ match *item {
+ Item::Abs(amount, 1..) => {
+ self.regions.size.y += amount;
+ self.items.remove(i);
+ break;
+ }
+ Item::Tag(_) | Item::Abs(..) | Item::Placed(..) => {}
+ Item::Frame(..) | Item::Fr(..) => break,
+ }
+ }
+ }
+
+ /// The amount of trailing weak spacing.
+ fn weak_spacing(&mut self) -> Abs {
+ for item in self.items.iter().rev() {
+ match *item {
+ Item::Abs(amount, 1..) => return amount,
+ Item::Tag(_) | Item::Abs(..) | Item::Placed(..) => {}
+ Item::Frame(..) | Item::Fr(..) => break,
+ }
+ }
+ Abs::zero()
+ }
+
+ /// Processes a line of a paragraph.
+ fn line(&mut self, line: &'b LineChild) -> FlowResult<()> {
+ // If the line doesn't fit and a followup region may improve things,
+ // finish the region.
+ if !self.regions.size.y.fits(line.frame.height()) && self.regions.may_progress() {
+ return Err(Stop::Finish(false));
+ }
+
+ // If the line's need, which includes its own height and that of
+ // following lines grouped by widow/orphan prevention, does not fit into
+ // the current region, but does fit into the next region, finish the
+ // region.
+ if !self.regions.size.y.fits(line.need)
+ && self
+ .regions
+ .iter()
+ .nth(1)
+ .is_some_and(|region| region.y.fits(line.need))
+ {
+ return Err(Stop::Finish(false));
+ }
+
+ self.frame(line.frame.clone(), line.align, false, false)
+ }
+
+ /// Processes an unbreakable block.
+ fn single(&mut self, single: &'b SingleChild<'a>) -> FlowResult<()> {
+ // Lay out the block.
+ let frame = single.layout(
+ self.composer.engine,
+ Region::new(self.regions.base(), self.regions.expand),
+ )?;
+
+ // Handle fractionally sized blocks.
+ if let Some(fr) = single.fr {
+ self.composer.footnotes(&self.regions, &frame, Abs::zero(), false)?;
+ self.flush_tags();
+ self.items.push(Item::Fr(fr, Some(single)));
+ return Ok(());
+ }
+
+ // If the block doesn't fit and a followup region may improve things,
+ // finish the region.
+ if !self.regions.size.y.fits(frame.height()) && self.regions.may_progress() {
+ return Err(Stop::Finish(false));
+ }
+
+ self.frame(frame, single.align, single.sticky, false)
+ }
+
+ /// Processes a breakable block.
+ fn multi(&mut self, multi: &'b MultiChild<'a>) -> FlowResult<()> {
+ // Skip directly if the region is already (over)full. `line` and
+ // `single` implicitly do this through their `fits` checks.
+ if self.regions.is_full() {
+ return Err(Stop::Finish(false));
+ }
+
+ // Lay out the block.
+ let (frame, spill) = multi.layout(self.composer.engine, self.regions)?;
+ self.frame(frame, multi.align, multi.sticky, true)?;
+
+ // If the block didn't fully fit into the current region, save it into
+ // the `spill` and finish the region.
+ if let Some(spill) = spill {
+ self.composer.work.spill = Some(spill);
+ self.composer.work.advance();
+ return Err(Stop::Finish(false));
+ }
+
+ Ok(())
+ }
+
+ /// Processes spillover from a breakable block.
+ fn multi_spill(&mut self, spill: MultiSpill<'a, 'b>) -> FlowResult<()> {
+ // Skip directly if the region is already (over)full.
+ if self.regions.is_full() {
+ self.composer.work.spill = Some(spill);
+ return Err(Stop::Finish(false));
+ }
+
+ // Lay out the spilled remains.
+ let align = spill.align();
+ let (frame, spill) = spill.layout(self.composer.engine, self.regions)?;
+ self.frame(frame, align, false, true)?;
+
+ // If there's still more, save it into the `spill` and finish the
+ // region.
+ if let Some(spill) = spill {
+ self.composer.work.spill = Some(spill);
+ return Err(Stop::Finish(false));
+ }
+
+ Ok(())
+ }
+
+ /// Processes an in-flow frame, generated from a line or block.
+ fn frame(
+ &mut self,
+ frame: Frame,
+ align: Axes<FixedAlignment>,
+ sticky: bool,
+ breakable: bool,
+ ) -> FlowResult<()> {
+ if sticky {
+ // If the frame is sticky and we haven't remembered a preceding
+ // sticky element, make a checkpoint which we can restore should we
+ // end on this sticky element.
+ if self.stickable && self.sticky.is_none() {
+ self.sticky = Some(self.snapshot());
+ }
+ } else if !frame.is_empty() {
+ // If the frame isn't sticky, we can forget a previous snapshot.
+ self.stickable = true;
+ self.sticky = None;
+ }
+
+ // Handle footnotes.
+ self.composer
+ .footnotes(&self.regions, &frame, frame.height(), breakable)?;
+
+ // Push an item for the frame.
+ self.regions.size.y -= frame.height();
+ self.flush_tags();
+ self.items.push(Item::Frame(frame, align));
+ Ok(())
+ }
+
+ /// Processes an absolutely or floatingly placed child.
+ fn placed(&mut self, placed: &'b PlacedChild<'a>) -> FlowResult<()> {
+ if placed.float {
+ // If the element is floatingly placed, let the composer handle it.
+ // It might require relayout because the area available for
+ // distribution shrinks. We make the spacing occupied by weak
+ // spacing temporarily available again because it can collapse if it
+ // ends up at a break due to the float.
+ let weak_spacing = self.weak_spacing();
+ self.regions.size.y += weak_spacing;
+ self.composer.float(
+ placed,
+ &self.regions,
+ self.items.iter().any(|item| matches!(item, Item::Frame(..))),
+ )?;
+ self.regions.size.y -= weak_spacing;
+ } else {
+ let frame = placed.layout(self.composer.engine, self.regions.base())?;
+ self.composer.footnotes(&self.regions, &frame, Abs::zero(), true)?;
+ self.flush_tags();
+ self.items.push(Item::Placed(frame, placed));
+ }
+ Ok(())
+ }
+
+ /// Processes a float flush.
+ fn flush(&mut self) -> FlowResult<()> {
+ // If there are still pending floats, finish the region instead of
+ // adding more content to it.
+ if !self.composer.work.floats.is_empty() {
+ return Err(Stop::Finish(false));
+ }
+ Ok(())
+ }
+
+ /// Processes a column break.
+ fn break_(&mut self, weak: bool) -> FlowResult<()> {
+ // If there is a region to break into, break into it.
+ if (!weak || !self.items.is_empty())
+ && (!self.regions.backlog.is_empty() || self.regions.last.is_some())
+ {
+ self.composer.work.advance();
+ return Err(Stop::Finish(true));
+ }
+ Ok(())
+ }
+
+ /// Arranges the produced items into an output frame.
+ ///
+ /// This performs alignment and resolves fractional spacing and blocks.
+ fn finalize(
+ mut self,
+ region: Region,
+ init: DistributionSnapshot<'a, 'b>,
+ forced: bool,
+ ) -> FlowResult<Frame> {
+ if forced {
+ // If this is the very end of the flow, flush pending tags.
+ self.flush_tags();
+ } else if !self.items.is_empty() && self.items.iter().all(Item::migratable) {
+ // Restore the initial state of all items are migratable.
+ self.restore(init);
+ } else {
+ // If we ended on a sticky block, but are not yet at the end of
+ // the flow, restore the saved checkpoint to move the sticky
+ // suffix to the next region.
+ if let Some(snapshot) = self.sticky.take() {
+ self.restore(snapshot)
+ }
+ }
+
+ self.trim_spacing();
+
+ let mut frs = Fr::zero();
+ let mut used = Size::zero();
+ let mut has_fr_child = false;
+
+ // Determine the amount of used space and the sum of fractionals.
+ for item in &self.items {
+ match item {
+ Item::Abs(v, _) => used.y += *v,
+ Item::Fr(v, child) => {
+ frs += *v;
+ has_fr_child |= child.is_some();
+ }
+ Item::Frame(frame, _) => {
+ used.y += frame.height();
+ used.x.set_max(frame.width());
+ }
+ Item::Tag(_) | Item::Placed(..) => {}
+ }
+ }
+
+ // When we have fractional spacing, occupy the remaining space with it.
+ let mut fr_space = Abs::zero();
+ if frs.get() > 0.0 && region.size.y.is_finite() {
+ fr_space = region.size.y - used.y;
+ used.y = region.size.y;
+ }
+
+ // Lay out fractionally sized blocks.
+ let mut fr_frames = vec![];
+ if has_fr_child {
+ for item in &self.items {
+ let Item::Fr(v, Some(single)) = item else { continue };
+ let length = v.share(frs, fr_space);
+ let pod = Region::new(Size::new(region.size.x, length), region.expand);
+ let frame = single.layout(self.composer.engine, pod)?;
+ used.x.set_max(frame.width());
+ fr_frames.push(frame);
+ }
+ }
+
+ // Also consider the width of insertions for alignment.
+ if !region.expand.x {
+ used.x.set_max(self.composer.insertion_width());
+ }
+
+ // Determine the region's size.
+ let size = region.expand.select(region.size, used.min(region.size));
+ let free = size.y - used.y;
+
+ let mut output = Frame::soft(size);
+ let mut ruler = FixedAlignment::Start;
+ let mut offset = Abs::zero();
+ let mut fr_frames = fr_frames.into_iter();
+
+ // Position all items.
+ for item in self.items {
+ match item {
+ Item::Tag(tag) => {
+ let y = offset + ruler.position(free);
+ let pos = Point::with_y(y);
+ output.push(pos, FrameItem::Tag(tag.clone()));
+ }
+ Item::Abs(v, _) => {
+ offset += v;
+ }
+ Item::Fr(v, single) => {
+ let length = v.share(frs, fr_space);
+ if let Some(single) = single {
+ let frame = fr_frames.next().unwrap();
+ let x = single.align.x.position(size.x - frame.width());
+ let pos = Point::new(x, offset);
+ output.push_frame(pos, frame);
+ }
+ offset += length;
+ }
+ Item::Frame(frame, align) => {
+ ruler = ruler.max(align.y);
+
+ let x = align.x.position(size.x - frame.width());
+ let y = offset + ruler.position(free);
+ let pos = Point::new(x, y);
+ offset += frame.height();
+
+ output.push_frame(pos, frame);
+ }
+ Item::Placed(frame, placed) => {
+ let x = placed.align_x.position(size.x - frame.width());
+ let y = match placed.align_y.unwrap_or_default() {
+ Some(align) => align.position(size.y - frame.height()),
+ _ => offset + ruler.position(free),
+ };
+
+ let pos = Point::new(x, y)
+ + placed.delta.zip_map(size, Rel::relative_to).to_point();
+
+ output.push_frame(pos, frame);
+ }
+ }
+ }
+
+ Ok(output)
+ }
+
+ /// Create a snapshot of the work and items.
+ fn snapshot(&self) -> DistributionSnapshot<'a, 'b> {
+ DistributionSnapshot {
+ work: self.composer.work.clone(),
+ items: self.items.len(),
+ }
+ }
+
+ /// Restore a snapshot of the work and items.
+ fn restore(&mut self, snapshot: DistributionSnapshot<'a, 'b>) {
+ *self.composer.work = snapshot.work;
+ self.items.truncate(snapshot.items);
+ }
+}
diff --git a/crates/typst-layout/src/flow/mod.rs b/crates/typst-layout/src/flow/mod.rs
new file mode 100644
index 00000000..7cbec59a
--- /dev/null
+++ b/crates/typst-layout/src/flow/mod.rs
@@ -0,0 +1,381 @@
+//! Layout of content into a [`Frame`] or [`Fragment`].
+
+mod block;
+mod collect;
+mod compose;
+mod distribute;
+
+pub(crate) use self::block::unbreakable_pod;
+
+use std::collections::HashSet;
+use std::num::NonZeroUsize;
+use std::rc::Rc;
+
+use bumpalo::Bump;
+use comemo::{Track, Tracked, TrackedMut};
+use ecow::EcoVec;
+use typst_library::diag::{bail, At, SourceDiagnostic, SourceResult};
+use typst_library::engine::{Engine, Route, Sink, Traced};
+use typst_library::foundations::{Content, Packed, Resolve, StyleChain};
+use typst_library::introspection::{
+ Introspector, Location, Locator, LocatorLink, SplitLocator, Tag,
+};
+use typst_library::layout::{
+ Abs, ColumnsElem, Dir, Em, Fragment, Frame, PageElem, PlacementScope, Region,
+ Regions, Rel, Size,
+};
+use typst_library::model::{FootnoteElem, FootnoteEntry, LineNumberingScope, ParLine};
+use typst_library::routines::{Arenas, Pair, RealizationKind, Routines};
+use typst_library::text::TextElem;
+use typst_library::World;
+use typst_utils::{NonZeroExt, Numeric};
+
+use self::block::{layout_multi_block, layout_single_block};
+use self::collect::{
+ collect, Child, LineChild, MultiChild, MultiSpill, PlacedChild, SingleChild,
+};
+use self::compose::{compose, Composer};
+use self::distribute::distribute;
+
+/// Lays out content into a single region, producing a single frame.
+pub fn layout_frame(
+ engine: &mut Engine,
+ content: &Content,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ layout_fragment(engine, content, locator, styles, region.into())
+ .map(Fragment::into_frame)
+}
+
+/// Lays out content into multiple regions.
+///
+/// When laying out into just one region, prefer [`layout_frame`].
+pub fn layout_fragment(
+ engine: &mut Engine,
+ content: &Content,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+) -> SourceResult<Fragment> {
+ layout_fragment_impl(
+ engine.routines,
+ engine.world,
+ engine.introspector,
+ engine.traced,
+ TrackedMut::reborrow_mut(&mut engine.sink),
+ engine.route.track(),
+ content,
+ locator.track(),
+ styles,
+ regions,
+ NonZeroUsize::ONE,
+ Rel::zero(),
+ )
+}
+
+/// Layout the columns.
+///
+/// This is different from just laying out into column-sized regions as the
+/// columns can interact due to parent-scoped placed elements.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_columns(
+ elem: &Packed<ColumnsElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+) -> SourceResult<Fragment> {
+ layout_fragment_impl(
+ engine.routines,
+ engine.world,
+ engine.introspector,
+ engine.traced,
+ TrackedMut::reborrow_mut(&mut engine.sink),
+ engine.route.track(),
+ &elem.body,
+ locator.track(),
+ styles,
+ regions,
+ elem.count(styles),
+ elem.gutter(styles),
+ )
+}
+
+/// The cached, internal implementation of [`layout_fragment`].
+#[comemo::memoize]
+#[allow(clippy::too_many_arguments)]
+fn layout_fragment_impl(
+ routines: &Routines,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ traced: Tracked<Traced>,
+ sink: TrackedMut<Sink>,
+ route: Tracked<Route>,
+ content: &Content,
+ locator: Tracked<Locator>,
+ styles: StyleChain,
+ regions: Regions,
+ columns: NonZeroUsize,
+ column_gutter: Rel<Abs>,
+) -> SourceResult<Fragment> {
+ if !regions.size.x.is_finite() && regions.expand.x {
+ bail!(content.span(), "cannot expand into infinite width");
+ }
+ if !regions.size.y.is_finite() && regions.expand.y {
+ bail!(content.span(), "cannot expand into infinite height");
+ }
+
+ let link = LocatorLink::new(locator);
+ let mut locator = Locator::link(&link).split();
+ let mut engine = Engine {
+ routines,
+ world,
+ introspector,
+ traced,
+ sink,
+ route: Route::extend(route),
+ };
+
+ engine.route.check_layout_depth().at(content.span())?;
+
+ let arenas = Arenas::default();
+ let children = (engine.routines.realize)(
+ RealizationKind::Container,
+ &mut engine,
+ &mut locator,
+ &arenas,
+ content,
+ styles,
+ )?;
+
+ layout_flow(
+ &mut engine,
+ &children,
+ &mut locator,
+ styles,
+ regions,
+ columns,
+ column_gutter,
+ false,
+ )
+}
+
+/// Lays out realized content into regions, potentially with columns.
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn layout_flow(
+ engine: &mut Engine,
+ children: &[Pair],
+ locator: &mut SplitLocator,
+ shared: StyleChain,
+ mut regions: Regions,
+ columns: NonZeroUsize,
+ column_gutter: Rel<Abs>,
+ root: bool,
+) -> SourceResult<Fragment> {
+ // Prepare configuration that is shared across the whole flow.
+ let config = Config {
+ root,
+ shared,
+ columns: {
+ let mut count = columns.get();
+ if !regions.size.x.is_finite() {
+ count = 1;
+ }
+
+ let gutter = column_gutter.relative_to(regions.base().x);
+ let width = (regions.size.x - gutter * (count - 1) as f64) / count as f64;
+ let dir = TextElem::dir_in(shared);
+ ColumnConfig { count, width, gutter, dir }
+ },
+ footnote: FootnoteConfig {
+ separator: FootnoteEntry::separator_in(shared),
+ clearance: FootnoteEntry::clearance_in(shared),
+ gap: FootnoteEntry::gap_in(shared),
+ expand: regions.expand.x,
+ },
+ line_numbers: root.then(|| LineNumberConfig {
+ scope: ParLine::numbering_scope_in(shared),
+ default_clearance: {
+ let width = if PageElem::flipped_in(shared) {
+ PageElem::height_in(shared)
+ } else {
+ PageElem::width_in(shared)
+ };
+ (0.026 * width.unwrap_or_default())
+ .clamp(Em::new(0.75).resolve(shared), Em::new(2.5).resolve(shared))
+ },
+ }),
+ };
+
+ // Collect the elements into pre-processed children. These are much easier
+ // to handle than the raw elements.
+ let bump = Bump::new();
+ let children = collect(
+ engine,
+ &bump,
+ children,
+ locator.next(&()),
+ Size::new(config.columns.width, regions.full),
+ regions.expand.x,
+ )?;
+
+ let mut work = Work::new(&children);
+ let mut finished = vec![];
+
+ // This loop runs once per region produced by the flow layout.
+ loop {
+ let frame = compose(engine, &mut work, &config, locator.next(&()), regions)?;
+ finished.push(frame);
+
+ // Terminate the loop when everything is processed, though draining the
+ // backlog if necessary.
+ if work.done() && (!regions.expand.y || regions.backlog.is_empty()) {
+ break;
+ }
+
+ regions.next();
+ }
+
+ Ok(Fragment::frames(finished))
+}
+
+/// The work that is left to do by flow layout.
+///
+/// The lifetimes 'a and 'b are used across flow layout:
+/// - 'a is that of the content coming out of realization
+/// - 'b is that of the collected/prepared children
+#[derive(Clone)]
+struct Work<'a, 'b> {
+ /// Children that we haven't processed yet. This slice shrinks over time.
+ children: &'b [Child<'a>],
+ /// Leftovers from a breakable block.
+ spill: Option<MultiSpill<'a, 'b>>,
+ /// Queued floats that didn't fit in previous regions.
+ floats: EcoVec<&'b PlacedChild<'a>>,
+ /// Queued footnotes that didn't fit in previous regions.
+ footnotes: EcoVec<Packed<FootnoteElem>>,
+ /// Spilled frames of a footnote that didn't fully fit. Similar to `spill`.
+ footnote_spill: Option<std::vec::IntoIter<Frame>>,
+ /// Queued tags that will be attached to the next frame.
+ tags: EcoVec<&'a Tag>,
+ /// Identifies floats and footnotes that can be skipped if visited because
+ /// they were already handled and incorporated as column or page level
+ /// insertions.
+ skips: Rc<HashSet<Location>>,
+}
+
+impl<'a, 'b> Work<'a, 'b> {
+ /// Create the initial work state from a list of children.
+ fn new(children: &'b [Child<'a>]) -> Self {
+ Self {
+ children,
+ spill: None,
+ floats: EcoVec::new(),
+ footnotes: EcoVec::new(),
+ footnote_spill: None,
+ tags: EcoVec::new(),
+ skips: Rc::new(HashSet::new()),
+ }
+ }
+
+ /// Get the first unprocessed child, from the start of the slice.
+ fn head(&self) -> Option<&'b Child<'a>> {
+ self.children.first()
+ }
+
+ /// Mark the `head()` child as processed, advancing the slice by one.
+ fn advance(&mut self) {
+ self.children = &self.children[1..];
+ }
+
+ /// Whether all work is done. This means we can terminate flow layout.
+ fn done(&self) -> bool {
+ self.children.is_empty()
+ && self.spill.is_none()
+ && self.floats.is_empty()
+ && self.footnote_spill.is_none()
+ && self.footnotes.is_empty()
+ }
+
+ /// Add skipped floats and footnotes from the insertion areas to the skip
+ /// set.
+ fn extend_skips(&mut self, skips: &[Location]) {
+ if !skips.is_empty() {
+ Rc::make_mut(&mut self.skips).extend(skips.iter().copied());
+ }
+ }
+}
+
+/// Shared configuration for the whole flow.
+struct Config<'x> {
+ /// Whether this is the root flow, which can host footnotes and line
+ /// numbers.
+ root: bool,
+ /// The styles shared by the whole flow. This is used for footnotes and line
+ /// numbers.
+ shared: StyleChain<'x>,
+ /// Settings for columns.
+ columns: ColumnConfig,
+ /// Settings for footnotes.
+ footnote: FootnoteConfig,
+ /// Settings for line numbers.
+ line_numbers: Option<LineNumberConfig>,
+}
+
+/// Configuration of footnotes.
+struct FootnoteConfig {
+ /// The separator between flow content and footnotes. Typically a line.
+ separator: Content,
+ /// The amount of space left above the separator.
+ clearance: Abs,
+ /// The gap between footnote entries.
+ gap: Abs,
+ /// Whether horizontal expansion is enabled for footnotes.
+ expand: bool,
+}
+
+/// Configuration of columns.
+struct ColumnConfig {
+ /// The number of columns.
+ count: usize,
+ /// The width of each column.
+ width: Abs,
+ /// The amount of space between columns.
+ gutter: Abs,
+ /// The horizontal direction in which columns progress. Defined by
+ /// `text.dir`.
+ dir: Dir,
+}
+
+/// Configuration of line numbers.
+struct LineNumberConfig {
+ /// Where line numbers are reset.
+ scope: LineNumberingScope,
+ /// The default clearance for `auto`.
+ default_clearance: Abs,
+}
+
+/// The result type for flow layout.
+///
+/// The `Err(_)` variant incorporate control flow events for finishing and
+/// relayouting regions.
+type FlowResult<T> = Result<T, Stop>;
+
+/// A control flow event during flow layout.
+enum Stop {
+ /// Indicates that the current subregion should be finished. Can be caused
+ /// by a lack of space (`false`) or an explicit column break (`true`).
+ Finish(bool),
+ /// Indicates that the given scope should be relayouted.
+ Relayout(PlacementScope),
+ /// A fatal error.
+ Error(EcoVec<SourceDiagnostic>),
+}
+
+impl From<EcoVec<SourceDiagnostic>> for Stop {
+ fn from(error: EcoVec<SourceDiagnostic>) -> Self {
+ Stop::Error(error)
+ }
+}
diff --git a/crates/typst-layout/src/grid/cells.rs b/crates/typst-layout/src/grid/cells.rs
new file mode 100644
index 00000000..175e2183
--- /dev/null
+++ b/crates/typst-layout/src/grid/cells.rs
@@ -0,0 +1,1349 @@
+use std::num::NonZeroUsize;
+use std::sync::Arc;
+
+use ecow::eco_format;
+use typst_library::diag::{bail, At, Hint, HintedStrResult, HintedString, SourceResult};
+use typst_library::engine::Engine;
+use typst_library::foundations::{Content, Smart, StyleChain};
+use typst_library::introspection::Locator;
+use typst_library::layout::{
+ Abs, Alignment, Axes, Celled, Fragment, Length, Regions, Rel, ResolvedCelled, Sides,
+ Sizing,
+};
+use typst_library::visualize::{Paint, Stroke};
+use typst_syntax::Span;
+use typst_utils::NonZeroExt;
+
+use super::{Footer, Header, Line, Repeatable};
+
+/// Used for cell-like elements which are aware of their final properties in
+/// the table, and may have property overrides.
+pub trait ResolvableCell {
+ /// Resolves the cell's fields, given its coordinates and default grid-wide
+ /// fill, align, inset and stroke properties, plus the expected value of
+ /// the `breakable` field.
+ /// Returns a final Cell.
+ #[allow(clippy::too_many_arguments)]
+ fn resolve_cell<'a>(
+ self,
+ x: usize,
+ y: usize,
+ fill: &Option<Paint>,
+ align: Smart<Alignment>,
+ inset: Sides<Option<Rel<Length>>>,
+ stroke: Sides<Option<Option<Arc<Stroke<Abs>>>>>,
+ breakable: bool,
+ locator: Locator<'a>,
+ styles: StyleChain,
+ ) -> Cell<'a>;
+
+ /// Returns this cell's column override.
+ fn x(&self, styles: StyleChain) -> Smart<usize>;
+
+ /// Returns this cell's row override.
+ fn y(&self, styles: StyleChain) -> Smart<usize>;
+
+ /// The amount of columns spanned by this cell.
+ fn colspan(&self, styles: StyleChain) -> NonZeroUsize;
+
+ /// The amount of rows spanned by this cell.
+ fn rowspan(&self, styles: StyleChain) -> NonZeroUsize;
+
+ /// The cell's span, for errors.
+ fn span(&self) -> Span;
+}
+
+/// A grid item, possibly affected by automatic cell positioning. Can be either
+/// a line or a cell.
+pub enum ResolvableGridItem<T: ResolvableCell> {
+ /// A horizontal line in the grid.
+ HLine {
+ /// The row above which the horizontal line is drawn.
+ y: Smart<usize>,
+ start: usize,
+ end: Option<NonZeroUsize>,
+ stroke: Option<Arc<Stroke<Abs>>>,
+ /// The span of the corresponding line element.
+ span: Span,
+ /// The line's position. "before" here means on top of row `y`, while
+ /// "after" means below it.
+ position: LinePosition,
+ },
+ /// A vertical line in the grid.
+ VLine {
+ /// The column before which the vertical line is drawn.
+ x: Smart<usize>,
+ start: usize,
+ end: Option<NonZeroUsize>,
+ stroke: Option<Arc<Stroke<Abs>>>,
+ /// The span of the corresponding line element.
+ span: Span,
+ /// The line's position. "before" here means to the left of column `x`,
+ /// while "after" means to its right (both considering LTR).
+ position: LinePosition,
+ },
+ /// A cell in the grid.
+ Cell(T),
+}
+
+/// Represents a cell in CellGrid, to be laid out by GridLayouter.
+pub struct Cell<'a> {
+ /// The cell's body.
+ pub body: Content,
+ /// The cell's locator.
+ pub locator: Locator<'a>,
+ /// The cell's fill.
+ pub fill: Option<Paint>,
+ /// The amount of columns spanned by the cell.
+ pub colspan: NonZeroUsize,
+ /// The amount of rows spanned by the cell.
+ pub rowspan: NonZeroUsize,
+ /// The cell's stroke.
+ ///
+ /// We use an Arc to avoid unnecessary space usage when all sides are the
+ /// same, or when the strokes come from a common source.
+ pub stroke: Sides<Option<Arc<Stroke<Abs>>>>,
+ /// Which stroke sides were explicitly overridden by the cell, over the
+ /// grid's global stroke setting.
+ ///
+ /// This is used to define whether or not this cell's stroke sides should
+ /// have priority over adjacent cells' stroke sides, if those don't
+ /// override their own stroke properties (and thus have less priority when
+ /// defining with which stroke to draw grid lines around this cell).
+ pub stroke_overridden: Sides<bool>,
+ /// Whether rows spanned by this cell can be placed in different pages.
+ /// By default, a cell spanning only fixed-size rows is unbreakable, while
+ /// a cell spanning at least one `auto`-sized row is breakable.
+ pub breakable: bool,
+}
+
+impl<'a> Cell<'a> {
+ /// Create a simple cell given its body and its locator.
+ pub fn new(body: Content, locator: Locator<'a>) -> Self {
+ Self {
+ body,
+ locator,
+ fill: None,
+ colspan: NonZeroUsize::ONE,
+ rowspan: NonZeroUsize::ONE,
+ stroke: Sides::splat(None),
+ stroke_overridden: Sides::splat(false),
+ breakable: true,
+ }
+ }
+
+ /// Layout the cell into the given regions.
+ ///
+ /// The `disambiguator` indicates which instance of this cell this should be
+ /// layouted as. For normal cells, it is always `0`, but for headers and
+ /// footers, it indicates the index of the header/footer among all. See the
+ /// [`Locator`] docs for more details on the concepts behind this.
+ pub fn layout(
+ &self,
+ engine: &mut Engine,
+ disambiguator: usize,
+ styles: StyleChain,
+ regions: Regions,
+ ) -> SourceResult<Fragment> {
+ let mut locator = self.locator.relayout();
+ if disambiguator > 0 {
+ locator = locator.split().next_inner(disambiguator as u128);
+ }
+ crate::layout_fragment(engine, &self.body, locator, styles, regions)
+ }
+}
+
+/// Indicates whether the line should be drawn before or after the track with
+/// its index. This is mostly only relevant when gutter is used, since, then,
+/// the position after a track is not the same as before the next
+/// non-gutter track.
+#[derive(Copy, Clone, PartialEq, Eq)]
+pub enum LinePosition {
+ /// The line should be drawn before its track (e.g. hline on top of a row).
+ Before,
+ /// The line should be drawn after its track (e.g. hline below a row).
+ After,
+}
+
+/// A grid entry.
+pub enum Entry<'a> {
+ /// An entry which holds a cell.
+ Cell(Cell<'a>),
+ /// An entry which is merged with another cell.
+ Merged {
+ /// The index of the cell this entry is merged with.
+ parent: usize,
+ },
+}
+
+impl<'a> Entry<'a> {
+ /// Obtains the cell inside this entry, if this is not a merged cell.
+ fn as_cell(&self) -> Option<&Cell<'a>> {
+ match self {
+ Self::Cell(cell) => Some(cell),
+ Self::Merged { .. } => None,
+ }
+ }
+}
+
+/// Any grid child, which can be either a header or an item.
+pub enum ResolvableGridChild<T: ResolvableCell, I> {
+ Header { repeat: bool, span: Span, items: I },
+ Footer { repeat: bool, span: Span, items: I },
+ Item(ResolvableGridItem<T>),
+}
+
+/// A grid of cells, including the columns, rows, and cell data.
+pub struct CellGrid<'a> {
+ /// The grid cells.
+ pub entries: Vec<Entry<'a>>,
+ /// The column tracks including gutter tracks.
+ pub cols: Vec<Sizing>,
+ /// The row tracks including gutter tracks.
+ pub rows: Vec<Sizing>,
+ /// The vertical lines before each column, or on the end border.
+ /// Gutter columns are not included.
+ /// Contains up to 'cols_without_gutter.len() + 1' vectors of lines.
+ pub vlines: Vec<Vec<Line>>,
+ /// The horizontal lines on top of each row, or on the bottom border.
+ /// Gutter rows are not included.
+ /// Contains up to 'rows_without_gutter.len() + 1' vectors of lines.
+ pub hlines: Vec<Vec<Line>>,
+ /// The repeatable header of this grid.
+ pub header: Option<Repeatable<Header>>,
+ /// The repeatable footer of this grid.
+ pub footer: Option<Repeatable<Footer>>,
+ /// Whether this grid has gutters.
+ pub has_gutter: bool,
+}
+
+impl<'a> CellGrid<'a> {
+ /// Generates the cell grid, given the tracks and cells.
+ pub fn new(
+ tracks: Axes<&[Sizing]>,
+ gutter: Axes<&[Sizing]>,
+ cells: impl IntoIterator<Item = Cell<'a>>,
+ ) -> Self {
+ let entries = cells.into_iter().map(Entry::Cell).collect();
+ Self::new_internal(tracks, gutter, vec![], vec![], None, None, entries)
+ }
+
+ /// Resolves and positions all cells in the grid before creating it.
+ /// Allows them to keep track of their final properties and positions
+ /// and adjust their fields accordingly.
+ /// Cells must implement Clone as they will be owned. Additionally, they
+ /// must implement Default in order to fill positions in the grid which
+ /// weren't explicitly specified by the user with empty cells.
+ #[allow(clippy::too_many_arguments)]
+ pub fn resolve<T, C, I>(
+ tracks: Axes<&[Sizing]>,
+ gutter: Axes<&[Sizing]>,
+ locator: Locator<'a>,
+ children: C,
+ fill: &Celled<Option<Paint>>,
+ align: &Celled<Smart<Alignment>>,
+ inset: &Celled<Sides<Option<Rel<Length>>>>,
+ stroke: &ResolvedCelled<Sides<Option<Option<Arc<Stroke>>>>>,
+ engine: &mut Engine,
+ styles: StyleChain,
+ span: Span,
+ ) -> SourceResult<Self>
+ where
+ T: ResolvableCell + Default,
+ I: Iterator<Item = ResolvableGridItem<T>>,
+ C: IntoIterator<Item = ResolvableGridChild<T, I>>,
+ C::IntoIter: ExactSizeIterator,
+ {
+ let mut locator = locator.split();
+
+ // Number of content columns: Always at least one.
+ let c = tracks.x.len().max(1);
+
+ // Lists of lines.
+ // Horizontal lines are only pushed later to be able to check for row
+ // validity, since the amount of rows isn't known until all items were
+ // analyzed in the for loop below.
+ // We keep their spans so we can report errors later.
+ // The additional boolean indicates whether the hline had an automatic
+ // 'y' index, and is used to change the index of hlines at the top of a
+ // header or footer.
+ let mut pending_hlines: Vec<(Span, Line, bool)> = vec![];
+
+ // For consistency, only push vertical lines later as well.
+ let mut pending_vlines: Vec<(Span, Line)> = vec![];
+ let has_gutter = gutter.any(|tracks| !tracks.is_empty());
+
+ let mut header: Option<Header> = None;
+ let mut repeat_header = false;
+
+ // Stores where the footer is supposed to end, its span, and the
+ // actual footer structure.
+ let mut footer: Option<(usize, Span, Footer)> = None;
+ let mut repeat_footer = false;
+
+ // Resolves the breakability of a cell. Cells that span at least one
+ // auto-sized row or gutter are considered breakable.
+ let resolve_breakable = |y, rowspan| {
+ let auto = Sizing::Auto;
+ let zero = Sizing::Rel(Rel::zero());
+ tracks
+ .y
+ .iter()
+ .chain(std::iter::repeat(tracks.y.last().unwrap_or(&auto)))
+ .skip(y)
+ .take(rowspan)
+ .any(|row| row == &Sizing::Auto)
+ || gutter
+ .y
+ .iter()
+ .chain(std::iter::repeat(gutter.y.last().unwrap_or(&zero)))
+ .skip(y)
+ .take(rowspan - 1)
+ .any(|row_gutter| row_gutter == &Sizing::Auto)
+ };
+
+ // We can't just use the cell's index in the 'cells' vector to
+ // determine its automatic position, since cells could have arbitrary
+ // positions, so the position of a cell in 'cells' can differ from its
+ // final position in 'resolved_cells' (see below).
+ // Therefore, we use a counter, 'auto_index', to determine the position
+ // of the next cell with (x: auto, y: auto). It is only stepped when
+ // a cell with (x: auto, y: auto), usually the vast majority, is found.
+ let mut auto_index: usize = 0;
+
+ // We have to rebuild the grid to account for arbitrary positions.
+ // Create at least 'children.len()' positions, since there could be at
+ // least 'children.len()' cells (if no explicit lines were specified),
+ // even though some of them might be placed in arbitrary positions and
+ // thus cause the grid to expand.
+ // Additionally, make sure we allocate up to the next multiple of 'c',
+ // since each row will have 'c' cells, even if the last few cells
+ // weren't explicitly specified by the user.
+ // We apply '% c' twice so that the amount of cells potentially missing
+ // is zero when 'children.len()' is already a multiple of 'c' (thus
+ // 'children.len() % c' would be zero).
+ let children = children.into_iter();
+ let Some(child_count) = children.len().checked_add((c - children.len() % c) % c)
+ else {
+ bail!(span, "too many cells or lines were given")
+ };
+ let mut resolved_cells: Vec<Option<Entry>> = Vec::with_capacity(child_count);
+ for child in children {
+ let mut is_header = false;
+ let mut is_footer = false;
+ let mut child_start = usize::MAX;
+ let mut child_end = 0;
+ let mut child_span = Span::detached();
+ let mut start_new_row = false;
+ let mut first_index_of_top_hlines = usize::MAX;
+ let mut first_index_of_non_top_hlines = usize::MAX;
+
+ let (header_footer_items, simple_item) = match child {
+ ResolvableGridChild::Header { repeat, span, items, .. } => {
+ if header.is_some() {
+ bail!(span, "cannot have more than one header");
+ }
+
+ is_header = true;
+ child_span = span;
+ repeat_header = repeat;
+
+ // If any cell in the header is automatically positioned,
+ // have it skip to the next row. This is to avoid having a
+ // header after a partially filled row just add cells to
+ // that row instead of starting a new one.
+ // FIXME: Revise this approach when headers can start from
+ // arbitrary rows.
+ start_new_row = true;
+
+ // Any hlines at the top of the header will start at this
+ // index.
+ first_index_of_top_hlines = pending_hlines.len();
+
+ (Some(items), None)
+ }
+ ResolvableGridChild::Footer { repeat, span, items, .. } => {
+ if footer.is_some() {
+ bail!(span, "cannot have more than one footer");
+ }
+
+ is_footer = true;
+ child_span = span;
+ repeat_footer = repeat;
+
+ // If any cell in the footer is automatically positioned,
+ // have it skip to the next row. This is to avoid having a
+ // footer after a partially filled row just add cells to
+ // that row instead of starting a new one.
+ start_new_row = true;
+
+ // Any hlines at the top of the footer will start at this
+ // index.
+ first_index_of_top_hlines = pending_hlines.len();
+
+ (Some(items), None)
+ }
+ ResolvableGridChild::Item(item) => (None, Some(item)),
+ };
+
+ let items = header_footer_items
+ .into_iter()
+ .flatten()
+ .chain(simple_item.into_iter());
+ for item in items {
+ let cell = match item {
+ ResolvableGridItem::HLine {
+ y,
+ start,
+ end,
+ stroke,
+ span,
+ position,
+ } => {
+ let has_auto_y = y.is_auto();
+ let y = y.unwrap_or_else(|| {
+ // Avoid placing the hline inside consecutive
+ // rowspans occupying all columns, as it'd just
+ // disappear, at least when there's no column
+ // gutter.
+ skip_auto_index_through_fully_merged_rows(
+ &resolved_cells,
+ &mut auto_index,
+ c,
+ );
+
+ // When no 'y' is specified for the hline, we place
+ // it under the latest automatically positioned
+ // cell.
+ // The current value of the auto index is always
+ // the index of the latest automatically positioned
+ // cell placed plus one (that's what we do in
+ // 'resolve_cell_position'), so we subtract 1 to
+ // get that cell's index, and place the hline below
+ // its row. The exception is when the auto_index is
+ // 0, meaning no automatically positioned cell was
+ // placed yet. In that case, we place the hline at
+ // the top of the table.
+ //
+ // Exceptionally, the hline will be placed before
+ // the minimum auto index if the current auto index
+ // from previous iterations is smaller than the
+ // minimum it should have for the current grid
+ // child. Effectively, this means that a hline at
+ // the start of a header will always appear above
+ // that header's first row. Similarly for footers.
+ auto_index
+ .checked_sub(1)
+ .map_or(0, |last_auto_index| last_auto_index / c + 1)
+ });
+ if end.is_some_and(|end| end.get() < start) {
+ bail!(span, "line cannot end before it starts");
+ }
+ let line = Line { index: y, start, end, stroke, position };
+
+ // Since the amount of rows is dynamic, delay placing
+ // hlines until after all cells were placed so we can
+ // properly verify if they are valid. Note that we
+ // can't place hlines even if we already know they
+ // would be in a valid row, since it's possible that we
+ // pushed pending hlines in the same row as this one in
+ // previous iterations, and we need to ensure that
+ // hlines from previous iterations are pushed to the
+ // final vector of hlines first - the order of hlines
+ // must be kept, as this matters when determining which
+ // one "wins" in case of conflict. Pushing the current
+ // hline before we push pending hlines later would
+ // change their order!
+ pending_hlines.push((span, line, has_auto_y));
+ continue;
+ }
+ ResolvableGridItem::VLine {
+ x,
+ start,
+ end,
+ stroke,
+ span,
+ position,
+ } => {
+ let x = x.unwrap_or_else(|| {
+ // When no 'x' is specified for the vline, we place
+ // it after the latest automatically positioned
+ // cell.
+ // The current value of the auto index is always
+ // the index of the latest automatically positioned
+ // cell placed plus one (that's what we do in
+ // 'resolve_cell_position'), so we subtract 1 to
+ // get that cell's index, and place the vline after
+ // its column. The exception is when the auto_index
+ // is 0, meaning no automatically positioned cell
+ // was placed yet. In that case, we place the vline
+ // to the left of the table.
+ //
+ // Exceptionally, a vline is also placed to the
+ // left of the table if we should start a new row
+ // for the next automatically positioned cell.
+ // For example, this means that a vline at
+ // the beginning of a header will be placed to its
+ // left rather than after the previous
+ // automatically positioned cell. Same for footers.
+ auto_index
+ .checked_sub(1)
+ .filter(|_| !start_new_row)
+ .map_or(0, |last_auto_index| last_auto_index % c + 1)
+ });
+ if end.is_some_and(|end| end.get() < start) {
+ bail!(span, "line cannot end before it starts");
+ }
+ let line = Line { index: x, start, end, stroke, position };
+
+ // For consistency with hlines, we only push vlines to
+ // the final vector of vlines after processing every
+ // cell.
+ pending_vlines.push((span, line));
+ continue;
+ }
+ ResolvableGridItem::Cell(cell) => cell,
+ };
+ let cell_span = cell.span();
+ let colspan = cell.colspan(styles).get();
+ let rowspan = cell.rowspan(styles).get();
+ // Let's calculate the cell's final position based on its
+ // requested position.
+ let resolved_index = {
+ let cell_x = cell.x(styles);
+ let cell_y = cell.y(styles);
+ resolve_cell_position(
+ cell_x,
+ cell_y,
+ colspan,
+ rowspan,
+ &resolved_cells,
+ &mut auto_index,
+ &mut start_new_row,
+ c,
+ )
+ .at(cell_span)?
+ };
+ let x = resolved_index % c;
+ let y = resolved_index / c;
+
+ if colspan > c - x {
+ bail!(
+ cell_span,
+ "cell's colspan would cause it to exceed the available column(s)";
+ hint: "try placing the cell in another position or reducing its colspan"
+ )
+ }
+
+ let Some(largest_index) = c
+ .checked_mul(rowspan - 1)
+ .and_then(|full_rowspan_offset| {
+ resolved_index.checked_add(full_rowspan_offset)
+ })
+ .and_then(|last_row_pos| last_row_pos.checked_add(colspan - 1))
+ else {
+ bail!(
+ cell_span,
+ "cell would span an exceedingly large position";
+ hint: "try reducing the cell's rowspan or colspan"
+ )
+ };
+
+ // Let's resolve the cell so it can determine its own fields
+ // based on its final position.
+ let cell = cell.resolve_cell(
+ x,
+ y,
+ &fill.resolve(engine, styles, x, y)?,
+ align.resolve(engine, styles, x, y)?,
+ inset.resolve(engine, styles, x, y)?,
+ stroke.resolve(engine, styles, x, y)?,
+ resolve_breakable(y, rowspan),
+ locator.next(&cell_span),
+ styles,
+ );
+
+ if largest_index >= resolved_cells.len() {
+ // Ensure the length of the vector of resolved cells is
+ // always a multiple of 'c' by pushing full rows every
+ // time. Here, we add enough absent positions (later
+ // converted to empty cells) to ensure the last row in the
+ // new vector length is completely filled. This is
+ // necessary so that those positions, even if not
+ // explicitly used at the end, are eventually susceptible
+ // to show rules and receive grid styling, as they will be
+ // resolved as empty cells in a second loop below.
+ let Some(new_len) = largest_index
+ .checked_add(1)
+ .and_then(|new_len| new_len.checked_add((c - new_len % c) % c))
+ else {
+ bail!(cell_span, "cell position too large")
+ };
+
+ // Here, the cell needs to be placed in a position which
+ // doesn't exist yet in the grid (out of bounds). We will
+ // add enough absent positions for this to be possible.
+ // They must be absent as no cells actually occupy them
+ // (they can be overridden later); however, if no cells
+ // occupy them as we finish building the grid, then such
+ // positions will be replaced by empty cells.
+ resolved_cells.resize_with(new_len, || None);
+ }
+
+ // The vector is large enough to contain the cell, so we can
+ // just index it directly to access the position it will be
+ // placed in. However, we still need to ensure we won't try to
+ // place a cell where there already is one.
+ let slot = &mut resolved_cells[resolved_index];
+ if slot.is_some() {
+ bail!(
+ cell_span,
+ "attempted to place a second cell at column {x}, row {y}";
+ hint: "try specifying your cells in a different order"
+ );
+ }
+
+ *slot = Some(Entry::Cell(cell));
+
+ // Now, if the cell spans more than one row or column, we fill
+ // the spanned positions in the grid with Entry::Merged
+ // pointing to the original cell as its parent.
+ for rowspan_offset in 0..rowspan {
+ let spanned_y = y + rowspan_offset;
+ let first_row_index = resolved_index + c * rowspan_offset;
+ for (colspan_offset, slot) in resolved_cells[first_row_index..]
+ [..colspan]
+ .iter_mut()
+ .enumerate()
+ {
+ let spanned_x = x + colspan_offset;
+ if spanned_x == x && spanned_y == y {
+ // This is the parent cell.
+ continue;
+ }
+ if slot.is_some() {
+ bail!(
+ cell_span,
+ "cell would span a previously placed cell at column {spanned_x}, row {spanned_y}";
+ hint: "try specifying your cells in a different order or reducing the cell's rowspan or colspan"
+ )
+ }
+ *slot = Some(Entry::Merged { parent: resolved_index });
+ }
+ }
+
+ if is_header || is_footer {
+ // Ensure each cell in a header or footer is fully
+ // contained within it.
+ child_start = child_start.min(y);
+ child_end = child_end.max(y + rowspan);
+
+ if start_new_row && child_start <= auto_index.div_ceil(c) {
+ // No need to start a new row as we already include
+ // the row of the next automatically positioned cell in
+ // the header or footer.
+ start_new_row = false;
+ }
+
+ if !start_new_row {
+ // From now on, upcoming hlines won't be at the top of
+ // the child, as the first automatically positioned
+ // cell was placed.
+ first_index_of_non_top_hlines =
+ first_index_of_non_top_hlines.min(pending_hlines.len());
+ }
+ }
+ }
+
+ if (is_header || is_footer) && child_start == usize::MAX {
+ // Empty header/footer: consider the header/footer to be
+ // at the next empty row after the latest auto index.
+ auto_index = find_next_empty_row(&resolved_cells, auto_index, c);
+ child_start = auto_index.div_ceil(c);
+ child_end = child_start + 1;
+
+ if resolved_cells.len() <= c * child_start {
+ // Ensure the automatically chosen row actually exists.
+ resolved_cells.resize_with(c * (child_start + 1), || None);
+ }
+ }
+
+ if is_header {
+ if child_start != 0 {
+ bail!(
+ child_span,
+ "header must start at the first row";
+ hint: "remove any rows before the header"
+ );
+ }
+
+ header = Some(Header {
+ // Later on, we have to correct this number in case there
+ // is gutter. But only once all cells have been analyzed
+ // and the header has fully expanded in the fixup loop
+ // below.
+ end: child_end,
+ });
+ }
+
+ if is_footer {
+ // Only check if the footer is at the end later, once we know
+ // the final amount of rows.
+ footer = Some((
+ child_end,
+ child_span,
+ Footer {
+ // Later on, we have to correct this number in case there
+ // is gutter, but only once all cells have been analyzed
+ // and the header's and footer's exact boundaries are
+ // known. That is because the gutter row immediately
+ // before the footer might not be included as part of
+ // the footer if it is contained within the header.
+ start: child_start,
+ },
+ ));
+ }
+
+ if is_header || is_footer {
+ let amount_hlines = pending_hlines.len();
+ for (_, top_hline, has_auto_y) in pending_hlines
+ .get_mut(
+ first_index_of_top_hlines
+ ..first_index_of_non_top_hlines.min(amount_hlines),
+ )
+ .unwrap_or(&mut [])
+ {
+ if *has_auto_y {
+ // Move this hline to the top of the child, as it was
+ // placed before the first automatically positioned cell
+ // and had an automatic index.
+ top_hline.index = child_start;
+ }
+ }
+
+ // Next automatically positioned cell goes under this header.
+ // FIXME: Consider only doing this if the header has any fully
+ // automatically positioned cells. Otherwise,
+ // `resolve_cell_position` should be smart enough to skip
+ // upcoming headers.
+ // Additionally, consider that cells with just an 'x' override
+ // could end up going too far back and making previous
+ // non-header rows into header rows (maybe they should be
+ // placed at the first row that is fully empty or something).
+ // Nothing we can do when both 'x' and 'y' were overridden, of
+ // course.
+ // None of the above are concerns for now, as headers must
+ // start at the first row.
+ auto_index = auto_index.max(c * child_end);
+ }
+ }
+
+ // If the user specified cells occupying less rows than the given rows,
+ // we shall expand the grid so that it has at least the given amount of
+ // rows.
+ let Some(expected_total_cells) = c.checked_mul(tracks.y.len()) else {
+ bail!(span, "too many rows were specified");
+ };
+ let missing_cells = expected_total_cells.saturating_sub(resolved_cells.len());
+
+ // Fixup phase (final step in cell grid generation):
+ // 1. Replace absent entries by resolved empty cells, and produce a
+ // vector of 'Entry' from 'Option<Entry>'.
+ // 2. Add enough empty cells to the end of the grid such that it has at
+ // least the given amount of rows.
+ // 3. If any cells were added to the header's rows after the header's
+ // creation, ensure the header expands enough to accommodate them
+ // across all of their spanned rows. Same for the footer.
+ // 4. If any cells before the footer try to span it, error.
+ let resolved_cells = resolved_cells
+ .into_iter()
+ .chain(std::iter::repeat_with(|| None).take(missing_cells))
+ .enumerate()
+ .map(|(i, cell)| {
+ if let Some(cell) = cell {
+ if let Some(parent_cell) = cell.as_cell() {
+ if let Some(header) = &mut header
+ {
+ let y = i / c;
+ if y < header.end {
+ // Ensure the header expands enough such that
+ // all cells inside it, even those added later,
+ // are fully contained within the header.
+ // FIXME: check if start < y < end when start can
+ // be != 0.
+ // FIXME: when start can be != 0, decide what
+ // happens when a cell after the header placed
+ // above it tries to span the header (either
+ // error or expand upwards).
+ header.end = header.end.max(y + parent_cell.rowspan.get());
+ }
+ }
+
+ if let Some((end, footer_span, footer)) = &mut footer {
+ let x = i % c;
+ let y = i / c;
+ let cell_end = y + parent_cell.rowspan.get();
+ if y < footer.start && cell_end > footer.start {
+ // Don't allow a cell before the footer to span
+ // it. Surely, we could move the footer to
+ // start at where this cell starts, so this is
+ // more of a design choice, as it's unlikely
+ // for the user to intentionally include a cell
+ // before the footer spanning it but not
+ // being repeated with it.
+ bail!(
+ *footer_span,
+ "footer would conflict with a cell placed before it at column {x} row {y}";
+ hint: "try reducing that cell's rowspan or moving the footer"
+ );
+ }
+ if y >= footer.start && y < *end {
+ // Expand the footer to include all rows
+ // spanned by this cell, as it is inside the
+ // footer.
+ *end = (*end).max(cell_end);
+ }
+ }
+ }
+
+ Ok(cell)
+ } else {
+ let x = i % c;
+ let y = i / c;
+
+ // Ensure all absent entries are affected by show rules and
+ // grid styling by turning them into resolved empty cells.
+ let new_cell = T::default().resolve_cell(
+ x,
+ y,
+ &fill.resolve(engine, styles, x, y)?,
+ align.resolve(engine, styles, x, y)?,
+ inset.resolve(engine, styles, x, y)?,
+ stroke.resolve(engine, styles, x, y)?,
+ resolve_breakable(y, 1),
+ locator.next(&()),
+ styles,
+ );
+ Ok(Entry::Cell(new_cell))
+ }
+ })
+ .collect::<SourceResult<Vec<Entry>>>()?;
+
+ // Populate the final lists of lines.
+ // For each line type (horizontal or vertical), we keep a vector for
+ // every group of lines with the same index.
+ let mut vlines: Vec<Vec<Line>> = vec![];
+ let mut hlines: Vec<Vec<Line>> = vec![];
+ let row_amount = resolved_cells.len().div_ceil(c);
+
+ for (line_span, line, _) in pending_hlines {
+ let y = line.index;
+ if y > row_amount {
+ bail!(line_span, "cannot place horizontal line at invalid row {y}");
+ }
+ if y == row_amount && line.position == LinePosition::After {
+ bail!(
+ line_span,
+ "cannot place horizontal line at the 'bottom' position of the bottom border (y = {y})";
+ hint: "set the line's position to 'top' or place it at a smaller 'y' index"
+ );
+ }
+ let line = if line.position == LinePosition::After
+ && (!has_gutter || y + 1 == row_amount)
+ {
+ // Just place the line on top of the next row if
+ // there's no gutter and the line should be placed
+ // after the one with given index.
+ //
+ // Note that placing after the last row is also the same as
+ // just placing on the grid's bottom border, even with
+ // gutter.
+ Line {
+ index: y + 1,
+ position: LinePosition::Before,
+ ..line
+ }
+ } else {
+ line
+ };
+ let y = line.index;
+
+ if hlines.len() <= y {
+ hlines.resize_with(y + 1, Vec::new);
+ }
+ hlines[y].push(line);
+ }
+
+ for (line_span, line) in pending_vlines {
+ let x = line.index;
+ if x > c {
+ bail!(line_span, "cannot place vertical line at invalid column {x}");
+ }
+ if x == c && line.position == LinePosition::After {
+ bail!(
+ line_span,
+ "cannot place vertical line at the 'end' position of the end border (x = {c})";
+ hint: "set the line's position to 'start' or place it at a smaller 'x' index"
+ );
+ }
+ let line =
+ if line.position == LinePosition::After && (!has_gutter || x + 1 == c) {
+ // Just place the line before the next column if
+ // there's no gutter and the line should be placed
+ // after the one with given index.
+ //
+ // Note that placing after the last column is also the
+ // same as just placing on the grid's end border, even
+ // with gutter.
+ Line {
+ index: x + 1,
+ position: LinePosition::Before,
+ ..line
+ }
+ } else {
+ line
+ };
+ let x = line.index;
+
+ if vlines.len() <= x {
+ vlines.resize_with(x + 1, Vec::new);
+ }
+ vlines[x].push(line);
+ }
+
+ let header = header
+ .map(|mut header| {
+ // Repeat the gutter below a header (hence why we don't
+ // subtract 1 from the gutter case).
+ // Don't do this if there are no rows under the header.
+ if has_gutter {
+ // - 'header.end' is always 'last y + 1'. The header stops
+ // before that row.
+ // - Therefore, '2 * header.end' will be 2 * (last y + 1),
+ // which is the adjusted index of the row before which the
+ // header stops, meaning it will still stop right before it
+ // even with gutter thanks to the multiplication below.
+ // - This means that it will span all rows up to
+ // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates
+ // to the index of the gutter row right below the header,
+ // which is what we want (that gutter spacing should be
+ // repeated across pages to maintain uniformity).
+ header.end *= 2;
+
+ // If the header occupies the entire grid, ensure we don't
+ // include an extra gutter row when it doesn't exist, since
+ // the last row of the header is at the very bottom,
+ // therefore '2 * last y + 1' is not a valid index.
+ let row_amount = (2 * row_amount).saturating_sub(1);
+ header.end = header.end.min(row_amount);
+ }
+ header
+ })
+ .map(|header| {
+ if repeat_header {
+ Repeatable::Repeated(header)
+ } else {
+ Repeatable::NotRepeated(header)
+ }
+ });
+
+ let footer = footer
+ .map(|(footer_end, footer_span, mut footer)| {
+ if footer_end != row_amount {
+ bail!(footer_span, "footer must end at the last row");
+ }
+
+ let header_end =
+ header.as_ref().map(Repeatable::unwrap).map(|header| header.end);
+
+ if has_gutter {
+ // Convert the footer's start index to post-gutter coordinates.
+ footer.start *= 2;
+
+ // Include the gutter right before the footer, unless there is
+ // none, or the gutter is already included in the header (no
+ // rows between the header and the footer).
+ if header_end.map_or(true, |header_end| header_end != footer.start) {
+ footer.start = footer.start.saturating_sub(1);
+ }
+ }
+
+ if header_end.is_some_and(|header_end| header_end > footer.start) {
+ bail!(footer_span, "header and footer must not have common rows");
+ }
+
+ Ok(footer)
+ })
+ .transpose()?
+ .map(|footer| {
+ if repeat_footer {
+ Repeatable::Repeated(footer)
+ } else {
+ Repeatable::NotRepeated(footer)
+ }
+ });
+
+ Ok(Self::new_internal(
+ tracks,
+ gutter,
+ vlines,
+ hlines,
+ header,
+ footer,
+ resolved_cells,
+ ))
+ }
+
+ /// Generates the cell grid, given the tracks and resolved entries.
+ pub fn new_internal(
+ tracks: Axes<&[Sizing]>,
+ gutter: Axes<&[Sizing]>,
+ vlines: Vec<Vec<Line>>,
+ hlines: Vec<Vec<Line>>,
+ header: Option<Repeatable<Header>>,
+ footer: Option<Repeatable<Footer>>,
+ entries: Vec<Entry<'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 = entries.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();
+ }
+
+ Self {
+ cols,
+ rows,
+ entries,
+ vlines,
+ hlines,
+ header,
+ footer,
+ has_gutter,
+ }
+ }
+
+ /// Get the grid entry in column `x` and row `y`.
+ ///
+ /// Returns `None` if it's a gutter cell.
+ #[track_caller]
+ pub fn entry(&self, x: usize, y: usize) -> Option<&Entry<'a>> {
+ assert!(x < self.cols.len());
+ assert!(y < self.rows.len());
+
+ 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.entries.get((y / 2) * c + x / 2)
+ } else {
+ None
+ }
+ } else {
+ let c = self.cols.len();
+ self.entries.get(y * c + x)
+ }
+ }
+
+ /// Get the content of the cell in column `x` and row `y`.
+ ///
+ /// Returns `None` if it's a gutter cell or merged position.
+ #[track_caller]
+ pub fn cell(&self, x: usize, y: usize) -> Option<&Cell<'a>> {
+ self.entry(x, y).and_then(Entry::as_cell)
+ }
+
+ /// Returns the position of the parent cell of the grid entry at the given
+ /// position. It is guaranteed to have a non-gutter, non-merged cell at
+ /// the returned position, due to how the grid is built.
+ /// - If the entry at the given position is a cell, returns the given
+ /// position.
+ /// - If it is a merged cell, returns the parent cell's position.
+ /// - If it is a gutter cell, returns None.
+ #[track_caller]
+ pub fn parent_cell_position(&self, x: usize, y: usize) -> Option<Axes<usize>> {
+ self.entry(x, y).map(|entry| match entry {
+ Entry::Cell(_) => Axes::new(x, y),
+ Entry::Merged { parent } => {
+ let c = if self.has_gutter {
+ 1 + self.cols.len() / 2
+ } else {
+ self.cols.len()
+ };
+ let factor = if self.has_gutter { 2 } else { 1 };
+ Axes::new(factor * (*parent % c), factor * (*parent / c))
+ }
+ })
+ }
+
+ /// Returns the position of the actual parent cell of a merged position,
+ /// even if the given position is gutter, in which case we return the
+ /// parent of the nearest adjacent content cell which could possibly span
+ /// the given gutter position. If the given position is not a gutter cell,
+ /// then this function will return the same as `parent_cell_position` would.
+ /// If the given position is a gutter cell, but no cell spans it, returns
+ /// `None`.
+ ///
+ /// This is useful for lines. A line needs to check if a cell next to it
+ /// has a stroke override - even at a gutter position there could be a
+ /// stroke override, since a cell could be merged with two cells at both
+ /// ends of the gutter cell (e.g. to its left and to its right), and thus
+ /// that cell would impose a stroke under the gutter. This function allows
+ /// getting the position of that cell (which spans the given gutter
+ /// position, if it is gutter), if it exists; otherwise returns None (it's
+ /// gutter and no cell spans it).
+ #[track_caller]
+ pub fn effective_parent_cell_position(
+ &self,
+ x: usize,
+ y: usize,
+ ) -> Option<Axes<usize>> {
+ if self.has_gutter {
+ // If (x, y) is a gutter cell, we skip it (skip a gutter column and
+ // row) to the nearest adjacent content cell, in the direction
+ // which merged cells grow toward (increasing x and increasing y),
+ // such that we can verify if that adjacent cell is merged with the
+ // gutter cell by checking if its parent would come before (x, y).
+ // Otherwise, no cell is merged with this gutter cell, and we
+ // return None.
+ self.parent_cell_position(x + x % 2, y + y % 2)
+ .filter(|&parent| parent.x <= x && parent.y <= y)
+ } else {
+ self.parent_cell_position(x, y)
+ }
+ }
+
+ /// Checks if the track with the given index is gutter.
+ /// Does not check if the index is a valid track.
+ #[inline]
+ pub fn is_gutter_track(&self, index: usize) -> bool {
+ self.has_gutter && index % 2 == 1
+ }
+
+ /// Returns the effective colspan of a cell, considering the gutters it
+ /// might span if the grid has gutters.
+ #[inline]
+ pub fn effective_colspan_of_cell(&self, cell: &Cell) -> usize {
+ if self.has_gutter {
+ 2 * cell.colspan.get() - 1
+ } else {
+ cell.colspan.get()
+ }
+ }
+
+ /// Returns the effective rowspan of a cell, considering the gutters it
+ /// might span if the grid has gutters.
+ #[inline]
+ pub fn effective_rowspan_of_cell(&self, cell: &Cell) -> usize {
+ if self.has_gutter {
+ 2 * cell.rowspan.get() - 1
+ } else {
+ cell.rowspan.get()
+ }
+ }
+}
+
+/// Given a cell's requested x and y, the vector with the resolved cell
+/// positions, the `auto_index` counter (determines the position of the next
+/// `(auto, auto)` cell) and the amount of columns in the grid, returns the
+/// final index of this cell in the vector of resolved cells.
+///
+/// The `start_new_row` parameter is used to ensure that, if this cell is
+/// fully automatically positioned, it should start a new, empty row. This is
+/// useful for headers and footers, which must start at their own rows, without
+/// interference from previous cells.
+#[allow(clippy::too_many_arguments)]
+fn resolve_cell_position(
+ cell_x: Smart<usize>,
+ cell_y: Smart<usize>,
+ colspan: usize,
+ rowspan: usize,
+ resolved_cells: &[Option<Entry>],
+ auto_index: &mut usize,
+ start_new_row: &mut bool,
+ columns: usize,
+) -> HintedStrResult<usize> {
+ // Translates a (x, y) position to the equivalent index in the final cell vector.
+ // Errors if the position would be too large.
+ let cell_index = |x, y: usize| {
+ y.checked_mul(columns)
+ .and_then(|row_index| row_index.checked_add(x))
+ .ok_or_else(|| HintedString::from(eco_format!("cell position too large")))
+ };
+ match (cell_x, cell_y) {
+ // Fully automatic cell positioning. The cell did not
+ // request a coordinate.
+ (Smart::Auto, Smart::Auto) => {
+ // Let's find the first available position starting from the
+ // automatic position counter, searching in row-major order.
+ let mut resolved_index = *auto_index;
+ if *start_new_row {
+ resolved_index =
+ find_next_empty_row(resolved_cells, resolved_index, columns);
+
+ // Next cell won't have to start a new row if we just did that,
+ // in principle.
+ *start_new_row = false;
+ } else {
+ while let Some(Some(_)) = resolved_cells.get(resolved_index) {
+ // Skip any non-absent cell positions (`Some(None)`) to
+ // determine where this cell will be placed. An out of
+ // bounds position (thus `None`) is also a valid new
+ // position (only requires expanding the vector).
+ resolved_index += 1;
+ }
+ }
+
+ // Ensure the next cell with automatic position will be
+ // placed after this one (maybe not immediately after).
+ //
+ // The calculation below also affects the position of the upcoming
+ // automatically-positioned lines.
+ *auto_index = if colspan == columns {
+ // The cell occupies all columns, so no cells can be placed
+ // after it until all of its rows have been spanned.
+ resolved_index + colspan * rowspan
+ } else {
+ // The next cell will have to be placed at least after its
+ // spanned columns.
+ resolved_index + colspan
+ };
+
+ Ok(resolved_index)
+ }
+ // Cell has chosen at least its column.
+ (Smart::Custom(cell_x), cell_y) => {
+ if cell_x >= columns {
+ return Err(HintedString::from(eco_format!(
+ "cell could not be placed at invalid column {cell_x}"
+ )));
+ }
+ if let Smart::Custom(cell_y) = cell_y {
+ // Cell has chosen its exact position.
+ cell_index(cell_x, cell_y)
+ } else {
+ // Cell has only chosen its column.
+ // Let's find the first row which has that column available.
+ let mut resolved_y = 0;
+ while let Some(Some(_)) =
+ resolved_cells.get(cell_index(cell_x, resolved_y)?)
+ {
+ // Try each row until either we reach an absent position
+ // (`Some(None)`) or an out of bounds position (`None`),
+ // in which case we'd create a new row to place this cell in.
+ resolved_y += 1;
+ }
+ cell_index(cell_x, resolved_y)
+ }
+ }
+ // Cell has only chosen its row, not its column.
+ (Smart::Auto, Smart::Custom(cell_y)) => {
+ // Let's find the first column which has that row available.
+ let first_row_pos = cell_index(0, cell_y)?;
+ let last_row_pos = first_row_pos
+ .checked_add(columns)
+ .ok_or_else(|| eco_format!("cell position too large"))?;
+
+ (first_row_pos..last_row_pos)
+ .find(|possible_index| {
+ // Much like in the previous cases, we skip any occupied
+ // positions until we either reach an absent position
+ // (`Some(None)`) or an out of bounds position (`None`),
+ // in which case we can just expand the vector enough to
+ // place this cell. In either case, we found an available
+ // position.
+ !matches!(resolved_cells.get(*possible_index), Some(Some(_)))
+ })
+ .ok_or_else(|| {
+ eco_format!(
+ "cell could not be placed in row {cell_y} because it was full"
+ )
+ })
+ .hint("try specifying your cells in a different order")
+ }
+ }
+}
+
+/// Computes the index of the first cell in the next empty row in the grid,
+/// starting with the given initial index.
+fn find_next_empty_row(
+ resolved_cells: &[Option<Entry>],
+ initial_index: usize,
+ columns: usize,
+) -> usize {
+ let mut resolved_index = initial_index.next_multiple_of(columns);
+ while resolved_cells
+ .get(resolved_index..resolved_index + columns)
+ .is_some_and(|row| row.iter().any(Option::is_some))
+ {
+ // Skip non-empty rows.
+ resolved_index += columns;
+ }
+
+ resolved_index
+}
+
+/// Fully merged rows under the cell of latest auto index indicate rowspans
+/// occupying all columns, so we skip the auto index until the shortest rowspan
+/// ends, such that, in the resulting row, we will be able to place an
+/// automatically positioned cell - and, in particular, hlines under it. The
+/// idea is that an auto hline will be placed after the shortest such rowspan.
+/// Otherwise, the hline would just be placed under the first row of those
+/// rowspans and disappear (except at the presence of column gutter).
+fn skip_auto_index_through_fully_merged_rows(
+ resolved_cells: &[Option<Entry>],
+ auto_index: &mut usize,
+ columns: usize,
+) {
+ // If the auto index isn't currently at the start of a row, that means
+ // there's still at least one auto position left in the row, ignoring
+ // cells with manual positions, so we wouldn't have a problem in placing
+ // further cells or, in this case, hlines here.
+ if *auto_index % columns == 0 {
+ while resolved_cells
+ .get(*auto_index..*auto_index + columns)
+ .is_some_and(|row| {
+ row.iter().all(|entry| matches!(entry, Some(Entry::Merged { .. })))
+ })
+ {
+ *auto_index += columns;
+ }
+ }
+}
diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs
new file mode 100644
index 00000000..7c94617d
--- /dev/null
+++ b/crates/typst-layout/src/grid/layouter.rs
@@ -0,0 +1,1582 @@
+use std::fmt::Debug;
+
+use typst_library::diag::{bail, SourceResult};
+use typst_library::engine::Engine;
+use typst_library::foundations::{Resolve, StyleChain};
+use typst_library::layout::{
+ Abs, Axes, Dir, Fr, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Rel,
+ Size, Sizing,
+};
+use typst_library::text::TextElem;
+use typst_library::visualize::Geometry;
+use typst_syntax::Span;
+use typst_utils::{MaybeReverseIter, Numeric};
+
+use super::{
+ generate_line_segments, hline_stroke_at_column, vline_stroke_at_row, Cell, CellGrid,
+ LinePosition, LineSegment, Repeatable, Rowspan, UnbreakableRowGroup,
+};
+
+/// Performs grid layout.
+pub struct GridLayouter<'a> {
+ /// The grid of cells.
+ pub(super) grid: &'a CellGrid<'a>,
+ /// The regions to layout children into.
+ pub(super) regions: Regions<'a>,
+ /// The inherited styles.
+ pub(super) styles: StyleChain<'a>,
+ /// Resolved column sizes.
+ pub(super) rcols: Vec<Abs>,
+ /// The sum of `rcols`.
+ pub(super) width: Abs,
+ /// Resolve row sizes, by region.
+ pub(super) rrows: Vec<Vec<RowPiece>>,
+ /// Rows in the current region.
+ pub(super) lrows: Vec<Row>,
+ /// The amount of unbreakable rows remaining to be laid out in the
+ /// current unbreakable row group. While this is positive, no region breaks
+ /// should occur.
+ pub(super) unbreakable_rows_left: usize,
+ /// Rowspans not yet laid out because not all of their spanned rows were
+ /// laid out yet.
+ pub(super) rowspans: Vec<Rowspan>,
+ /// The initial size of the current region before we started subtracting.
+ pub(super) initial: Size,
+ /// Frames for finished regions.
+ pub(super) finished: Vec<Frame>,
+ /// Whether this is an RTL grid.
+ pub(super) is_rtl: bool,
+ /// The simulated header height.
+ /// This field is reset in `layout_header` and properly updated by
+ /// `layout_auto_row` and `layout_relative_row`, and should not be read
+ /// before all header rows are fully laid out. It is usually fine because
+ /// header rows themselves are unbreakable, and unbreakable rows do not
+ /// need to read this field at all.
+ pub(super) header_height: Abs,
+ /// The simulated footer height for this region.
+ /// The simulation occurs before any rows are laid out for a region.
+ pub(super) footer_height: Abs,
+ /// The span of the grid element.
+ pub(super) span: Span,
+}
+
+/// 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.
+pub(super) enum Row {
+ /// Finished row frame of auto or relative row with y index.
+ /// The last parameter indicates whether or not this is the last region
+ /// where this row is laid out, and it can only be false when a row uses
+ /// `layout_multi_row`, which in turn is only used by breakable auto rows.
+ Frame(Frame, usize, bool),
+ /// Fractional row with y index and disambiguator.
+ Fr(Fr, usize, usize),
+}
+
+impl Row {
+ /// Returns the `y` index of this row.
+ fn index(&self) -> usize {
+ match self {
+ Self::Frame(_, y, _) => *y,
+ Self::Fr(_, y, _) => *y,
+ }
+ }
+}
+
+impl<'a> GridLayouter<'a> {
+ /// Create a new grid layouter.
+ ///
+ /// This prepares grid layout by unifying content and gutter tracks.
+ pub fn new(
+ grid: &'a CellGrid<'a>,
+ regions: Regions<'a>,
+ styles: StyleChain<'a>,
+ span: Span,
+ ) -> Self {
+ // 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 {
+ grid,
+ regions,
+ styles,
+ rcols: vec![Abs::zero(); grid.cols.len()],
+ width: Abs::zero(),
+ rrows: vec![],
+ lrows: vec![],
+ unbreakable_rows_left: 0,
+ rowspans: vec![],
+ initial: regions.size,
+ finished: vec![],
+ is_rtl: TextElem::dir_in(styles) == Dir::RTL,
+ header_height: Abs::zero(),
+ footer_height: Abs::zero(),
+ span,
+ }
+ }
+
+ /// Determines the columns sizes and then layouts the grid row-by-row.
+ pub fn layout(mut self, engine: &mut Engine) -> SourceResult<Fragment> {
+ self.measure_columns(engine)?;
+
+ if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
+ // Ensure rows in the first region will be aware of the possible
+ // presence of the footer.
+ self.prepare_footer(footer, engine, 0)?;
+ if matches!(self.grid.header, None | Some(Repeatable::NotRepeated(_))) {
+ // No repeatable header, so we won't subtract it later.
+ self.regions.size.y -= self.footer_height;
+ }
+ }
+
+ for y in 0..self.grid.rows.len() {
+ if let Some(Repeatable::Repeated(header)) = &self.grid.header {
+ if y < header.end {
+ if y == 0 {
+ self.layout_header(header, engine, 0)?;
+ self.regions.size.y -= self.footer_height;
+ }
+ // Skip header rows during normal layout.
+ continue;
+ }
+ }
+
+ if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
+ if y >= footer.start {
+ if y == footer.start {
+ self.layout_footer(footer, engine, self.finished.len())?;
+ }
+ continue;
+ }
+ }
+
+ self.layout_row(y, engine, 0)?;
+ }
+
+ self.finish_region(engine, true)?;
+
+ // Layout any missing rowspans.
+ // There are only two possibilities for rowspans not yet laid out
+ // (usually, a rowspan is laid out as soon as its last row, or any row
+ // after it, is laid out):
+ // 1. The rowspan was fully empty and only spanned fully empty auto
+ // rows, which were all prevented from being laid out. Those rowspans
+ // are ignored by 'layout_rowspan', and are not of any concern.
+ //
+ // 2. The rowspan's last row was an auto row at the last region which
+ // was not laid out, and no other rows were laid out after it. Those
+ // might still need to be laid out, so we check for them.
+ for rowspan in std::mem::take(&mut self.rowspans) {
+ self.layout_rowspan(rowspan, None, engine)?;
+ }
+
+ self.render_fills_strokes()
+ }
+
+ /// Layout the given row.
+ pub(super) fn layout_row(
+ &mut self,
+ y: usize,
+ engine: &mut Engine,
+ disambiguator: usize,
+ ) -> SourceResult<()> {
+ // Skip to next region if current one is full, but only for content
+ // rows, not for gutter rows, and only if we aren't laying out an
+ // unbreakable group of rows.
+ let is_content_row = !self.grid.is_gutter_track(y);
+ if self.unbreakable_rows_left == 0 && self.regions.is_full() && is_content_row {
+ self.finish_region(engine, false)?;
+ }
+
+ if is_content_row {
+ // Gutter rows have no rowspans or possibly unbreakable cells.
+ self.check_for_rowspans(disambiguator, y);
+ self.check_for_unbreakable_rows(y, engine)?;
+ }
+
+ // Don't layout gutter rows at the top of a region.
+ if is_content_row || !self.lrows.is_empty() {
+ match self.grid.rows[y] {
+ Sizing::Auto => self.layout_auto_row(engine, disambiguator, y)?,
+ Sizing::Rel(v) => {
+ self.layout_relative_row(engine, disambiguator, v, y)?
+ }
+ Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y, disambiguator)),
+ }
+ }
+
+ self.unbreakable_rows_left = self.unbreakable_rows_left.saturating_sub(1);
+
+ Ok(())
+ }
+
+ /// Add lines and backgrounds.
+ fn render_fills_strokes(mut self) -> SourceResult<Fragment> {
+ let mut finished = std::mem::take(&mut self.finished);
+ let frame_amount = finished.len();
+ for ((frame_index, frame), rows) in
+ finished.iter_mut().enumerate().zip(&self.rrows)
+ {
+ if self.rcols.is_empty() || rows.is_empty() {
+ continue;
+ }
+
+ // Render grid lines.
+ // We collect lines into a vector before rendering so we can sort
+ // them based on thickness, such that the lines with largest
+ // thickness are drawn on top; and also so we can prepend all of
+ // them at once in the frame, as calling prepend() for each line,
+ // and thus pushing all frame items forward each time, would result
+ // in quadratic complexity.
+ let mut lines = vec![];
+
+ // Which line position to look for in the list of lines for a
+ // track, such that placing lines with those positions will
+ // correspond to placing them before the given track index.
+ //
+ // If the index represents a gutter track, this means the list of
+ // lines will actually correspond to the list of lines in the
+ // previous index, so we must look for lines positioned after the
+ // previous index, and not before, to determine which lines should
+ // be placed before gutter.
+ //
+ // Note that the maximum index is always an odd number when
+ // there's gutter, so we must check for it to ensure we don't give
+ // it the same treatment as a line before a gutter track.
+ let expected_line_position = |index, is_max_index: bool| {
+ if self.grid.is_gutter_track(index) && !is_max_index {
+ LinePosition::After
+ } else {
+ LinePosition::Before
+ }
+ };
+
+ // Render vertical lines.
+ // Render them first so horizontal lines have priority later.
+ for (x, dx) in points(self.rcols.iter().copied()).enumerate() {
+ let dx = if self.is_rtl { self.width - dx } else { dx };
+ let is_end_border = x == self.grid.cols.len();
+ let expected_vline_position = expected_line_position(x, is_end_border);
+
+ let vlines_at_column = self
+ .grid
+ .vlines
+ .get(if !self.grid.has_gutter {
+ x
+ } else if is_end_border {
+ // The end border has its own vector of lines, but
+ // dividing it by 2 and flooring would give us the
+ // vector of lines with the index of the last column.
+ // Add 1 so we get the border's lines.
+ x / 2 + 1
+ } else {
+ // If x is a gutter column, this will round down to the
+ // index of the previous content column, which is
+ // intentional - the only lines which can appear before
+ // a gutter column are lines for the previous column
+ // marked with "LinePosition::After". Therefore, we get
+ // the previous column's lines. Worry not, as
+ // 'generate_line_segments' will correctly filter lines
+ // based on their LinePosition for us.
+ //
+ // If x is a content column, this will correctly return
+ // its index before applying gutters, so nothing
+ // special here (lines with "LinePosition::After" would
+ // then be ignored for this column, as we are drawing
+ // lines before it, not after).
+ x / 2
+ })
+ .into_iter()
+ .flatten()
+ .filter(|line| line.position == expected_vline_position);
+
+ let tracks = rows.iter().map(|row| (row.y, row.height));
+
+ // Determine all different line segments we have to draw in
+ // this column, and convert them to points and shapes.
+ //
+ // Even a single, uniform line might generate more than one
+ // segment, if it happens to cross a colspan (over which it
+ // must not be drawn).
+ let segments = generate_line_segments(
+ self.grid,
+ tracks,
+ x,
+ vlines_at_column,
+ vline_stroke_at_row,
+ )
+ .map(|segment| {
+ let LineSegment { stroke, offset: dy, length, priority } = segment;
+ let stroke = (*stroke).clone().unwrap_or_default();
+ let thickness = stroke.thickness;
+ let half = thickness / 2.0;
+ let target = Point::with_y(length + thickness);
+ let vline = Geometry::Line(target).stroked(stroke);
+ (
+ thickness,
+ priority,
+ Point::new(dx, dy - half),
+ FrameItem::Shape(vline, self.span),
+ )
+ });
+
+ lines.extend(segments);
+ }
+
+ // Render horizontal lines.
+ // They are rendered second as they default to appearing on top.
+ // First, calculate their offsets from the top of the frame.
+ let hline_offsets = points(rows.iter().map(|piece| piece.height));
+
+ // Additionally, determine their indices (the indices of the
+ // rows they are drawn on top of). In principle, this will
+ // correspond to the rows' indices directly, except for the
+ // last hline index, which must be (amount of rows) in order to
+ // draw the table's bottom border.
+ let hline_indices = rows
+ .iter()
+ .map(|piece| piece.y)
+ .chain(std::iter::once(self.grid.rows.len()));
+
+ // Converts a row to the corresponding index in the vector of
+ // hlines.
+ let hline_index_of_row = |y: usize| {
+ if !self.grid.has_gutter {
+ y
+ } else if y == self.grid.rows.len() {
+ y / 2 + 1
+ } else {
+ // Check the vlines loop for an explanation regarding
+ // these index operations.
+ y / 2
+ }
+ };
+
+ let get_hlines_at = |y| {
+ self.grid
+ .hlines
+ .get(hline_index_of_row(y))
+ .map(Vec::as_slice)
+ .unwrap_or(&[])
+ };
+
+ let mut prev_y = None;
+ for (y, dy) in hline_indices.zip(hline_offsets) {
+ // Position of lines below the row index in the previous iteration.
+ let expected_prev_line_position = prev_y
+ .map(|prev_y| {
+ expected_line_position(
+ prev_y + 1,
+ prev_y + 1 == self.grid.rows.len(),
+ )
+ })
+ .unwrap_or(LinePosition::Before);
+
+ // FIXME: In the future, directly specify in 'self.rrows' when
+ // we place a repeated header rather than its original rows.
+ // That would let us remove most of those verbose checks, both
+ // in 'lines.rs' and here. Those checks also aren't fully
+ // accurate either, since they will also trigger when some rows
+ // have been removed between the header and what's below it.
+ let is_under_repeated_header = self
+ .grid
+ .header
+ .as_ref()
+ .and_then(Repeatable::as_repeated)
+ .zip(prev_y)
+ .is_some_and(|(header, prev_y)| {
+ // Note: 'y == header.end' would mean we're right below
+ // the NON-REPEATED header, so that case should return
+ // false.
+ prev_y < header.end && y > header.end
+ });
+
+ // If some grid rows were omitted between the previous resolved
+ // row and the current one, we ensure lines below the previous
+ // row don't "disappear" and are considered, albeit with less
+ // priority. However, don't do this when we're below a header,
+ // as it must have more priority instead of less, so it is
+ // chained later instead of before. The exception is when the
+ // last row in the header is removed, in which case we append
+ // both the lines under the row above us and also (later) the
+ // lines under the header's (removed) last row.
+ let prev_lines = prev_y
+ .filter(|prev_y| {
+ prev_y + 1 != y
+ && (!is_under_repeated_header
+ || self
+ .grid
+ .header
+ .as_ref()
+ .and_then(Repeatable::as_repeated)
+ .is_some_and(|header| prev_y + 1 != header.end))
+ })
+ .map(|prev_y| get_hlines_at(prev_y + 1))
+ .unwrap_or(&[]);
+
+ let expected_hline_position =
+ expected_line_position(y, y == self.grid.rows.len());
+
+ let hlines_at_y = get_hlines_at(y)
+ .iter()
+ .filter(|line| line.position == expected_hline_position);
+
+ let top_border_hlines = if prev_y.is_none() && y != 0 {
+ // For lines at the top of the region, give priority to
+ // the lines at the top border.
+ get_hlines_at(0)
+ } else {
+ &[]
+ };
+
+ let mut expected_header_line_position = LinePosition::Before;
+ let header_hlines = if let Some((Repeatable::Repeated(header), prev_y)) =
+ self.grid.header.as_ref().zip(prev_y)
+ {
+ if is_under_repeated_header
+ && (!self.grid.has_gutter
+ || matches!(
+ self.grid.rows[prev_y],
+ Sizing::Rel(length) if length.is_zero()
+ ))
+ {
+ // For lines below a header, give priority to the
+ // lines originally below the header rather than
+ // the lines of what's below the repeated header.
+ // However, no need to do that when we're laying
+ // out the header for the first time, since the
+ // lines being normally laid out then will be
+ // precisely the lines below the header.
+ //
+ // Additionally, we don't repeat lines above the row
+ // below the header when gutter is enabled, since, in
+ // that case, there will be a gutter row between header
+ // and content, so no lines should overlap. The
+ // exception is when the gutter at the end of the
+ // header has a size of zero, which happens when only
+ // column-gutter is specified, for example. In that
+ // case, we still repeat the line under the gutter.
+ expected_header_line_position = expected_line_position(
+ header.end,
+ header.end == self.grid.rows.len(),
+ );
+ get_hlines_at(header.end)
+ } else {
+ &[]
+ }
+ } else {
+ &[]
+ };
+
+ // The effective hlines to be considered at this row index are
+ // chained in order of increasing priority:
+ // 1. Lines from the row right above us, if needed;
+ // 2. Lines from the current row (usually, only those are
+ // present);
+ // 3. Lines from the top border (above the top cells, hence
+ // 'before' position only);
+ // 4. Lines from the header above us, if present.
+ let hlines_at_row =
+ prev_lines
+ .iter()
+ .filter(|line| line.position == expected_prev_line_position)
+ .chain(hlines_at_y)
+ .chain(
+ top_border_hlines
+ .iter()
+ .filter(|line| line.position == LinePosition::Before),
+ )
+ .chain(header_hlines.iter().filter(|line| {
+ line.position == expected_header_line_position
+ }));
+
+ let tracks = self.rcols.iter().copied().enumerate();
+
+ // Normally, given an hline above row y, the row above it is
+ // 'y - 1' (if y > 0). However, sometimes that's not true, for
+ // example if 'y - 1' is in a previous region, or if 'y - 1'
+ // was an empty auto row which was removed. Therefore, we tell
+ // the hlines at this index which row is actually above them in
+ // the laid out region so they can include that row's bottom
+ // strokes in the folding process.
+ let local_top_y = prev_y;
+
+ // When we're in the last region, the bottom border stroke
+ // doesn't necessarily gain priority like it does in previous
+ // regions.
+ let in_last_region = frame_index + 1 == frame_amount;
+
+ // Determine all different line segments we have to draw in
+ // this row, and convert them to points and shapes.
+ let segments = generate_line_segments(
+ self.grid,
+ tracks,
+ y,
+ hlines_at_row,
+ |grid, y, x, stroke| {
+ hline_stroke_at_column(
+ grid,
+ rows,
+ local_top_y,
+ in_last_region,
+ y,
+ x,
+ stroke,
+ )
+ },
+ )
+ .map(|segment| {
+ let LineSegment { stroke, offset: dx, length, priority } = segment;
+ let stroke = (*stroke).clone().unwrap_or_default();
+ let thickness = stroke.thickness;
+ let half = thickness / 2.0;
+ let dx = if self.is_rtl { self.width - dx - length } else { dx };
+ let target = Point::with_x(length + thickness);
+ let hline = Geometry::Line(target).stroked(stroke);
+ (
+ thickness,
+ priority,
+ Point::new(dx - half, dy),
+ FrameItem::Shape(hline, self.span),
+ )
+ });
+
+ // Draw later (after we sort all lines below.)
+ lines.extend(segments);
+
+ prev_y = Some(y);
+ }
+
+ // Sort by increasing thickness, so that we draw larger strokes
+ // on top. When the thickness is the same, sort by priority.
+ //
+ // Sorting by thickness avoids layering problems where a smaller
+ // hline appears "inside" a larger vline. When both have the same
+ // size, hlines are drawn on top (since the sort is stable, and
+ // they are pushed later).
+ lines.sort_by_key(|(thickness, priority, ..)| (*thickness, *priority));
+
+ // Render cell backgrounds.
+ // We collect them into a vector so they can all be prepended at
+ // once to the frame, together with lines.
+ let mut fills = vec![];
+
+ // Reverse with RTL so that later columns start first.
+ let mut dx = Abs::zero();
+ for (x, &col) in self.rcols.iter().enumerate().rev_if(self.is_rtl) {
+ let mut dy = Abs::zero();
+ for row in rows {
+ // We want to only draw the fill starting at the parent
+ // positions of cells. However, sometimes the parent
+ // position is absent from the current region, either
+ // because the first few rows of a rowspan were empty auto
+ // rows and thus removed from layout, or because the parent
+ // cell was in a previous region (in which case we'd want
+ // to draw its fill again, in the current region).
+ // Therefore, we first analyze the parent position to see
+ // if the current row would be the first row spanned by the
+ // parent cell in this region. If so, this means we have to
+ // start drawing the cell's fill here. If not, we ignore
+ // the position `(x, row.y)`, as its fill will already have
+ // been rendered before.
+ //
+ // Note: In the case of gutter rows, we have to check the
+ // row below before discarding them fully, because a
+ // gutter row might be the first row spanned by a rowspan
+ // in this region (e.g. if the first row was empty and
+ // therefore removed), so its fill could start in that
+ // gutter row. That's why we use
+ // 'effective_parent_cell_position'.
+ let parent = self
+ .grid
+ .effective_parent_cell_position(x, row.y)
+ .filter(|parent| {
+ // Ensure this is the first column spanned by the
+ // cell before drawing its fill, otherwise we
+ // already rendered its fill in a previous
+ // iteration of the outer loop (and/or this is a
+ // gutter column, which we ignore).
+ //
+ // Additionally, we should only draw the fill when
+ // this row is the local parent Y for this cell,
+ // that is, the first row spanned by the cell's
+ // parent in this region, because if the parent
+ // cell's fill was already drawn in a previous
+ // region, we must render it again in later regions
+ // spanned by that cell. Note that said condition
+ // always holds when the current cell has a rowspan
+ // of 1 and we're not currently at a gutter row.
+ parent.x == x
+ && (parent.y == row.y
+ || rows
+ .iter()
+ .find(|row| row.y >= parent.y)
+ .is_some_and(|first_spanned_row| {
+ first_spanned_row.y == row.y
+ }))
+ });
+
+ if let Some(parent) = parent {
+ let cell = self.grid.cell(parent.x, parent.y).unwrap();
+ let fill = cell.fill.clone();
+ if let Some(fill) = fill {
+ let rowspan = self.grid.effective_rowspan_of_cell(cell);
+ let height = if rowspan == 1 {
+ row.height
+ } else {
+ rows.iter()
+ .filter(|row| {
+ (parent.y..parent.y + rowspan).contains(&row.y)
+ })
+ .map(|row| row.height)
+ .sum()
+ };
+ let width = self.cell_spanned_width(cell, x);
+ // In the grid, cell colspans expand to the right,
+ // so we're at the leftmost (lowest 'x') column
+ // spanned by the cell. However, in RTL, cells
+ // expand to the left. Therefore, without the
+ // offset below, cell fills would start at the
+ // rightmost visual position of a cell and extend
+ // over to unrelated columns to the right in RTL.
+ // We avoid this by ensuring the fill starts at the
+ // very left of the cell, even with colspan > 1.
+ let offset =
+ if self.is_rtl { -width + col } else { Abs::zero() };
+ let pos = Point::new(dx + offset, dy);
+ let size = Size::new(width, height);
+ let rect = Geometry::Rect(size).filled(fill);
+ fills.push((pos, FrameItem::Shape(rect, self.span)));
+ }
+ }
+ dy += row.height;
+ }
+ dx += col;
+ }
+
+ // Now we render each fill and stroke by prepending to the frame,
+ // such that both appear below cell contents. Fills come first so
+ // that they appear below lines.
+ frame.prepend_multiple(
+ fills
+ .into_iter()
+ .chain(lines.into_iter().map(|(_, _, point, shape)| (point, shape))),
+ );
+ }
+
+ Ok(Fragment::frames(finished))
+ }
+
+ /// Determine all column sizes.
+ fn measure_columns(&mut self, engine: &mut Engine) -> 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.grid.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(engine, 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(())
+ }
+
+ /// Total width spanned by the cell (among resolved columns).
+ /// Includes spanned gutter columns.
+ pub(super) fn cell_spanned_width(&self, cell: &Cell, x: usize) -> Abs {
+ let colspan = self.grid.effective_colspan_of_cell(cell);
+ self.rcols.iter().skip(x).take(colspan).sum()
+ }
+
+ /// Measure the size that is available to auto columns.
+ fn measure_auto_columns(
+ &mut self,
+ engine: &mut Engine,
+ available: Abs,
+ ) -> SourceResult<(Abs, usize)> {
+ let mut auto = Abs::zero();
+ let mut count = 0;
+ let all_frac_cols = self
+ .grid
+ .cols
+ .iter()
+ .enumerate()
+ .filter(|(_, col)| col.is_fractional())
+ .map(|(x, _)| x)
+ .collect::<Vec<_>>();
+
+ // 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.grid.cols.iter().enumerate() {
+ if col != Sizing::Auto {
+ continue;
+ }
+
+ let mut resolved = Abs::zero();
+ for y in 0..self.grid.rows.len() {
+ // We get the parent cell in case this is a merged position.
+ let Some(parent) = self.grid.parent_cell_position(x, y) else {
+ continue;
+ };
+ if parent.y != y {
+ // Don't check the width of rowspans more than once.
+ continue;
+ }
+ let cell = self.grid.cell(parent.x, parent.y).unwrap();
+ let colspan = self.grid.effective_colspan_of_cell(cell);
+ if colspan > 1 {
+ let last_spanned_auto_col = self
+ .grid
+ .cols
+ .iter()
+ .enumerate()
+ .skip(parent.x)
+ .take(colspan)
+ .rev()
+ .find(|(_, col)| **col == Sizing::Auto)
+ .map(|(x, _)| x);
+
+ if last_spanned_auto_col != Some(x) {
+ // A colspan only affects the size of the last spanned
+ // auto column.
+ continue;
+ }
+ }
+
+ if colspan > 1
+ && self.regions.size.x.is_finite()
+ && !all_frac_cols.is_empty()
+ && all_frac_cols
+ .iter()
+ .all(|x| (parent.x..parent.x + colspan).contains(x))
+ {
+ // Additionally, as a heuristic, a colspan won't affect the
+ // size of auto columns if it already spans all fractional
+ // columns, since those would already expand to provide all
+ // remaining available after auto column sizing to that
+ // cell. However, this heuristic is only valid in finite
+ // regions (pages without 'auto' width), since otherwise
+ // the fractional columns don't expand at all.
+ continue;
+ }
+
+ // Sum the heights of spanned rows to find the expected
+ // available height for the cell, unless it spans a fractional
+ // or auto column.
+ let rowspan = self.grid.effective_rowspan_of_cell(cell);
+ let height = self
+ .grid
+ .rows
+ .iter()
+ .skip(y)
+ .take(rowspan)
+ .try_fold(Abs::zero(), |acc, col| {
+ // For relative rows, we can already resolve the correct
+ // base and for auto and fr we could only guess anyway.
+ match col {
+ Sizing::Rel(v) => Some(
+ acc + v
+ .resolve(self.styles)
+ .relative_to(self.regions.base().y),
+ ),
+ _ => None,
+ }
+ })
+ .unwrap_or_else(|| self.regions.base().y);
+
+ // Don't expand this auto column more than the cell actually
+ // needs. To do this, we check how much the other, previously
+ // resolved columns provide to the cell in terms of width
+ // (if it is a colspan), and subtract this from its expected
+ // width when comparing with other cells in this column. Note
+ // that, since this is the last auto column spanned by this
+ // cell, all other auto columns will already have been resolved
+ // and will be considered.
+ // Only fractional columns will be excluded from this
+ // calculation, which can lead to auto columns being expanded
+ // unnecessarily when cells span both a fractional column and
+ // an auto column. One mitigation for this is the heuristic
+ // used above to not expand the last auto column spanned by a
+ // cell if it spans all fractional columns in a finite region.
+ let already_covered_width = self.cell_spanned_width(cell, parent.x);
+
+ let size = Size::new(available, height);
+ let pod = Region::new(size, Axes::splat(false));
+ let frame = cell.layout(engine, 0, self.styles, pod.into())?.into_frame();
+ resolved.set_max(frame.width() - already_covered_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.grid.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.grid.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.grid.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,
+ engine: &mut Engine,
+ disambiguator: usize,
+ 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(
+ engine,
+ disambiguator,
+ y,
+ true,
+ self.unbreakable_rows_left,
+ None,
+ )? {
+ Some(resolved) => resolved,
+ None => {
+ self.finish_region(engine, false)?;
+ self.measure_auto_row(
+ engine,
+ disambiguator,
+ y,
+ false,
+ self.unbreakable_rows_left,
+ None,
+ )?
+ .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(engine, disambiguator, first, y)?;
+ self.push_row(frame, y, true);
+
+ if self
+ .grid
+ .header
+ .as_ref()
+ .and_then(Repeatable::as_repeated)
+ .is_some_and(|header| y < header.end)
+ {
+ // Add to header height.
+ self.header_height += first;
+ }
+
+ 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 ((i, region), target) in self
+ .regions
+ .iter()
+ .enumerate()
+ .zip(&mut resolved[..len - 1])
+ .skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize)
+ {
+ // Subtract header and footer heights from the region height when
+ // it's not the first.
+ target.set_max(
+ region.y
+ - if i > 0 {
+ self.header_height + self.footer_height
+ } else {
+ Abs::zero()
+ },
+ );
+ }
+
+ // Layout into multiple regions.
+ let fragment = self.layout_multi_row(engine, disambiguator, &resolved, y)?;
+ let len = fragment.len();
+ for (i, frame) in fragment.into_iter().enumerate() {
+ self.push_row(frame, y, i + 1 == len);
+ if i + 1 < len {
+ self.finish_region(engine, false)?;
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Measure the regions sizes of an auto row. The option is always `Some(_)`
+ /// if `can_skip` is false.
+ /// If `unbreakable_rows_left` is positive, this function shall only return
+ /// a single frame. Useful when an unbreakable rowspan crosses this auto
+ /// row.
+ /// The `row_group_data` option is used within the unbreakable row group
+ /// simulator to predict the height of the auto row if previous rows in the
+ /// group were placed in the same region.
+ pub(super) fn measure_auto_row(
+ &self,
+ engine: &mut Engine,
+ disambiguator: usize,
+ y: usize,
+ can_skip: bool,
+ unbreakable_rows_left: usize,
+ row_group_data: Option<&UnbreakableRowGroup>,
+ ) -> SourceResult<Option<Vec<Abs>>> {
+ let breakable = unbreakable_rows_left == 0;
+ let mut resolved: Vec<Abs> = vec![];
+ let mut pending_rowspans: Vec<(usize, usize, Vec<Abs>)> = vec![];
+
+ for x in 0..self.rcols.len() {
+ // Get the parent cell in case this is a merged position.
+ let Some(parent) = self.grid.parent_cell_position(x, y) else {
+ // Skip gutter columns.
+ continue;
+ };
+ if parent.x != x {
+ // Only check the height of a colspan once.
+ continue;
+ }
+ // The parent cell is never a gutter or merged position.
+ let cell = self.grid.cell(parent.x, parent.y).unwrap();
+ let rowspan = self.grid.effective_rowspan_of_cell(cell);
+
+ if rowspan > 1 {
+ let last_spanned_auto_row = self
+ .grid
+ .rows
+ .iter()
+ .enumerate()
+ .skip(parent.y)
+ .take(rowspan)
+ .rev()
+ .find(|(_, &row)| row == Sizing::Auto)
+ .map(|(y, _)| y);
+
+ if last_spanned_auto_row != Some(y) {
+ // A rowspan should only affect the height of its last
+ // spanned auto row.
+ continue;
+ }
+ }
+
+ let measurement_data = self.prepare_auto_row_cell_measurement(
+ parent,
+ cell,
+ breakable,
+ row_group_data,
+ );
+ let size = Axes::new(measurement_data.width, measurement_data.height);
+ let backlog =
+ measurement_data.backlog.unwrap_or(&measurement_data.custom_backlog);
+
+ let pod = if !breakable {
+ // Force cell to fit into a single region when the row is
+ // unbreakable, even when it is a breakable rowspan, as a best
+ // effort.
+ let mut pod: Regions = Region::new(size, self.regions.expand).into();
+ pod.full = measurement_data.full;
+
+ if measurement_data.frames_in_previous_regions > 0 {
+ // Best effort to conciliate a breakable rowspan which
+ // started at a previous region going through an
+ // unbreakable auto row. Ensure it goes through previously
+ // laid out regions, but stops at this one when measuring.
+ pod.backlog = backlog;
+ }
+
+ pod
+ } else {
+ // This row is breakable, so measure the cell normally, with
+ // the initial height and backlog determined previously.
+ let mut pod = self.regions;
+ pod.size = size;
+ pod.backlog = backlog;
+ pod.full = measurement_data.full;
+ pod.last = measurement_data.last;
+
+ pod
+ };
+
+ let frames =
+ cell.layout(engine, disambiguator, self.styles, pod)?.into_frames();
+
+ // Skip the first region if one cell in it is empty. Then,
+ // remeasure.
+ if let Some([first, rest @ ..]) =
+ frames.get(measurement_data.frames_in_previous_regions..)
+ {
+ if can_skip
+ && breakable
+ && first.is_empty()
+ && rest.iter().any(|frame| !frame.is_empty())
+ {
+ return Ok(None);
+ }
+ }
+
+ // Skip frames from previous regions if applicable.
+ let mut sizes = frames
+ .iter()
+ .skip(measurement_data.frames_in_previous_regions)
+ .map(|frame| frame.height())
+ .collect::<Vec<_>>();
+
+ // Don't expand this row more than the cell needs.
+ // To figure out how much height the cell needs, we must first
+ // subtract, from the cell's expected height, the already resolved
+ // heights of its spanned rows. Note that this is the last spanned
+ // auto row, so all previous auto rows were already resolved, as
+ // well as fractional rows in previous regions.
+ // Additionally, we subtract the heights of fixed-size rows which
+ // weren't laid out yet, since those heights won't change in
+ // principle.
+ // Upcoming fractional rows are ignored.
+ // Upcoming gutter rows might be removed, so we need to simulate
+ // them.
+ if rowspan > 1 {
+ let should_simulate = self.prepare_rowspan_sizes(
+ y,
+ &mut sizes,
+ cell,
+ parent.y,
+ rowspan,
+ unbreakable_rows_left,
+ &measurement_data,
+ );
+
+ if should_simulate {
+ // Rowspan spans gutter and is breakable. We'll need to
+ // run a simulation to predict how much this auto row needs
+ // to expand so that the rowspan's contents fit into the
+ // table.
+ pending_rowspans.push((parent.y, rowspan, sizes));
+ continue;
+ }
+ }
+
+ let mut sizes = sizes.into_iter();
+
+ 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);
+ }
+
+ // Simulate the upcoming regions in order to predict how much we need
+ // to expand this auto row for rowspans which span gutter.
+ if !pending_rowspans.is_empty() {
+ self.simulate_and_measure_rowspans_in_auto_row(
+ y,
+ &mut resolved,
+ &pending_rowspans,
+ unbreakable_rows_left,
+ row_group_data,
+ disambiguator,
+ engine,
+ )?;
+ }
+
+ debug_assert!(breakable || resolved.len() <= 1);
+
+ 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,
+ engine: &mut Engine,
+ disambiguator: usize,
+ v: Rel<Length>,
+ y: usize,
+ ) -> SourceResult<()> {
+ let resolved = v.resolve(self.styles).relative_to(self.regions.base().y);
+ let frame = self.layout_single_row(engine, disambiguator, resolved, y)?;
+
+ if self
+ .grid
+ .header
+ .as_ref()
+ .and_then(Repeatable::as_repeated)
+ .is_some_and(|header| y < header.end)
+ {
+ // Add to header height.
+ self.header_height += resolved;
+ }
+
+ // Skip to fitting region, but only if we aren't part of an unbreakable
+ // row group. We use 'in_last_with_offset' so our 'in_last' call
+ // properly considers that a header and a footer would be added on each
+ // region break.
+ let height = frame.height();
+ while self.unbreakable_rows_left == 0
+ && !self.regions.size.y.fits(height)
+ && !in_last_with_offset(self.regions, self.header_height + self.footer_height)
+ {
+ self.finish_region(engine, false)?;
+
+ // Don't skip multiple regions for gutter and don't push a row.
+ if self.grid.is_gutter_track(y) {
+ return Ok(());
+ }
+ }
+
+ self.push_row(frame, y, true);
+
+ Ok(())
+ }
+
+ /// Layout a row with fixed height and return its frame.
+ fn layout_single_row(
+ &mut self,
+ engine: &mut Engine,
+ disambiguator: usize,
+ height: Abs,
+ y: usize,
+ ) -> SourceResult<Frame> {
+ if !self.width.is_finite() {
+ bail!(self.span, "cannot create grid with infinite width");
+ }
+
+ if !height.is_finite() {
+ bail!(self.span, "cannot create grid with infinite height");
+ }
+
+ let mut output = Frame::soft(Size::new(self.width, height));
+ let mut pos = Point::zero();
+
+ // Reverse the column order when using RTL.
+ for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) {
+ if let Some(cell) = self.grid.cell(x, y) {
+ // Rowspans have a separate layout step
+ if cell.rowspan.get() == 1 {
+ let width = self.cell_spanned_width(cell, x);
+ let size = Size::new(width, height);
+ let mut pod: Regions = Region::new(size, Axes::splat(true)).into();
+ if self.grid.rows[y] == Sizing::Auto
+ && self.unbreakable_rows_left == 0
+ {
+ // Cells at breakable auto rows have lengths relative
+ // to the entire page, unlike cells in unbreakable auto
+ // rows.
+ pod.full = self.regions.full;
+ }
+ let frame = cell
+ .layout(engine, disambiguator, self.styles, pod)?
+ .into_frame();
+ let mut pos = pos;
+ if self.is_rtl {
+ // In the grid, cell colspans expand to the right,
+ // so we're at the leftmost (lowest 'x') column
+ // spanned by the cell. However, in RTL, cells
+ // expand to the left. Therefore, without the
+ // offset below, the cell's contents would be laid out
+ // starting at its rightmost visual position and extend
+ // over to unrelated cells to its right in RTL.
+ // We avoid this by ensuring the rendered cell starts at
+ // the very left of the cell, even with colspan > 1.
+ let offset = -width + rcol;
+ pos.x += offset;
+ }
+ output.push_frame(pos, frame);
+ }
+ }
+
+ pos.x += rcol;
+ }
+
+ Ok(output)
+ }
+
+ /// Layout a row spanning multiple regions.
+ fn layout_multi_row(
+ &mut self,
+ engine: &mut Engine,
+ disambiguator: usize,
+ heights: &[Abs],
+ y: usize,
+ ) -> SourceResult<Fragment> {
+ // Prepare frames.
+ let mut outputs: Vec<_> = heights
+ .iter()
+ .map(|&h| Frame::soft(Size::new(self.width, h)))
+ .collect();
+
+ // Prepare regions.
+ let size = Size::new(self.width, heights[0]);
+ let mut pod: Regions = Region::new(size, Axes::splat(true)).into();
+ 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().rev_if(self.is_rtl) {
+ if let Some(cell) = self.grid.cell(x, y) {
+ // Rowspans have a separate layout step
+ if cell.rowspan.get() == 1 {
+ let width = self.cell_spanned_width(cell, x);
+ pod.size.x = width;
+
+ // Push the layouted frames into the individual output frames.
+ let fragment =
+ cell.layout(engine, disambiguator, self.styles, pod)?;
+ for (output, frame) in outputs.iter_mut().zip(fragment) {
+ let mut pos = pos;
+ if self.is_rtl {
+ let offset = -width + rcol;
+ pos.x += offset;
+ }
+ output.push_frame(pos, frame);
+ }
+ }
+ }
+
+ pos.x += rcol;
+ }
+
+ Ok(Fragment::frames(outputs))
+ }
+
+ /// Push a row frame into the current region.
+ /// The `is_last` parameter must be `true` if this is the last frame which
+ /// will be pushed for this particular row. It can be `false` for rows
+ /// spanning multiple regions.
+ fn push_row(&mut self, frame: Frame, y: usize, is_last: bool) {
+ self.regions.size.y -= frame.height();
+ self.lrows.push(Row::Frame(frame, y, is_last));
+ }
+
+ /// Finish rows for one region.
+ pub(super) fn finish_region(
+ &mut self,
+ engine: &mut Engine,
+ last: bool,
+ ) -> SourceResult<()> {
+ if self
+ .lrows
+ .last()
+ .is_some_and(|row| self.grid.is_gutter_track(row.index()))
+ {
+ // Remove the last row in the region if it is a gutter row.
+ self.lrows.pop().unwrap();
+ }
+
+ // If no rows other than the footer have been laid out so far, and
+ // there are rows beside the footer, then don't lay it out at all.
+ // This check doesn't apply, and is thus overridden, when there is a
+ // header.
+ let mut footer_would_be_orphan = self.lrows.is_empty()
+ && !in_last_with_offset(
+ self.regions,
+ self.header_height + self.footer_height,
+ )
+ && self
+ .grid
+ .footer
+ .as_ref()
+ .and_then(Repeatable::as_repeated)
+ .is_some_and(|footer| footer.start != 0);
+
+ if let Some(Repeatable::Repeated(header)) = &self.grid.header {
+ if self.grid.rows.len() > header.end
+ && self
+ .grid
+ .footer
+ .as_ref()
+ .and_then(Repeatable::as_repeated)
+ .map_or(true, |footer| footer.start != header.end)
+ && self.lrows.last().is_some_and(|row| row.index() < header.end)
+ && !in_last_with_offset(
+ self.regions,
+ self.header_height + self.footer_height,
+ )
+ {
+ // Header and footer would be alone in this region, but there are more
+ // rows beyond the header and the footer. Push an empty region.
+ self.lrows.clear();
+ footer_would_be_orphan = true;
+ }
+ }
+
+ let mut laid_out_footer_start = None;
+ if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
+ // Don't layout the footer if it would be alone with the header in
+ // the page, and don't layout it twice.
+ if !footer_would_be_orphan
+ && self.lrows.iter().all(|row| row.index() < footer.start)
+ {
+ laid_out_footer_start = Some(footer.start);
+ self.layout_footer(footer, engine, self.finished.len())?;
+ }
+ }
+
+ // 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::soft(size);
+ let mut pos = Point::zero();
+ let mut rrows = vec![];
+ let current_region = self.finished.len();
+
+ // Place finished rows and layout fractional rows.
+ for row in std::mem::take(&mut self.lrows) {
+ let (frame, y, is_last) = match row {
+ Row::Frame(frame, y, is_last) => (frame, y, is_last),
+ Row::Fr(v, y, disambiguator) => {
+ let remaining = self.regions.full - used;
+ let height = v.share(fr, remaining);
+ (self.layout_single_row(engine, disambiguator, height, y)?, y, true)
+ }
+ };
+
+ let height = frame.height();
+
+ // Ensure rowspans which span this row will have enough space to
+ // be laid out over it later.
+ for rowspan in self
+ .rowspans
+ .iter_mut()
+ .filter(|rowspan| (rowspan.y..rowspan.y + rowspan.rowspan).contains(&y))
+ .filter(|rowspan| {
+ rowspan.max_resolved_row.map_or(true, |max_row| y > max_row)
+ })
+ {
+ // If the first region wasn't defined yet, it will have the
+ // initial value of usize::MAX, so we can set it to the current
+ // region's index.
+ if rowspan.first_region > current_region {
+ rowspan.first_region = current_region;
+ // The rowspan starts at this region, precisely at this
+ // row. In other regions, it will start at dy = 0.
+ rowspan.dy = pos.y;
+ // When we layout the rowspan later, the full size of the
+ // pod must be equal to the full size of the first region
+ // it appears in.
+ rowspan.region_full = self.regions.full;
+ }
+ let amount_missing_heights = (current_region + 1)
+ .saturating_sub(rowspan.heights.len() + rowspan.first_region);
+
+ // Ensure the vector of heights is long enough such that the
+ // last height is the one for the current region.
+ rowspan
+ .heights
+ .extend(std::iter::repeat(Abs::zero()).take(amount_missing_heights));
+
+ // Ensure that, in this region, the rowspan will span at least
+ // this row.
+ *rowspan.heights.last_mut().unwrap() += height;
+
+ if is_last {
+ // Do not extend the rowspan through this row again, even
+ // if it is repeated in a future region.
+ rowspan.max_resolved_row = Some(y);
+ }
+ }
+
+ // We use a for loop over indices to avoid borrow checking
+ // problems (we need to mutate the rowspans vector, so we can't
+ // have an iterator actively borrowing it). We keep a separate
+ // 'i' variable so we can step the counter back after removing
+ // a rowspan (see explanation below).
+ let mut i = 0;
+ while let Some(rowspan) = self.rowspans.get(i) {
+ // Layout any rowspans which end at this row, but only if this is
+ // this row's last frame (to avoid having the rowspan stop being
+ // laid out at the first frame of the row).
+ // Any rowspans ending before this row are laid out even
+ // on this row's first frame.
+ if laid_out_footer_start.map_or(true, |footer_start| {
+ // If this is a footer row, then only lay out this rowspan
+ // if the rowspan is contained within the footer.
+ y < footer_start || rowspan.y >= footer_start
+ }) && (rowspan.y + rowspan.rowspan < y + 1
+ || rowspan.y + rowspan.rowspan == y + 1 && is_last)
+ {
+ // Rowspan ends at this or an earlier row, so we take
+ // it from the rowspans vector and lay it out.
+ // It's safe to pass the current region as a possible
+ // region for the rowspan to be laid out in, even if
+ // the rowspan's last row was at an earlier region,
+ // because the rowspan won't have an entry for this
+ // region in its 'heights' vector if it doesn't span
+ // any rows in this region.
+ //
+ // Here we don't advance the index counter ('i') because
+ // a new element we haven't checked yet in this loop
+ // will take the index of the now removed element, so
+ // we have to check the same index again in the next
+ // iteration.
+ let rowspan = self.rowspans.remove(i);
+ self.layout_rowspan(rowspan, Some((&mut output, &rrows)), engine)?;
+ } else {
+ i += 1;
+ }
+ }
+
+ output.push_frame(pos, frame);
+ rrows.push(RowPiece { height, y });
+ pos.y += height;
+ }
+
+ self.finish_region_internal(output, rrows);
+
+ if !last {
+ let disambiguator = self.finished.len();
+ if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
+ self.prepare_footer(footer, engine, disambiguator)?;
+ }
+
+ if let Some(Repeatable::Repeated(header)) = &self.grid.header {
+ // Add a header to the new region.
+ self.layout_header(header, engine, disambiguator)?;
+ }
+
+ // Ensure rows don't try to overrun the footer.
+ self.regions.size.y -= self.footer_height;
+ }
+
+ Ok(())
+ }
+
+ /// Advances to the next region, registering the finished output and
+ /// resolved rows for the current region in the appropriate vectors.
+ pub(super) fn finish_region_internal(
+ &mut self,
+ output: Frame,
+ resolved_rows: Vec<RowPiece>,
+ ) {
+ self.finished.push(output);
+ self.rrows.push(resolved_rows);
+ self.regions.next();
+ self.initial = self.regions.size;
+ }
+}
+
+/// Turn an iterator of extents into an iterator of offsets before, in between,
+/// and after the extents, e.g. [10mm, 5mm] -> [0mm, 10mm, 15mm].
+pub(super) fn points(
+ extents: impl IntoIterator<Item = Abs>,
+) -> impl Iterator<Item = Abs> {
+ let mut offset = Abs::zero();
+ std::iter::once(Abs::zero()).chain(extents).map(move |extent| {
+ offset += extent;
+ offset
+ })
+}
+
+/// Checks if the first region of a sequence of regions is the last usable
+/// region, assuming that the last region will always be occupied by some
+/// specific offset height, even after calling `.next()`, due to some
+/// additional logic which adds content automatically on each region turn (in
+/// our case, headers).
+pub(super) fn in_last_with_offset(regions: Regions<'_>, offset: Abs) -> bool {
+ regions.backlog.is_empty()
+ && regions.last.map_or(true, |height| regions.size.y + offset == height)
+}
diff --git a/crates/typst-layout/src/grid/lines.rs b/crates/typst-layout/src/grid/lines.rs
new file mode 100644
index 00000000..3e89612a
--- /dev/null
+++ b/crates/typst-layout/src/grid/lines.rs
@@ -0,0 +1,1548 @@
+use std::num::NonZeroUsize;
+use std::sync::Arc;
+
+use typst_library::foundations::{AlternativeFold, Fold};
+use typst_library::layout::Abs;
+use typst_library::visualize::Stroke;
+
+use super::{CellGrid, LinePosition, Repeatable, RowPiece};
+
+/// Represents an explicit grid line (horizontal or vertical) specified by the
+/// user.
+pub struct Line {
+ /// The index of the track after this line. This will be the index of the
+ /// row a horizontal line is above of, or of the column right after a
+ /// vertical line.
+ ///
+ /// Must be within `0..=tracks.len()` (where `tracks` is either `grid.cols`
+ /// or `grid.rows`, ignoring gutter tracks, as appropriate).
+ pub index: usize,
+ /// The index of the track at which this line starts being drawn.
+ /// This is the first column a horizontal line appears in, or the first row
+ /// a vertical line appears in.
+ ///
+ /// Must be within `0..tracks.len()` minus gutter tracks.
+ pub start: usize,
+ /// The index after the last track through which the line is drawn.
+ /// Thus, the line is drawn through tracks `start..end` (note that `end` is
+ /// exclusive).
+ ///
+ /// Must be within `1..=tracks.len()` minus gutter tracks.
+ /// `None` indicates the line should go all the way to the end.
+ pub end: Option<NonZeroUsize>,
+ /// The line's stroke. This is `None` when the line is explicitly used to
+ /// override a previously specified line.
+ pub stroke: Option<Arc<Stroke<Abs>>>,
+ /// The line's position in relation to the track with its index.
+ pub position: LinePosition,
+}
+
+/// Indicates which priority a particular grid line segment should have, based
+/// on the highest priority configuration that defined the segment's stroke.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum StrokePriority {
+ /// The stroke of the segment was derived solely from the grid's global
+ /// stroke setting, so it should have the lowest priority.
+ GridStroke = 0,
+ /// The segment's stroke was derived (even if partially) from a cell's
+ /// stroke override, so it should have priority over non-overridden cell
+ /// strokes and be drawn on top of them (when they have the same
+ /// thickness).
+ CellStroke = 1,
+ /// The segment's stroke was derived from a user's explicitly placed line
+ /// (hline or vline), and thus should have maximum priority, drawn on top
+ /// of any cell strokes (when they have the same thickness).
+ ExplicitLine = 2,
+}
+
+/// Data for a particular line segment in the grid as generated by
+/// `generate_line_segments`.
+#[derive(Debug, PartialEq, Eq)]
+pub struct LineSegment {
+ /// The stroke with which to draw this segment.
+ pub stroke: Arc<Stroke<Abs>>,
+ /// The offset of this segment since the beginning of its axis.
+ /// For a vertical line segment, this is the offset since the top of the
+ /// table in the current page; for a horizontal line segment, this is the
+ /// offset since the start border of the table.
+ pub offset: Abs,
+ /// The length of this segment.
+ pub length: Abs,
+ /// The segment's drawing priority, indicating on top of which other
+ /// segments this one should be drawn.
+ pub priority: StrokePriority,
+}
+
+/// Generates the segments of lines that should be drawn alongside a certain
+/// axis in the grid, going through the given tracks (orthogonal to the lines).
+/// Each returned segment contains its stroke, its offset from the start, and
+/// its length.
+///
+/// Accepts, as parameters, the index of the lines that should be produced
+/// (for example, the column at which vertical lines will be drawn); a list of
+/// user-specified lines with the same index (the `lines` parameter); whether
+/// the given index corresponds to the maximum index for the line's axis; and a
+/// function which returns the final stroke that should be used for each track
+/// the line goes through, alongside the priority of the returned stroke (its
+/// parameters are the grid, the index of the line to be drawn, the number of
+/// the track to draw at and the stroke of the user hline/vline override at
+/// this index to fold with, if any). Contiguous segments with the same stroke
+/// and priority are joined together automatically.
+///
+/// The function should return `None` for positions at which the line would
+/// otherwise cross a merged cell (for example, a vline could cross a colspan),
+/// in which case a new segment should be drawn after the merged cell(s), even
+/// if it would have the same stroke as the previous one.
+///
+/// Regarding priority, the function should return a priority of ExplicitLine
+/// when the user-defined line's stroke at the current position isn't None
+/// (note that it is passed by parameter to the function). When it is None, the
+/// function should return a priority of CellStroke if the stroke returned was
+/// given or affected by a per-cell override of the grid's global stroke.
+/// When that isn't the case, the returned stroke was entirely provided by the
+/// grid's global stroke, and thus a priority of GridStroke should be returned.
+///
+/// Note that we assume that the tracks are sorted according to ascending
+/// number, and they must be iterable over pairs of (number, size). For
+/// vertical lines, for instance, `tracks` would describe the rows in the
+/// current region, as pairs (row index, row height).
+pub fn generate_line_segments<'grid, F, I, L>(
+ grid: &'grid CellGrid,
+ tracks: I,
+ index: usize,
+ lines: L,
+ line_stroke_at_track: F,
+) -> impl Iterator<Item = LineSegment> + 'grid
+where
+ F: Fn(
+ &CellGrid,
+ usize,
+ usize,
+ Option<Option<Arc<Stroke<Abs>>>>,
+ ) -> Option<(Arc<Stroke<Abs>>, StrokePriority)>
+ + 'grid,
+ I: IntoIterator<Item = (usize, Abs)>,
+ I::IntoIter: 'grid,
+ L: IntoIterator<Item = &'grid Line>,
+ L::IntoIter: Clone + 'grid,
+{
+ // The segment currently being drawn.
+ //
+ // It is extended for each consecutive track through which the line would
+ // be drawn with the same stroke and priority.
+ //
+ // Starts as None to force us to create a new segment as soon as we find
+ // the first track through which we should draw.
+ let mut current_segment: Option<LineSegment> = None;
+
+ // How far from the start (before the first track) have we gone so far.
+ // Used to determine the positions at which to draw each segment.
+ let mut offset = Abs::zero();
+
+ // How much to multiply line indices by to account for gutter.
+ let gutter_factor = if grid.has_gutter { 2 } else { 1 };
+
+ // Create an iterator of line segments, which will go through each track,
+ // from start to finish, to create line segments and extend them until they
+ // are interrupted and thus yielded through the iterator. We then repeat
+ // the process, picking up from the track after the one at which we had
+ // an interruption, until we have gone through all tracks.
+ //
+ // When going through each track, we check if the current segment would be
+ // interrupted, either because, at this track, we hit a merged cell over
+ // which we shouldn't draw, or because the line would have a different
+ // stroke or priority at this point (so we have to start a new segment). If
+ // so, the current segment is yielded and its variable is either set to
+ // 'None' (if no segment should be drawn at the point of interruption,
+ // meaning we might have to create a new segment later) or to the new
+ // segment (if we're starting to draw a segment with a different stroke or
+ // priority than before).
+ // Otherwise (if the current segment should span the current track), it is
+ // simply extended (or a new one is created, if it is 'None'), and no value
+ // is yielded for the current track, since the segment isn't yet complete
+ // (the next tracks might extend it further before it is interrupted and
+ // yielded). That is, we yield each segment only when it is interrupted,
+ // since then we will know its final length for sure.
+ //
+ // After the loop is done (and thus we went through all tracks), we
+ // interrupt the current segment one last time, to ensure the final segment
+ // is always interrupted and yielded, if it wasn't interrupted earlier.
+ let mut tracks = tracks.into_iter();
+ let lines = lines.into_iter();
+ std::iter::from_fn(move || {
+ // Each time this closure runs, we advance the track iterator as much
+ // as possible before returning because the current segment was
+ // interrupted. The for loop is resumed from where it stopped at the
+ // next call due to that, ensuring we go through all tracks and then
+ // stop.
+ for (track, size) in &mut tracks {
+ // Get the expected line stroke at this track by folding the
+ // strokes of each user-specified line (with priority to the
+ // user-specified line specified last).
+ let mut line_strokes = lines
+ .clone()
+ .filter(|line| {
+ line.end
+ .map(|end| {
+ // Subtract 1 from end index so we stop at the last
+ // cell before it (don't cross one extra gutter).
+ let end = if grid.has_gutter {
+ 2 * end.get() - 1
+ } else {
+ end.get()
+ };
+ (gutter_factor * line.start..end).contains(&track)
+ })
+ .unwrap_or_else(|| track >= gutter_factor * line.start)
+ })
+ .map(|line| line.stroke.clone());
+
+ // Distinguish between unspecified stroke (None, if no lines
+ // were matched above) and specified stroke of None (Some(None),
+ // if some lines were matched and the one specified last had a
+ // stroke of None) by conditionally folding after 'next()'.
+ let line_stroke = line_strokes.next().map(|first_stroke| {
+ line_strokes.fold(first_stroke, |acc, line_stroke| line_stroke.fold(acc))
+ });
+
+ // The function shall determine if it is appropriate to draw
+ // the line at this position or not (i.e. whether or not it
+ // would cross a merged cell), and, if so, the final stroke it
+ // should have (because cells near this position could have
+ // stroke overrides, which have priority and should be folded
+ // with the stroke obtained above).
+ //
+ // If we are currently already drawing a segment and the function
+ // indicates we should, at this track, draw some other segment
+ // (with a different stroke or priority), or even no segment at
+ // all, we interrupt and yield the current segment (which was drawn
+ // up to the previous track) by returning it wrapped in 'Some()'
+ // (which indicates, in the context of 'std::iter::from_fn', that
+ // our iterator isn't over yet, and this should be its next value).
+ if let Some((stroke, priority)) =
+ line_stroke_at_track(grid, index, track, line_stroke)
+ {
+ // We should draw at this position. Let's check if we were
+ // already drawing in the previous position.
+ if let Some(current_segment) = &mut current_segment {
+ // We are currently building a segment. Let's check if
+ // we should extend it to this track as well.
+ if current_segment.stroke == stroke
+ && current_segment.priority == priority
+ {
+ // Extend the current segment so it covers at least
+ // this track as well, since we should use the same
+ // stroke as in the previous one when a line goes
+ // through this track, with the same priority.
+ current_segment.length += size;
+ } else {
+ // We got a different stroke or priority now, so create
+ // a new segment with the new stroke and spanning the
+ // current track. Yield the old segment, as it was
+ // interrupted and is thus complete.
+ let new_segment =
+ LineSegment { stroke, offset, length: size, priority };
+ let old_segment = std::mem::replace(current_segment, new_segment);
+ offset += size;
+ return Some(old_segment);
+ }
+ } else {
+ // We should draw here, but there is no segment
+ // currently being drawn, either because the last
+ // position had a merged cell, had a stroke
+ // of 'None', or because this is the first track.
+ // Create a new segment to draw. We start spanning this
+ // track.
+ current_segment =
+ Some(LineSegment { stroke, offset, length: size, priority });
+ }
+ } else if let Some(old_segment) = Option::take(&mut current_segment) {
+ // We shouldn't draw here (stroke of None), so we yield the
+ // current segment, as it was interrupted.
+ offset += size;
+ return Some(old_segment);
+ }
+ // Either the current segment is None (meaning we didn't start
+ // drawing a segment yet since the last yielded one), so we keep
+ // searching for a track where we should draw one; or the current
+ // segment is Some but wasn't interrupted at this track, so we keep
+ // looping through the following tracks until it is interrupted,
+ // or we reach the end.
+ offset += size;
+ }
+
+ // Reached the end of all tracks, so we interrupt and finish
+ // the current segment. Note that, on future calls to this
+ // closure, the current segment will necessarily be 'None',
+ // so the iterator will necessarily end (that is, we will return None)
+ // after this.
+ //
+ // Note: Fully-qualified notation because rust-analyzer is confused.
+ Option::take(&mut current_segment)
+ })
+}
+
+/// Returns the correct stroke with which to draw a vline right before column
+/// `x` when going through row `y`, given the stroke of the user-specified line
+/// at this position, if any (note that a stroke of `None` is unspecified,
+/// while `Some(None)` means specified to remove any stroke at this position).
+/// Also returns the stroke's drawing priority, which depends on its source.
+///
+/// If the vline would go through a colspan, returns None (shouldn't be drawn).
+/// If the one (when at the border) or two (otherwise) cells to the left and
+/// right of the vline have right and left stroke overrides, respectively,
+/// then the cells' stroke overrides are folded together with the vline's
+/// stroke (with priority to the vline's stroke, followed by the right cell's
+/// stroke, and, finally, the left cell's) and returned. If only one of the two
+/// cells around the vline (if there are two) has an override, that cell's
+/// stroke is given priority when folding. If, however, the cells around the
+/// vline at this row do not have any stroke overrides, then the vline's own
+/// stroke, as defined by user-specified lines (if any), is returned.
+///
+/// The priority associated with the returned stroke follows the rules
+/// described in the docs for `generate_line_segment`.
+pub fn vline_stroke_at_row(
+ grid: &CellGrid,
+ x: usize,
+ y: usize,
+ stroke: Option<Option<Arc<Stroke<Abs>>>>,
+) -> Option<(Arc<Stroke<Abs>>, StrokePriority)> {
+ // When the vline isn't at the border, we need to check if a colspan would
+ // be present between columns 'x' and 'x-1' at row 'y', and thus overlap
+ // with the line.
+ // To do so, we analyze the cell right after this vline. If it is merged
+ // with a cell before this line (parent.x < x) which is at this row or
+ // above it (parent.y <= y, which is checked by
+ // 'effective_parent_cell_position'), this means it would overlap with the
+ // vline, so the vline must not be drawn at this row.
+ if x != 0 && x != grid.cols.len() {
+ // Use 'effective_parent_cell_position' to skip the gutters, if x or y
+ // represent gutter tracks.
+ // We would then analyze the cell one column after (if at a gutter
+ // column), and/or one row below (if at a gutter row), in order to
+ // check if it would be merged with a cell before the vline.
+ if let Some(parent) = grid.effective_parent_cell_position(x, y) {
+ if parent.x < x {
+ // There is a colspan cell going through this vline's position,
+ // so don't draw it here.
+ return None;
+ }
+ }
+ }
+
+ let (left_cell_stroke, left_cell_prioritized) = x
+ .checked_sub(1)
+ .and_then(|left_x| {
+ // Let's find the parent cell of the position before us, in order
+ // to take its right stroke, even with gutter before us.
+ grid.effective_parent_cell_position(left_x, y)
+ })
+ .map(|parent| {
+ let left_cell = grid.cell(parent.x, parent.y).unwrap();
+ (left_cell.stroke.right.clone(), left_cell.stroke_overridden.right)
+ })
+ .unwrap_or((None, false));
+
+ let (right_cell_stroke, right_cell_prioritized) = if x < grid.cols.len() {
+ // Let's find the parent cell of the position after us, in order
+ // to take its left stroke, even with gutter after us.
+ grid.effective_parent_cell_position(x, y)
+ .map(|parent| {
+ let right_cell = grid.cell(parent.x, parent.y).unwrap();
+ (right_cell.stroke.left.clone(), right_cell.stroke_overridden.left)
+ })
+ .unwrap_or((None, false))
+ } else {
+ (None, false)
+ };
+
+ let priority = if stroke.is_some() {
+ StrokePriority::ExplicitLine
+ } else if left_cell_prioritized || right_cell_prioritized {
+ StrokePriority::CellStroke
+ } else {
+ StrokePriority::GridStroke
+ };
+
+ let (prioritized_cell_stroke, deprioritized_cell_stroke) =
+ if left_cell_prioritized && !right_cell_prioritized {
+ (left_cell_stroke, right_cell_stroke)
+ } else {
+ // When both cells' strokes have the same priority, we default to
+ // prioritizing the right cell's left stroke.
+ (right_cell_stroke, left_cell_stroke)
+ };
+
+ // When both cells specify a stroke for this line segment, fold
+ // both strokes, with priority to either the one prioritized cell,
+ // or to the right cell's left stroke in case of a tie. But when one of
+ // them doesn't specify a stroke, the other cell's stroke should be used
+ // instead, regardless of priority (hence the usage of 'fold_or').
+ let cell_stroke = prioritized_cell_stroke.fold_or(deprioritized_cell_stroke);
+
+ // Fold the line stroke and folded cell strokes, if possible.
+ // Give priority to the explicit line stroke.
+ // Otherwise, use whichever of the two isn't 'none' or unspecified.
+ let final_stroke = stroke.fold_or(Some(cell_stroke)).flatten();
+
+ final_stroke.zip(Some(priority))
+}
+
+/// Returns the correct stroke with which to draw a hline on top of row `y`
+/// when going through column `x`, given the stroke of the user-specified line
+/// at this position, if any (note that a stroke of `None` is unspecified,
+/// while `Some(None)` means specified to remove any stroke at this position).
+/// Also returns the stroke's drawing priority, which depends on its source.
+///
+/// The `local_top_y` parameter indicates which row is effectively on top of
+/// this hline at the current region. This is `None` if the hline is above the
+/// first row in the region, for instance. The `in_last_region` parameter
+/// indicates whether this is the last region of the table. If not and this is
+/// a line at the bottom border, the bottom border's line gains priority.
+///
+/// If the one (when at the border) or two (otherwise) cells above and below
+/// the hline have bottom and top stroke overrides, respectively, then the
+/// cells' stroke overrides are folded together with the hline's stroke (with
+/// priority to hline's stroke, followed by the bottom cell's stroke, and,
+/// finally, the top cell's) and returned. If only one of the two cells around
+/// the vline (if there are two) has an override, that cell's stroke is given
+/// priority when folding. If, however, the cells around the hline at this
+/// column do not have any stroke overrides, then the hline's own stroke, as
+/// defined by user-specified lines (if any), is directly returned.
+///
+/// The priority associated with the returned stroke follows the rules
+/// described in the docs for `generate_line_segment`.
+///
+/// The rows argument is needed to know which rows are effectively present in
+/// the current region, in order to avoid unnecessary hline splitting when a
+/// rowspan's previous rows are either in a previous region or empty (and thus
+/// wouldn't overlap with the hline, since its first row in the current region
+/// is below the hline).
+///
+/// This function assumes columns are sorted by increasing `x`, and rows are
+/// sorted by increasing `y`.
+pub fn hline_stroke_at_column(
+ grid: &CellGrid,
+ rows: &[RowPiece],
+ local_top_y: Option<usize>,
+ in_last_region: bool,
+ y: usize,
+ x: usize,
+ stroke: Option<Option<Arc<Stroke<Abs>>>>,
+) -> Option<(Arc<Stroke<Abs>>, StrokePriority)> {
+ // When the hline isn't at the border, we need to check if a rowspan
+ // would be present between rows 'y' and 'y-1' at column 'x', and thus
+ // overlap with the line.
+ // To do so, we analyze the cell right below this hline. If it is
+ // merged with a cell above this line (parent.y < y) which is at this
+ // column or before it (parent.x <= x, which is checked by
+ // 'effective_parent_cell_position'), this means it would overlap with the
+ // hline, so the hline must not be drawn at this column.
+ if y != 0 && y != grid.rows.len() {
+ // Use 'effective_parent_cell_position' to skip the gutters, if x or y
+ // represent gutter tracks.
+ // We would then analyze the cell one column after (if at a gutter
+ // column), and/or one row below (if at a gutter row), in order to
+ // check if it would be merged with a cell before the hline.
+ if let Some(parent) = grid.effective_parent_cell_position(x, y) {
+ if parent.y < y {
+ // Get the first 'y' spanned by the possible rowspan in this region.
+ // The 'parent.y' row and any other spanned rows above 'y' could be
+ // missing from this region, which could have lead the check above
+ // to be triggered, even though there is no spanned row above the
+ // hline in the final layout of this region, and thus no overlap
+ // with the hline, allowing it to be drawn regardless of the
+ // theoretical presence of a rowspan going across its position.
+ let local_parent_y = rows
+ .iter()
+ .find(|row| row.y >= parent.y)
+ .map(|row| row.y)
+ .unwrap_or(y);
+
+ if local_parent_y < y {
+ // There is a rowspan cell going through this hline's
+ // position, so don't draw it here.
+ return None;
+ }
+ }
+ }
+ }
+
+ // When the hline is at the top of the region and this isn't the first
+ // region, fold with the top stroke of the topmost cell at this column,
+ // that is, the top border.
+ let use_top_border_stroke = local_top_y.is_none() && y != 0;
+ let (top_cell_stroke, top_cell_prioritized) = local_top_y
+ .or(use_top_border_stroke.then_some(0))
+ .and_then(|top_y| {
+ // Let's find the parent cell of the position above us, in order
+ // to take its bottom stroke, even when we're below gutter.
+ grid.effective_parent_cell_position(x, top_y)
+ })
+ .map(|parent| {
+ let top_cell = grid.cell(parent.x, parent.y).unwrap();
+ if use_top_border_stroke {
+ (top_cell.stroke.top.clone(), top_cell.stroke_overridden.top)
+ } else {
+ (top_cell.stroke.bottom.clone(), top_cell.stroke_overridden.bottom)
+ }
+ })
+ .unwrap_or((None, false));
+
+ // Use the bottom border stroke with priority if we're not in the last
+ // region, we have the last index, and (as a failsafe) we don't have the
+ // last row of cells above us.
+ let use_bottom_border_stroke = !in_last_region
+ && local_top_y.map_or(true, |top_y| top_y + 1 != grid.rows.len())
+ && y == grid.rows.len();
+ let bottom_y =
+ if use_bottom_border_stroke { grid.rows.len().saturating_sub(1) } else { y };
+ let (bottom_cell_stroke, bottom_cell_prioritized) = if bottom_y < grid.rows.len() {
+ // Let's find the parent cell of the position below us, in order
+ // to take its top stroke, even when we're above gutter.
+ grid.effective_parent_cell_position(x, bottom_y)
+ .map(|parent| {
+ let bottom_cell = grid.cell(parent.x, parent.y).unwrap();
+ if use_bottom_border_stroke {
+ (
+ bottom_cell.stroke.bottom.clone(),
+ bottom_cell.stroke_overridden.bottom,
+ )
+ } else {
+ (bottom_cell.stroke.top.clone(), bottom_cell.stroke_overridden.top)
+ }
+ })
+ .unwrap_or((None, false))
+ } else {
+ // No cell below the bottom border.
+ (None, false)
+ };
+
+ let priority = if stroke.is_some() {
+ StrokePriority::ExplicitLine
+ } else if top_cell_prioritized || bottom_cell_prioritized {
+ StrokePriority::CellStroke
+ } else {
+ StrokePriority::GridStroke
+ };
+
+ // Top border stroke and header stroke are generally prioritized, unless
+ // they don't have explicit hline overrides and one or more user-provided
+ // hlines would appear at the same position, which then are prioritized.
+ let top_stroke_comes_from_header = grid
+ .header
+ .as_ref()
+ .and_then(Repeatable::as_repeated)
+ .zip(local_top_y)
+ .is_some_and(|(header, local_top_y)| {
+ // Ensure the row above us is a repeated header.
+ // FIXME: Make this check more robust when headers at arbitrary
+ // positions are added.
+ local_top_y < header.end && y > header.end
+ });
+
+ // Prioritize the footer's top stroke as well where applicable.
+ let bottom_stroke_comes_from_footer = grid
+ .footer
+ .as_ref()
+ .and_then(Repeatable::as_repeated)
+ .is_some_and(|footer| {
+ // Ensure the row below us is a repeated footer.
+ // FIXME: Make this check more robust when footers at arbitrary
+ // positions are added.
+ local_top_y.unwrap_or(0) + 1 < footer.start && y >= footer.start
+ });
+
+ let (prioritized_cell_stroke, deprioritized_cell_stroke) =
+ if !use_bottom_border_stroke
+ && !bottom_stroke_comes_from_footer
+ && (use_top_border_stroke
+ || top_stroke_comes_from_header
+ || top_cell_prioritized && !bottom_cell_prioritized)
+ {
+ // Top border must always be prioritized, even if it did not
+ // request for that explicitly.
+ (top_cell_stroke, bottom_cell_stroke)
+ } else {
+ // When both cells' strokes have the same priority, we default to
+ // prioritizing the bottom cell's top stroke.
+ // Additionally, the bottom border cell's stroke always has
+ // priority. Same for stroke above footers.
+ (bottom_cell_stroke, top_cell_stroke)
+ };
+
+ // When both cells specify a stroke for this line segment, fold
+ // both strokes, with priority to either the one prioritized cell,
+ // or to the bottom cell's top stroke in case of a tie. But when one of
+ // them doesn't specify a stroke, the other cell's stroke should be used
+ // instead, regardless of priority (hence the usage of 'fold_or').
+ let cell_stroke = prioritized_cell_stroke.fold_or(deprioritized_cell_stroke);
+
+ // Fold the line stroke and folded cell strokes, if possible.
+ // Give priority to the explicit line stroke.
+ // Otherwise, use whichever of the two isn't 'none' or unspecified.
+ let final_stroke = stroke.fold_or(Some(cell_stroke)).flatten();
+
+ final_stroke.zip(Some(priority))
+}
+
+#[cfg(test)]
+mod test {
+ use typst_library::foundations::Content;
+ use typst_library::introspection::Locator;
+ use typst_library::layout::{Axes, Sides, Sizing};
+ use typst_utils::NonZeroExt;
+
+ use super::super::cells::Entry;
+ use super::super::Cell;
+ use super::*;
+
+ fn sample_cell() -> Cell<'static> {
+ Cell {
+ body: Content::default(),
+ locator: Locator::root(),
+ fill: None,
+ colspan: NonZeroUsize::ONE,
+ rowspan: NonZeroUsize::ONE,
+ stroke: Sides::splat(Some(Arc::new(Stroke::default()))),
+ stroke_overridden: Sides::splat(false),
+ breakable: true,
+ }
+ }
+
+ fn cell_with_colspan_rowspan(colspan: usize, rowspan: usize) -> Cell<'static> {
+ Cell {
+ body: Content::default(),
+ locator: Locator::root(),
+ fill: None,
+ colspan: NonZeroUsize::try_from(colspan).unwrap(),
+ rowspan: NonZeroUsize::try_from(rowspan).unwrap(),
+ stroke: Sides::splat(Some(Arc::new(Stroke::default()))),
+ stroke_overridden: Sides::splat(false),
+ breakable: true,
+ }
+ }
+
+ fn sample_grid_for_vlines(gutters: bool) -> CellGrid<'static> {
+ const COLS: usize = 4;
+ const ROWS: usize = 6;
+ let entries = vec![
+ // row 0
+ Entry::Cell(sample_cell()),
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan_rowspan(2, 1)),
+ Entry::Merged { parent: 2 },
+ // row 1
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan_rowspan(3, 1)),
+ Entry::Merged { parent: 5 },
+ Entry::Merged { parent: 5 },
+ // row 2
+ Entry::Merged { parent: 4 },
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan_rowspan(2, 1)),
+ Entry::Merged { parent: 10 },
+ // row 3
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan_rowspan(3, 2)),
+ Entry::Merged { parent: 13 },
+ Entry::Merged { parent: 13 },
+ // row 4
+ Entry::Cell(sample_cell()),
+ Entry::Merged { parent: 13 },
+ Entry::Merged { parent: 13 },
+ Entry::Merged { parent: 13 },
+ // row 5
+ Entry::Cell(sample_cell()),
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan_rowspan(2, 1)),
+ Entry::Merged { parent: 22 },
+ ];
+ CellGrid::new_internal(
+ Axes::with_x(&[Sizing::Auto; COLS]),
+ if gutters {
+ Axes::new(&[Sizing::Auto; COLS - 1], &[Sizing::Auto; ROWS - 1])
+ } else {
+ Axes::default()
+ },
+ vec![],
+ vec![],
+ None,
+ None,
+ entries,
+ )
+ }
+
+ #[test]
+ fn test_vline_splitting_without_gutter() {
+ let stroke = Arc::new(Stroke::default());
+ let grid = sample_grid_for_vlines(false);
+ let rows = &[
+ RowPiece { height: Abs::pt(1.0), y: 0 },
+ RowPiece { height: Abs::pt(2.0), y: 1 },
+ RowPiece { height: Abs::pt(4.0), y: 2 },
+ RowPiece { height: Abs::pt(8.0), y: 3 },
+ RowPiece { height: Abs::pt(16.0), y: 4 },
+ RowPiece { height: Abs::pt(32.0), y: 5 },
+ ];
+ let expected_vline_splits = &[
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ priority: StrokePriority::GridStroke,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ priority: StrokePriority::GridStroke,
+ }],
+ // interrupted a few times by colspans
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2.),
+ length: Abs::pt(4.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16.),
+ length: Abs::pt(32.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ // interrupted every time by colspans
+ vec![],
+ vec![LineSegment {
+ stroke,
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ priority: StrokePriority::GridStroke,
+ }],
+ ];
+ for (x, expected_splits) in expected_vline_splits.iter().enumerate() {
+ let tracks = rows.iter().map(|row| (row.y, row.height));
+ assert_eq!(
+ expected_splits,
+ &generate_line_segments(&grid, tracks, x, &[], vline_stroke_at_row)
+ .collect::<Vec<_>>(),
+ );
+ }
+ }
+
+ #[test]
+ fn test_vline_splitting_with_gutter_and_per_cell_stroke() {
+ let stroke = Arc::new(Stroke::default());
+ let grid = sample_grid_for_vlines(true);
+ let rows = &[
+ RowPiece { height: Abs::pt(1.0), y: 0 },
+ RowPiece { height: Abs::pt(2.0), y: 1 },
+ RowPiece { height: Abs::pt(4.0), y: 2 },
+ RowPiece { height: Abs::pt(8.0), y: 3 },
+ RowPiece { height: Abs::pt(16.0), y: 4 },
+ RowPiece { height: Abs::pt(32.0), y: 5 },
+ RowPiece { height: Abs::pt(64.0), y: 6 },
+ RowPiece { height: Abs::pt(128.0), y: 7 },
+ RowPiece { height: Abs::pt(256.0), y: 8 },
+ RowPiece { height: Abs::pt(512.0), y: 9 },
+ RowPiece { height: Abs::pt(1024.0), y: 10 },
+ ];
+ // Stroke is per-cell so we skip gutter
+ let expected_vline_splits = &[
+ // left border
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ // Covers the rowspan between (original) rows 1 and 2
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2.),
+ length: Abs::pt(4. + 8. + 16.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ length: Abs::pt(64.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128.),
+ length: Abs::pt(256.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512.,
+ ),
+ length: Abs::pt(1024.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ // gutter line below
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ // Covers the rowspan between (original) rows 1 and 2
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2.),
+ length: Abs::pt(4. + 8. + 16.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ length: Abs::pt(64.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128.),
+ length: Abs::pt(256.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512.,
+ ),
+ length: Abs::pt(1024.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2.),
+ length: Abs::pt(4.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8.),
+ length: Abs::pt(16.),
+ priority: StrokePriority::GridStroke,
+ },
+ // Covers the rowspan between (original) rows 3 and 4
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ length: Abs::pt(64. + 128. + 256.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512.,
+ ),
+ length: Abs::pt(1024.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ // gutter line below
+ // the two lines below are interrupted multiple times by colspans
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8.),
+ length: Abs::pt(16.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512.,
+ ),
+ length: Abs::pt(1024.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8.),
+ length: Abs::pt(16.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512.,
+ ),
+ length: Abs::pt(1024.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ // gutter line below
+ // the two lines below can only cross certain gutter rows, because
+ // all non-gutter cells in the following column are merged with
+ // cells from the previous column.
+ vec![],
+ vec![],
+ // right border
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2.),
+ length: Abs::pt(4.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8.),
+ length: Abs::pt(16.),
+ priority: StrokePriority::GridStroke,
+ },
+ // Covers the rowspan between (original) rows 3 and 4
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
+ length: Abs::pt(64. + 128. + 256.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512.,
+ ),
+ length: Abs::pt(1024.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ ];
+ for (x, expected_splits) in expected_vline_splits.iter().enumerate() {
+ let tracks = rows.iter().map(|row| (row.y, row.height));
+ assert_eq!(
+ expected_splits,
+ &generate_line_segments(&grid, tracks, x, &[], vline_stroke_at_row)
+ .collect::<Vec<_>>(),
+ );
+ }
+ }
+
+ #[test]
+ fn test_vline_splitting_with_gutter_and_explicit_vlines() {
+ let stroke = Arc::new(Stroke::default());
+ let grid = sample_grid_for_vlines(true);
+ let rows = &[
+ RowPiece { height: Abs::pt(1.0), y: 0 },
+ RowPiece { height: Abs::pt(2.0), y: 1 },
+ RowPiece { height: Abs::pt(4.0), y: 2 },
+ RowPiece { height: Abs::pt(8.0), y: 3 },
+ RowPiece { height: Abs::pt(16.0), y: 4 },
+ RowPiece { height: Abs::pt(32.0), y: 5 },
+ RowPiece { height: Abs::pt(64.0), y: 6 },
+ RowPiece { height: Abs::pt(128.0), y: 7 },
+ RowPiece { height: Abs::pt(256.0), y: 8 },
+ RowPiece { height: Abs::pt(512.0), y: 9 },
+ RowPiece { height: Abs::pt(1024.0), y: 10 },
+ ];
+ let expected_vline_splits = &[
+ // left border
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.,
+ ),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.,
+ ),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.,
+ ),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ // the two lines below are interrupted multiple times by colspans
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8. + 16. + 32.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.),
+ length: Abs::pt(512. + 1024.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ ],
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8. + 16. + 32.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.),
+ length: Abs::pt(512. + 1024.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ ],
+ // gutter line below
+ // the two lines below can only cross certain gutter rows, because
+ // all non-gutter cells in the following column are merged with
+ // cells from the previous column.
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1.),
+ length: Abs::pt(2.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16.),
+ length: Abs::pt(32.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.),
+ length: Abs::pt(512.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ ],
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1.),
+ length: Abs::pt(2.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16.),
+ length: Abs::pt(32.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.),
+ length: Abs::pt(512.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ ],
+ // right border
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(
+ 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.,
+ ),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ ];
+ for (x, expected_splits) in expected_vline_splits.iter().enumerate() {
+ let tracks = rows.iter().map(|row| (row.y, row.height));
+ assert_eq!(
+ expected_splits,
+ &generate_line_segments(
+ &grid,
+ tracks,
+ x,
+ &[
+ Line {
+ index: x,
+ start: 0,
+ end: None,
+ stroke: Some(stroke.clone()),
+ position: LinePosition::Before
+ },
+ Line {
+ index: x,
+ start: 0,
+ end: None,
+ stroke: Some(stroke.clone()),
+ position: LinePosition::After
+ },
+ ],
+ vline_stroke_at_row
+ )
+ .collect::<Vec<_>>(),
+ );
+ }
+ }
+
+ fn sample_grid_for_hlines(gutters: bool) -> CellGrid<'static> {
+ const COLS: usize = 4;
+ const ROWS: usize = 9;
+ let entries = vec![
+ // row 0
+ Entry::Cell(cell_with_colspan_rowspan(1, 2)),
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan_rowspan(2, 2)),
+ Entry::Merged { parent: 2 },
+ // row 1
+ Entry::Merged { parent: 0 },
+ Entry::Cell(sample_cell()),
+ Entry::Merged { parent: 2 },
+ Entry::Merged { parent: 2 },
+ // row 2
+ Entry::Cell(sample_cell()),
+ Entry::Cell(sample_cell()),
+ Entry::Cell(sample_cell()),
+ Entry::Cell(sample_cell()),
+ // row 3
+ Entry::Cell(cell_with_colspan_rowspan(4, 2)),
+ Entry::Merged { parent: 12 },
+ Entry::Merged { parent: 12 },
+ Entry::Merged { parent: 12 },
+ // row 4
+ Entry::Merged { parent: 12 },
+ Entry::Merged { parent: 12 },
+ Entry::Merged { parent: 12 },
+ Entry::Merged { parent: 12 },
+ // row 5
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan_rowspan(1, 2)),
+ Entry::Cell(cell_with_colspan_rowspan(2, 1)),
+ Entry::Merged { parent: 22 },
+ // row 6
+ Entry::Cell(sample_cell()),
+ Entry::Merged { parent: 21 },
+ Entry::Cell(sample_cell()),
+ Entry::Cell(sample_cell()),
+ // row 7 (adjacent rowspans covering the whole row)
+ Entry::Cell(cell_with_colspan_rowspan(2, 2)),
+ Entry::Merged { parent: 28 },
+ Entry::Cell(cell_with_colspan_rowspan(2, 2)),
+ Entry::Merged { parent: 30 },
+ // row 8
+ Entry::Merged { parent: 28 },
+ Entry::Merged { parent: 28 },
+ Entry::Merged { parent: 30 },
+ Entry::Merged { parent: 30 },
+ ];
+ CellGrid::new_internal(
+ Axes::with_x(&[Sizing::Auto; COLS]),
+ if gutters {
+ Axes::new(&[Sizing::Auto; COLS - 1], &[Sizing::Auto; ROWS - 1])
+ } else {
+ Axes::default()
+ },
+ vec![],
+ vec![],
+ None,
+ None,
+ entries,
+ )
+ }
+
+ #[test]
+ fn test_hline_splitting_without_gutter() {
+ let stroke = Arc::new(Stroke::default());
+ let grid = sample_grid_for_hlines(false);
+ let columns = &[Abs::pt(1.), Abs::pt(2.), Abs::pt(4.), Abs::pt(8.)];
+ // Assume all rows would be drawn in the same region, and are available.
+ let rows = grid
+ .rows
+ .iter()
+ .enumerate()
+ .map(|(y, _)| RowPiece { height: Abs::pt(f64::from(2u32.pow(y as u32))), y })
+ .collect::<Vec<_>>();
+ let expected_hline_splits = &[
+ // top border
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke,
+ }],
+ // interrupted a few times by rowspans
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1.),
+ length: Abs::pt(2.),
+ priority: StrokePriority::GridStroke,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke,
+ }],
+ // interrupted every time by rowspans
+ vec![],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke,
+ }],
+ // interrupted once by rowspan
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2.),
+ length: Abs::pt(4. + 8.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke,
+ }],
+ // interrupted every time by successive rowspans
+ vec![],
+ // bottom border
+ vec![LineSegment {
+ stroke,
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke,
+ }],
+ ];
+ for (y, expected_splits) in expected_hline_splits.iter().enumerate() {
+ let tracks = columns.iter().copied().enumerate();
+ assert_eq!(
+ expected_splits,
+ &generate_line_segments(&grid, tracks, y, &[], |grid, y, x, stroke| {
+ hline_stroke_at_column(
+ grid,
+ &rows,
+ y.checked_sub(1),
+ true,
+ y,
+ x,
+ stroke,
+ )
+ })
+ .collect::<Vec<_>>(),
+ );
+ }
+ }
+
+ #[test]
+ fn test_hline_splitting_with_gutter_and_explicit_hlines() {
+ let stroke = Arc::new(Stroke::default());
+ let grid = sample_grid_for_hlines(true);
+ let columns = &[
+ Abs::pt(1.0),
+ Abs::pt(2.0),
+ Abs::pt(4.0),
+ Abs::pt(8.0),
+ Abs::pt(16.0),
+ Abs::pt(32.0),
+ Abs::pt(64.0),
+ ];
+ // Assume all rows would be drawn in the same region, and are available.
+ let rows = grid
+ .rows
+ .iter()
+ .enumerate()
+ .map(|(y, _)| RowPiece { height: Abs::pt(f64::from(2u32.pow(y as u32))), y })
+ .collect::<Vec<_>>();
+ let expected_hline_splits = &[
+ // top border
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ // interrupted a few times by rowspans
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1.),
+ length: Abs::pt(2. + 4. + 8.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // interrupted a few times by rowspans
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1.),
+ length: Abs::pt(2. + 4. + 8.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ // interrupted every time by rowspans
+ vec![],
+ // interrupted every time by rowspans
+ vec![],
+ // gutter line below
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ // interrupted once by rowspan
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ ],
+ // interrupted once by rowspan
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ ],
+ // gutter line below
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ // there are two consecutive rowspans, but the gutter column
+ // between them is free.
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // bottom border
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ ];
+ for (y, expected_splits) in expected_hline_splits.iter().enumerate() {
+ let tracks = columns.iter().copied().enumerate();
+ assert_eq!(
+ expected_splits,
+ &generate_line_segments(
+ &grid,
+ tracks,
+ y,
+ &[
+ Line {
+ index: y,
+ start: 0,
+ end: None,
+ stroke: Some(stroke.clone()),
+ position: LinePosition::Before
+ },
+ Line {
+ index: y,
+ start: 0,
+ end: None,
+ stroke: Some(stroke.clone()),
+ position: LinePosition::After
+ },
+ ],
+ |grid, y, x, stroke| hline_stroke_at_column(
+ grid,
+ &rows,
+ y.checked_sub(1),
+ true,
+ y,
+ x,
+ stroke
+ )
+ )
+ .collect::<Vec<_>>(),
+ );
+ }
+ }
+
+ #[test]
+ fn test_hline_splitting_considers_absent_rows() {
+ let grid = sample_grid_for_hlines(false);
+ let columns = &[Abs::pt(1.), Abs::pt(2.), Abs::pt(4.), Abs::pt(8.)];
+ // Assume row 3 is absent (even though there's a rowspan between rows
+ // 3 and 4)
+ // This can happen if it is an auto row which turns out to be fully
+ // empty.
+ let rows = grid
+ .rows
+ .iter()
+ .enumerate()
+ .filter(|(y, _)| *y != 3)
+ .map(|(y, _)| RowPiece { height: Abs::pt(f64::from(2u32.pow(y as u32))), y })
+ .collect::<Vec<_>>();
+
+ // Hline above row 4 is no longer blocked, since the rowspan is now
+ // effectively spanning just one row (at least, visibly).
+ assert_eq!(
+ &vec![LineSegment {
+ stroke: Arc::new(Stroke::default()),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke
+ }],
+ &generate_line_segments(
+ &grid,
+ columns.iter().copied().enumerate(),
+ 4,
+ &[],
+ |grid, y, x, stroke| hline_stroke_at_column(
+ grid,
+ &rows,
+ if y == 4 { Some(2) } else { y.checked_sub(1) },
+ true,
+ y,
+ x,
+ stroke
+ )
+ )
+ .collect::<Vec<_>>()
+ );
+ }
+}
diff --git a/crates/typst-layout/src/grid/mod.rs b/crates/typst-layout/src/grid/mod.rs
new file mode 100644
index 00000000..769bef8c
--- /dev/null
+++ b/crates/typst-layout/src/grid/mod.rs
@@ -0,0 +1,416 @@
+mod cells;
+mod layouter;
+mod lines;
+mod repeated;
+mod rowspans;
+
+pub use self::cells::{Cell, CellGrid};
+pub use self::layouter::GridLayouter;
+
+use std::num::NonZeroUsize;
+use std::sync::Arc;
+
+use ecow::eco_format;
+use typst_library::diag::{SourceResult, Trace, Tracepoint};
+use typst_library::engine::Engine;
+use typst_library::foundations::{Fold, Packed, Smart, StyleChain};
+use typst_library::introspection::Locator;
+use typst_library::layout::{
+ Abs, Alignment, Axes, Dir, Fragment, GridCell, GridChild, GridElem, GridItem, Length,
+ OuterHAlignment, OuterVAlignment, Regions, Rel, Sides,
+};
+use typst_library::model::{TableCell, TableChild, TableElem, TableItem};
+use typst_library::text::TextElem;
+use typst_library::visualize::{Paint, Stroke};
+use typst_syntax::Span;
+
+use self::cells::{
+ LinePosition, ResolvableCell, ResolvableGridChild, ResolvableGridItem,
+};
+use self::layouter::RowPiece;
+use self::lines::{
+ generate_line_segments, hline_stroke_at_column, vline_stroke_at_row, Line,
+ LineSegment,
+};
+use self::repeated::{Footer, Header, Repeatable};
+use self::rowspans::{Rowspan, UnbreakableRowGroup};
+
+/// Layout the grid.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_grid(
+ elem: &Packed<GridElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+) -> SourceResult<Fragment> {
+ let inset = elem.inset(styles);
+ let align = elem.align(styles);
+ let columns = elem.columns(styles);
+ let rows = elem.rows(styles);
+ let column_gutter = elem.column_gutter(styles);
+ let row_gutter = elem.row_gutter(styles);
+ let fill = elem.fill(styles);
+ let stroke = elem.stroke(styles);
+
+ let tracks = Axes::new(columns.0.as_slice(), rows.0.as_slice());
+ let gutter = Axes::new(column_gutter.0.as_slice(), row_gutter.0.as_slice());
+ // Use trace to link back to the grid when a specific cell errors
+ let tracepoint = || Tracepoint::Call(Some(eco_format!("grid")));
+ let resolve_item = |item: &GridItem| grid_item_to_resolvable(item, styles);
+ let children = elem.children().iter().map(|child| match child {
+ GridChild::Header(header) => ResolvableGridChild::Header {
+ repeat: header.repeat(styles),
+ span: header.span(),
+ items: header.children().iter().map(resolve_item),
+ },
+ GridChild::Footer(footer) => ResolvableGridChild::Footer {
+ repeat: footer.repeat(styles),
+ span: footer.span(),
+ items: footer.children().iter().map(resolve_item),
+ },
+ GridChild::Item(item) => {
+ ResolvableGridChild::Item(grid_item_to_resolvable(item, styles))
+ }
+ });
+ let grid = CellGrid::resolve(
+ tracks,
+ gutter,
+ locator,
+ children,
+ fill,
+ align,
+ &inset,
+ &stroke,
+ engine,
+ styles,
+ elem.span(),
+ )
+ .trace(engine.world, tracepoint, elem.span())?;
+
+ let layouter = GridLayouter::new(&grid, regions, styles, elem.span());
+
+ // Measure the columns and layout the grid row-by-row.
+ layouter.layout(engine)
+}
+
+/// Layout the table.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_table(
+ elem: &Packed<TableElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+) -> SourceResult<Fragment> {
+ let inset = elem.inset(styles);
+ let align = elem.align(styles);
+ let columns = elem.columns(styles);
+ let rows = elem.rows(styles);
+ let column_gutter = elem.column_gutter(styles);
+ let row_gutter = elem.row_gutter(styles);
+ let fill = elem.fill(styles);
+ let stroke = elem.stroke(styles);
+
+ let tracks = Axes::new(columns.0.as_slice(), rows.0.as_slice());
+ let gutter = Axes::new(column_gutter.0.as_slice(), row_gutter.0.as_slice());
+ // Use trace to link back to the table when a specific cell errors
+ let tracepoint = || Tracepoint::Call(Some(eco_format!("table")));
+ let resolve_item = |item: &TableItem| table_item_to_resolvable(item, styles);
+ let children = elem.children().iter().map(|child| match child {
+ TableChild::Header(header) => ResolvableGridChild::Header {
+ repeat: header.repeat(styles),
+ span: header.span(),
+ items: header.children().iter().map(resolve_item),
+ },
+ TableChild::Footer(footer) => ResolvableGridChild::Footer {
+ repeat: footer.repeat(styles),
+ span: footer.span(),
+ items: footer.children().iter().map(resolve_item),
+ },
+ TableChild::Item(item) => {
+ ResolvableGridChild::Item(table_item_to_resolvable(item, styles))
+ }
+ });
+ let grid = CellGrid::resolve(
+ tracks,
+ gutter,
+ locator,
+ children,
+ fill,
+ align,
+ &inset,
+ &stroke,
+ engine,
+ styles,
+ elem.span(),
+ )
+ .trace(engine.world, tracepoint, elem.span())?;
+
+ let layouter = GridLayouter::new(&grid, regions, styles, elem.span());
+ layouter.layout(engine)
+}
+
+fn grid_item_to_resolvable(
+ item: &GridItem,
+ styles: StyleChain,
+) -> ResolvableGridItem<Packed<GridCell>> {
+ match item {
+ GridItem::HLine(hline) => ResolvableGridItem::HLine {
+ y: hline.y(styles),
+ start: hline.start(styles),
+ end: hline.end(styles),
+ stroke: hline.stroke(styles),
+ span: hline.span(),
+ position: match hline.position(styles) {
+ OuterVAlignment::Top => LinePosition::Before,
+ OuterVAlignment::Bottom => LinePosition::After,
+ },
+ },
+ GridItem::VLine(vline) => ResolvableGridItem::VLine {
+ x: vline.x(styles),
+ start: vline.start(styles),
+ end: vline.end(styles),
+ stroke: vline.stroke(styles),
+ span: vline.span(),
+ position: match vline.position(styles) {
+ OuterHAlignment::Left if TextElem::dir_in(styles) == Dir::RTL => {
+ LinePosition::After
+ }
+ OuterHAlignment::Right if TextElem::dir_in(styles) == Dir::RTL => {
+ LinePosition::Before
+ }
+ OuterHAlignment::Start | OuterHAlignment::Left => LinePosition::Before,
+ OuterHAlignment::End | OuterHAlignment::Right => LinePosition::After,
+ },
+ },
+ GridItem::Cell(cell) => ResolvableGridItem::Cell(cell.clone()),
+ }
+}
+
+fn table_item_to_resolvable(
+ item: &TableItem,
+ styles: StyleChain,
+) -> ResolvableGridItem<Packed<TableCell>> {
+ match item {
+ TableItem::HLine(hline) => ResolvableGridItem::HLine {
+ y: hline.y(styles),
+ start: hline.start(styles),
+ end: hline.end(styles),
+ stroke: hline.stroke(styles),
+ span: hline.span(),
+ position: match hline.position(styles) {
+ OuterVAlignment::Top => LinePosition::Before,
+ OuterVAlignment::Bottom => LinePosition::After,
+ },
+ },
+ TableItem::VLine(vline) => ResolvableGridItem::VLine {
+ x: vline.x(styles),
+ start: vline.start(styles),
+ end: vline.end(styles),
+ stroke: vline.stroke(styles),
+ span: vline.span(),
+ position: match vline.position(styles) {
+ OuterHAlignment::Left if TextElem::dir_in(styles) == Dir::RTL => {
+ LinePosition::After
+ }
+ OuterHAlignment::Right if TextElem::dir_in(styles) == Dir::RTL => {
+ LinePosition::Before
+ }
+ OuterHAlignment::Start | OuterHAlignment::Left => LinePosition::Before,
+ OuterHAlignment::End | OuterHAlignment::Right => LinePosition::After,
+ },
+ },
+ TableItem::Cell(cell) => ResolvableGridItem::Cell(cell.clone()),
+ }
+}
+
+impl ResolvableCell for Packed<TableCell> {
+ fn resolve_cell<'a>(
+ mut self,
+ x: usize,
+ y: usize,
+ fill: &Option<Paint>,
+ align: Smart<Alignment>,
+ inset: Sides<Option<Rel<Length>>>,
+ stroke: Sides<Option<Option<Arc<Stroke<Abs>>>>>,
+ breakable: bool,
+ locator: Locator<'a>,
+ styles: StyleChain,
+ ) -> Cell<'a> {
+ let cell = &mut *self;
+ let colspan = cell.colspan(styles);
+ let rowspan = cell.rowspan(styles);
+ let breakable = cell.breakable(styles).unwrap_or(breakable);
+ let fill = cell.fill(styles).unwrap_or_else(|| fill.clone());
+
+ let cell_stroke = cell.stroke(styles);
+ let stroke_overridden =
+ cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_))));
+
+ // Using a typical 'Sides' fold, an unspecified side loses to a
+ // specified side. Additionally, when both are specified, an inner
+ // None wins over the outer Some, and vice-versa. When both are
+ // specified and Some, fold occurs, which, remarkably, leads to an Arc
+ // clone.
+ //
+ // In the end, we flatten because, for layout purposes, an unspecified
+ // cell stroke is the same as specifying 'none', so we equate the two
+ // concepts.
+ let stroke = cell_stroke.fold(stroke).map(Option::flatten);
+ cell.push_x(Smart::Custom(x));
+ cell.push_y(Smart::Custom(y));
+ cell.push_fill(Smart::Custom(fill.clone()));
+ cell.push_align(match align {
+ Smart::Custom(align) => {
+ Smart::Custom(cell.align(styles).map_or(align, |inner| inner.fold(align)))
+ }
+ // Don't fold if the table is using outer alignment. Use the
+ // cell's alignment instead (which, in the end, will fold with
+ // the outer alignment when it is effectively displayed).
+ Smart::Auto => cell.align(styles),
+ });
+ cell.push_inset(Smart::Custom(
+ cell.inset(styles).map_or(inset, |inner| inner.fold(inset)),
+ ));
+ cell.push_stroke(
+ // Here we convert the resolved stroke to a regular stroke, however
+ // with resolved units (that is, 'em' converted to absolute units).
+ // We also convert any stroke unspecified by both the cell and the
+ // outer stroke ('None' in the folded stroke) to 'none', that is,
+ // all sides are present in the resulting Sides object accessible
+ // by show rules on table cells.
+ stroke.as_ref().map(|side| {
+ Some(side.as_ref().map(|cell_stroke| {
+ Arc::new((**cell_stroke).clone().map(Length::from))
+ }))
+ }),
+ );
+ cell.push_breakable(Smart::Custom(breakable));
+ Cell {
+ body: self.pack(),
+ locator,
+ fill,
+ colspan,
+ rowspan,
+ stroke,
+ stroke_overridden,
+ breakable,
+ }
+ }
+
+ fn x(&self, styles: StyleChain) -> Smart<usize> {
+ (**self).x(styles)
+ }
+
+ fn y(&self, styles: StyleChain) -> Smart<usize> {
+ (**self).y(styles)
+ }
+
+ fn colspan(&self, styles: StyleChain) -> NonZeroUsize {
+ (**self).colspan(styles)
+ }
+
+ fn rowspan(&self, styles: StyleChain) -> NonZeroUsize {
+ (**self).rowspan(styles)
+ }
+
+ fn span(&self) -> Span {
+ Packed::span(self)
+ }
+}
+
+impl ResolvableCell for Packed<GridCell> {
+ fn resolve_cell<'a>(
+ mut self,
+ x: usize,
+ y: usize,
+ fill: &Option<Paint>,
+ align: Smart<Alignment>,
+ inset: Sides<Option<Rel<Length>>>,
+ stroke: Sides<Option<Option<Arc<Stroke<Abs>>>>>,
+ breakable: bool,
+ locator: Locator<'a>,
+ styles: StyleChain,
+ ) -> Cell<'a> {
+ let cell = &mut *self;
+ let colspan = cell.colspan(styles);
+ let rowspan = cell.rowspan(styles);
+ let breakable = cell.breakable(styles).unwrap_or(breakable);
+ let fill = cell.fill(styles).unwrap_or_else(|| fill.clone());
+
+ let cell_stroke = cell.stroke(styles);
+ let stroke_overridden =
+ cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_))));
+
+ // Using a typical 'Sides' fold, an unspecified side loses to a
+ // specified side. Additionally, when both are specified, an inner
+ // None wins over the outer Some, and vice-versa. When both are
+ // specified and Some, fold occurs, which, remarkably, leads to an Arc
+ // clone.
+ //
+ // In the end, we flatten because, for layout purposes, an unspecified
+ // cell stroke is the same as specifying 'none', so we equate the two
+ // concepts.
+ let stroke = cell_stroke.fold(stroke).map(Option::flatten);
+ cell.push_x(Smart::Custom(x));
+ cell.push_y(Smart::Custom(y));
+ cell.push_fill(Smart::Custom(fill.clone()));
+ cell.push_align(match align {
+ Smart::Custom(align) => {
+ Smart::Custom(cell.align(styles).map_or(align, |inner| inner.fold(align)))
+ }
+ // Don't fold if the grid is using outer alignment. Use the
+ // cell's alignment instead (which, in the end, will fold with
+ // the outer alignment when it is effectively displayed).
+ Smart::Auto => cell.align(styles),
+ });
+ cell.push_inset(Smart::Custom(
+ cell.inset(styles).map_or(inset, |inner| inner.fold(inset)),
+ ));
+ cell.push_stroke(
+ // Here we convert the resolved stroke to a regular stroke, however
+ // with resolved units (that is, 'em' converted to absolute units).
+ // We also convert any stroke unspecified by both the cell and the
+ // outer stroke ('None' in the folded stroke) to 'none', that is,
+ // all sides are present in the resulting Sides object accessible
+ // by show rules on grid cells.
+ stroke.as_ref().map(|side| {
+ Some(side.as_ref().map(|cell_stroke| {
+ Arc::new((**cell_stroke).clone().map(Length::from))
+ }))
+ }),
+ );
+ cell.push_breakable(Smart::Custom(breakable));
+ Cell {
+ body: self.pack(),
+ locator,
+ fill,
+ colspan,
+ rowspan,
+ stroke,
+ stroke_overridden,
+ breakable,
+ }
+ }
+
+ fn x(&self, styles: StyleChain) -> Smart<usize> {
+ (**self).x(styles)
+ }
+
+ fn y(&self, styles: StyleChain) -> Smart<usize> {
+ (**self).y(styles)
+ }
+
+ fn colspan(&self, styles: StyleChain) -> NonZeroUsize {
+ (**self).colspan(styles)
+ }
+
+ fn rowspan(&self, styles: StyleChain) -> NonZeroUsize {
+ (**self).rowspan(styles)
+ }
+
+ fn span(&self) -> Span {
+ Packed::span(self)
+ }
+}
diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs
new file mode 100644
index 00000000..972179da
--- /dev/null
+++ b/crates/typst-layout/src/grid/repeated.rs
@@ -0,0 +1,192 @@
+use typst_library::diag::SourceResult;
+use typst_library::engine::Engine;
+use typst_library::layout::{Abs, Axes, Frame, Regions};
+
+use super::layouter::GridLayouter;
+use super::rowspans::UnbreakableRowGroup;
+
+/// A repeatable grid header. Starts at the first row.
+pub struct Header {
+ /// The index after the last row included in this header.
+ pub end: usize,
+}
+
+/// A repeatable grid footer. Stops at the last row.
+pub struct Footer {
+ /// The first row included in this footer.
+ pub start: usize,
+}
+
+/// A possibly repeatable grid object.
+/// It still exists even when not repeatable, but must not have additional
+/// considerations by grid layout, other than for consistency (such as making
+/// a certain group of rows unbreakable).
+pub enum Repeatable<T> {
+ Repeated(T),
+ NotRepeated(T),
+}
+
+impl<T> Repeatable<T> {
+ /// Gets the value inside this repeatable, regardless of whether
+ /// it repeats.
+ pub fn unwrap(&self) -> &T {
+ match self {
+ Self::Repeated(repeated) => repeated,
+ Self::NotRepeated(not_repeated) => not_repeated,
+ }
+ }
+
+ /// Returns `Some` if the value is repeated, `None` otherwise.
+ pub fn as_repeated(&self) -> Option<&T> {
+ match self {
+ Self::Repeated(repeated) => Some(repeated),
+ Self::NotRepeated(_) => None,
+ }
+ }
+}
+
+impl<'a> GridLayouter<'a> {
+ /// Layouts the header's rows.
+ /// Skips regions as necessary.
+ pub fn layout_header(
+ &mut self,
+ header: &Header,
+ engine: &mut Engine,
+ disambiguator: usize,
+ ) -> SourceResult<()> {
+ let header_rows =
+ self.simulate_header(header, &self.regions, engine, disambiguator)?;
+ let mut skipped_region = false;
+ while self.unbreakable_rows_left == 0
+ && !self.regions.size.y.fits(header_rows.height + self.footer_height)
+ && self.regions.may_progress()
+ {
+ // Advance regions without any output until we can place the
+ // header and the footer.
+ self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]);
+ skipped_region = true;
+ }
+
+ // Reset the header height for this region.
+ // It will be re-calculated when laying out each header row.
+ self.header_height = Abs::zero();
+
+ if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
+ if skipped_region {
+ // Simulate the footer again; the region's 'full' might have
+ // changed.
+ self.footer_height = self
+ .simulate_footer(footer, &self.regions, engine, disambiguator)?
+ .height;
+ }
+ }
+
+ // Header is unbreakable.
+ // Thus, no risk of 'finish_region' being recursively called from
+ // within 'layout_row'.
+ self.unbreakable_rows_left += header.end;
+ for y in 0..header.end {
+ self.layout_row(y, engine, disambiguator)?;
+ }
+ Ok(())
+ }
+
+ /// Simulate the header's group of rows.
+ pub fn simulate_header(
+ &self,
+ header: &Header,
+ regions: &Regions<'_>,
+ engine: &mut Engine,
+ disambiguator: usize,
+ ) -> SourceResult<UnbreakableRowGroup> {
+ // Note that we assume the invariant that any rowspan in a header is
+ // fully contained within that header. Therefore, there won't be any
+ // unbreakable rowspans exceeding the header's rows, and we can safely
+ // assume that the amount of unbreakable rows following the first row
+ // in the header will be precisely the rows in the header.
+ self.simulate_unbreakable_row_group(
+ 0,
+ Some(header.end),
+ regions,
+ engine,
+ disambiguator,
+ )
+ }
+
+ /// Updates `self.footer_height` by simulating the footer, and skips to fitting region.
+ pub fn prepare_footer(
+ &mut self,
+ footer: &Footer,
+ engine: &mut Engine,
+ disambiguator: usize,
+ ) -> SourceResult<()> {
+ let footer_height = self
+ .simulate_footer(footer, &self.regions, engine, disambiguator)?
+ .height;
+ let mut skipped_region = false;
+ while self.unbreakable_rows_left == 0
+ && !self.regions.size.y.fits(footer_height)
+ && self.regions.may_progress()
+ {
+ // Advance regions without any output until we can place the
+ // footer.
+ self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]);
+ skipped_region = true;
+ }
+
+ self.footer_height = if skipped_region {
+ // Simulate the footer again; the region's 'full' might have
+ // changed.
+ self.simulate_footer(footer, &self.regions, engine, disambiguator)?
+ .height
+ } else {
+ footer_height
+ };
+
+ Ok(())
+ }
+
+ /// Lays out all rows in the footer.
+ /// They are unbreakable.
+ pub fn layout_footer(
+ &mut self,
+ footer: &Footer,
+ engine: &mut Engine,
+ disambiguator: usize,
+ ) -> SourceResult<()> {
+ // Ensure footer rows have their own height available.
+ // Won't change much as we're creating an unbreakable row group
+ // anyway, so this is mostly for correctness.
+ self.regions.size.y += self.footer_height;
+
+ let footer_len = self.grid.rows.len() - footer.start;
+ self.unbreakable_rows_left += footer_len;
+ for y in footer.start..self.grid.rows.len() {
+ self.layout_row(y, engine, disambiguator)?;
+ }
+
+ Ok(())
+ }
+
+ // Simulate the footer's group of rows.
+ pub fn simulate_footer(
+ &self,
+ footer: &Footer,
+ regions: &Regions<'_>,
+ engine: &mut Engine,
+ disambiguator: usize,
+ ) -> SourceResult<UnbreakableRowGroup> {
+ // Note that we assume the invariant that any rowspan in a footer is
+ // fully contained within that footer. Therefore, there won't be any
+ // unbreakable rowspans exceeding the footer's rows, and we can safely
+ // assume that the amount of unbreakable rows following the first row
+ // in the footer will be precisely the rows in the footer.
+ self.simulate_unbreakable_row_group(
+ footer.start,
+ Some(self.grid.rows.len() - footer.start),
+ regions,
+ engine,
+ disambiguator,
+ )
+ }
+}
diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs
new file mode 100644
index 00000000..03b4103f
--- /dev/null
+++ b/crates/typst-layout/src/grid/rowspans.rs
@@ -0,0 +1,1217 @@
+use typst_library::diag::SourceResult;
+use typst_library::engine::Engine;
+use typst_library::foundations::Resolve;
+use typst_library::layout::{Abs, Axes, Frame, Point, Region, Regions, Size, Sizing};
+use typst_utils::MaybeReverseIter;
+
+use super::layouter::{in_last_with_offset, points, Row, RowPiece};
+use super::repeated::Repeatable;
+use super::{Cell, GridLayouter};
+
+/// All information needed to layout a single rowspan.
+pub struct Rowspan {
+ /// First column of this rowspan.
+ pub x: usize,
+ /// First row of this rowspan.
+ pub y: usize,
+ /// The disambiguator for laying out the cells.
+ pub disambiguator: usize,
+ /// Amount of rows spanned by the cell at (x, y).
+ pub rowspan: usize,
+ /// Whether all rows of the rowspan are part of an unbreakable row group.
+ /// This is true e.g. in headers and footers, regardless of what the user
+ /// specified for the parent cell's `breakable` field.
+ pub is_effectively_unbreakable: bool,
+ /// The horizontal offset of this rowspan in all regions.
+ pub dx: Abs,
+ /// The vertical offset of this rowspan in the first region.
+ pub dy: Abs,
+ /// The index of the first region this rowspan appears in.
+ pub first_region: usize,
+ /// The full height in the first region this rowspan appears in, for
+ /// relative sizing.
+ pub region_full: Abs,
+ /// The vertical space available for this rowspan in each region.
+ pub heights: Vec<Abs>,
+ /// The index of the largest resolved spanned row so far.
+ /// Once a spanned row is resolved and its height added to `heights`, this
+ /// number is increased. Older rows, even if repeated through e.g. a
+ /// header, will no longer contribute height to this rowspan.
+ ///
+ /// This is `None` if no spanned rows were resolved in `finish_region` yet.
+ pub max_resolved_row: Option<usize>,
+}
+
+/// The output of the simulation of an unbreakable row group.
+#[derive(Default)]
+pub struct UnbreakableRowGroup {
+ /// The rows in this group of unbreakable rows.
+ /// Includes their indices and their predicted heights.
+ pub rows: Vec<(usize, Abs)>,
+ /// The total height of this row group.
+ pub height: Abs,
+}
+
+/// Data used to measure a cell in an auto row.
+pub struct CellMeasurementData<'layouter> {
+ /// The available width for the cell across all regions.
+ pub width: Abs,
+ /// The available height for the cell in its first region.
+ /// Infinite when the auto row is unbreakable.
+ pub height: Abs,
+ /// The backlog of heights available for the cell in later regions.
+ ///
+ /// When this is `None`, the `custom_backlog` field should be used instead.
+ /// That's because, otherwise, this field would have to contain a reference
+ /// to the `custom_backlog` field, which isn't possible in Rust without
+ /// resorting to unsafe hacks.
+ pub backlog: Option<&'layouter [Abs]>,
+ /// If the backlog needs to be built from scratch instead of reusing the
+ /// one at the current region, which is the case of a multi-region rowspan
+ /// (needs to join its backlog of already laid out heights with the current
+ /// backlog), then this vector will store the new backlog.
+ pub custom_backlog: Vec<Abs>,
+ /// The full height of the first region of the cell.
+ /// Infinite when the auto row is unbreakable.
+ pub full: Abs,
+ /// The height of the last repeated region to use in the measurement pod,
+ /// if any.
+ pub last: Option<Abs>,
+ /// The total height of previous rows spanned by the cell in the current
+ /// region (so far).
+ pub height_in_this_region: Abs,
+ /// The amount of previous regions spanned by the cell.
+ /// They are skipped for measurement purposes.
+ pub frames_in_previous_regions: usize,
+}
+
+impl<'a> GridLayouter<'a> {
+ /// Layout a rowspan over the already finished regions, plus the current
+ /// region's frame and resolved rows, if it wasn't finished yet (because
+ /// we're being called from `finish_region`, but note that this function is
+ /// also called once after all regions are finished, in which case
+ /// `current_region_data` is `None`).
+ ///
+ /// We need to do this only once we already know the heights of all
+ /// spanned rows, which is only possible after laying out the last row
+ /// spanned by the rowspan (or some row immediately after the last one).
+ pub fn layout_rowspan(
+ &mut self,
+ rowspan_data: Rowspan,
+ current_region_data: Option<(&mut Frame, &[RowPiece])>,
+ engine: &mut Engine,
+ ) -> SourceResult<()> {
+ let Rowspan {
+ x,
+ y,
+ disambiguator,
+ rowspan,
+ is_effectively_unbreakable,
+ dx,
+ dy,
+ first_region,
+ region_full,
+ heights,
+ ..
+ } = rowspan_data;
+ let [first_height, backlog @ ..] = heights.as_slice() else {
+ // Nothing to layout.
+ return Ok(());
+ };
+ let first_column = self.rcols[x];
+ let cell = self.grid.cell(x, y).unwrap();
+ let width = self.cell_spanned_width(cell, x);
+ let dx = if self.is_rtl { dx - width + first_column } else { dx };
+
+ // Prepare regions.
+ let size = Size::new(width, *first_height);
+ let mut pod: Regions = Region::new(size, Axes::splat(true)).into();
+ pod.backlog = backlog;
+
+ if !is_effectively_unbreakable
+ && self.grid.rows[y..][..rowspan]
+ .iter()
+ .any(|spanned_row| spanned_row == &Sizing::Auto)
+ {
+ // If the rowspan spans an auto row and is breakable, it will see
+ // '100%' as the full page height, at least at its first region.
+ // This is consistent with how it is measured, and with how
+ // non-rowspan cells behave in auto rows.
+ pod.full = region_full;
+ }
+
+ // Push the layouted frames directly into the finished frames.
+ let fragment = cell.layout(engine, disambiguator, self.styles, pod)?;
+ let (current_region, current_rrows) = current_region_data.unzip();
+ for ((i, finished), frame) in self
+ .finished
+ .iter_mut()
+ .chain(current_region.into_iter())
+ .skip(first_region)
+ .enumerate()
+ .zip(fragment)
+ {
+ let dy = if i == 0 {
+ // At first, we draw the rowspan starting at its expected
+ // vertical offset in the first region.
+ dy
+ } else {
+ // The rowspan continuation starts after the header (thus,
+ // at a position after the sum of the laid out header
+ // rows).
+ if let Some(Repeatable::Repeated(header)) = &self.grid.header {
+ let header_rows = self
+ .rrows
+ .get(i)
+ .map(Vec::as_slice)
+ .or(current_rrows)
+ .unwrap_or(&[])
+ .iter()
+ .take_while(|row| row.y < header.end);
+
+ header_rows.map(|row| row.height).sum()
+ } else {
+ // Without a header, start at the very top of the region.
+ Abs::zero()
+ }
+ };
+
+ finished.push_frame(Point::new(dx, dy), frame);
+ }
+
+ Ok(())
+ }
+
+ /// Checks if a row contains the beginning of one or more rowspan cells.
+ /// If so, adds them to the rowspans vector.
+ pub fn check_for_rowspans(&mut self, disambiguator: usize, y: usize) {
+ // We will compute the horizontal offset of each rowspan in advance.
+ // For that reason, we must reverse the column order when using RTL.
+ let offsets = points(self.rcols.iter().copied().rev_if(self.is_rtl));
+ for (x, dx) in (0..self.rcols.len()).rev_if(self.is_rtl).zip(offsets) {
+ let Some(cell) = self.grid.cell(x, y) else {
+ continue;
+ };
+ let rowspan = self.grid.effective_rowspan_of_cell(cell);
+ if rowspan > 1 {
+ // Rowspan detected. We will lay it out later.
+ self.rowspans.push(Rowspan {
+ x,
+ y,
+ disambiguator,
+ rowspan,
+ // The field below will be updated in
+ // 'check_for_unbreakable_rows'.
+ is_effectively_unbreakable: !cell.breakable,
+ dx,
+ // The four fields below will be updated in 'finish_region'.
+ dy: Abs::zero(),
+ first_region: usize::MAX,
+ region_full: Abs::zero(),
+ heights: vec![],
+ max_resolved_row: None,
+ });
+ }
+ }
+ }
+
+ /// Checks if the upcoming rows will be grouped together under an
+ /// unbreakable row group, and, if so, advances regions until there is
+ /// enough space for them. This can be needed, for example, if there's an
+ /// unbreakable rowspan crossing those rows.
+ pub fn check_for_unbreakable_rows(
+ &mut self,
+ current_row: usize,
+ engine: &mut Engine,
+ ) -> SourceResult<()> {
+ if self.unbreakable_rows_left == 0 {
+ // By default, the amount of unbreakable rows starting at the
+ // current row is dynamic and depends on the amount of upcoming
+ // unbreakable cells (with or without a rowspan setting).
+ let mut amount_unbreakable_rows = None;
+ if let Some(Repeatable::NotRepeated(header)) = &self.grid.header {
+ if current_row < header.end {
+ // Non-repeated header, so keep it unbreakable.
+ amount_unbreakable_rows = Some(header.end);
+ }
+ }
+ if let Some(Repeatable::NotRepeated(footer)) = &self.grid.footer {
+ if current_row >= footer.start {
+ // Non-repeated footer, so keep it unbreakable.
+ amount_unbreakable_rows = Some(self.grid.rows.len() - footer.start);
+ }
+ }
+
+ let row_group = self.simulate_unbreakable_row_group(
+ current_row,
+ amount_unbreakable_rows,
+ &self.regions,
+ engine,
+ 0,
+ )?;
+
+ // Skip to fitting region.
+ while !self.regions.size.y.fits(row_group.height)
+ && !in_last_with_offset(
+ self.regions,
+ self.header_height + self.footer_height,
+ )
+ {
+ self.finish_region(engine, false)?;
+ }
+
+ // Update unbreakable rows left.
+ self.unbreakable_rows_left = row_group.rows.len();
+ }
+
+ if self.unbreakable_rows_left > 1 {
+ // Mark rowspans as effectively unbreakable where applicable
+ // (if all of their spanned rows would be in the same unbreakable
+ // row group).
+ // Not needed if only one unbreakable row is left, since, then,
+ // no rowspan will be effectively unbreakable, at least necessarily.
+ // Note that this function is called after 'check_for_rowspans' and
+ // potentially updates the amount of remaining unbreakable rows, so
+ // it wouldn't be accurate to only check for this condition in that
+ // function. We need to check here instead.
+ for rowspan_data in
+ self.rowspans.iter_mut().filter(|rowspan| rowspan.y == current_row)
+ {
+ rowspan_data.is_effectively_unbreakable |=
+ self.unbreakable_rows_left >= rowspan_data.rowspan;
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Simulates a group of unbreakable rows, starting with the index of the
+ /// first row in the group. If `amount_unbreakable_rows` is `None`, keeps
+ /// adding rows to the group until none have unbreakable cells in common.
+ /// Otherwise, adds specifically the given amount of rows to the group.
+ ///
+ /// This is used to figure out how much height the next unbreakable row
+ /// group (if any) needs.
+ pub fn simulate_unbreakable_row_group(
+ &self,
+ first_row: usize,
+ amount_unbreakable_rows: Option<usize>,
+ regions: &Regions<'_>,
+ engine: &mut Engine,
+ disambiguator: usize,
+ ) -> SourceResult<UnbreakableRowGroup> {
+ let mut row_group = UnbreakableRowGroup::default();
+ let mut unbreakable_rows_left = amount_unbreakable_rows.unwrap_or(0);
+ for (y, row) in self.grid.rows.iter().enumerate().skip(first_row) {
+ if amount_unbreakable_rows.is_none() {
+ // When we don't set a fixed amount of unbreakable rows,
+ // determine the amount based on the rowspan of unbreakable
+ // cells in rows.
+ let additional_unbreakable_rows = self.check_for_unbreakable_cells(y);
+ unbreakable_rows_left =
+ unbreakable_rows_left.max(additional_unbreakable_rows);
+ }
+ if unbreakable_rows_left == 0 {
+ // This check is in case the first row does not have any
+ // unbreakable cells. Therefore, no unbreakable row group
+ // is formed.
+ break;
+ }
+ let height = match row {
+ Sizing::Rel(v) => v.resolve(self.styles).relative_to(regions.base().y),
+
+ // No need to pass the regions to the auto row, since
+ // unbreakable auto rows are always measured with infinite
+ // height, ignore backlog, and do not invoke the rowspan
+ // simulation procedure at all.
+ Sizing::Auto => self
+ .measure_auto_row(
+ engine,
+ disambiguator,
+ y,
+ false,
+ unbreakable_rows_left,
+ Some(&row_group),
+ )?
+ .unwrap()
+ .first()
+ .copied()
+ .unwrap_or_else(Abs::zero),
+ // Fractional rows don't matter when calculating the space
+ // needed for unbreakable rows
+ Sizing::Fr(_) => Abs::zero(),
+ };
+ row_group.height += height;
+ row_group.rows.push((y, height));
+ unbreakable_rows_left -= 1;
+ if unbreakable_rows_left == 0 {
+ // This second check is necessary so we can tell distinct
+ // but consecutive unbreakable row groups apart. If the
+ // unbreakable row group ended at this row, we stop before
+ // checking the next one.
+ break;
+ }
+ }
+
+ Ok(row_group)
+ }
+
+ /// Checks if one or more of the cells at the given row are unbreakable.
+ /// If so, returns the largest rowspan among the unbreakable cells;
+ /// the spanned rows must, as a result, be laid out in the same region.
+ pub fn check_for_unbreakable_cells(&self, y: usize) -> usize {
+ (0..self.grid.cols.len())
+ .filter_map(|x| self.grid.cell(x, y))
+ .filter(|cell| !cell.breakable)
+ .map(|cell| self.grid.effective_rowspan_of_cell(cell))
+ .max()
+ .unwrap_or(0)
+ }
+
+ /// Used by `measure_auto_row` to gather data needed to measure the cell.
+ pub fn prepare_auto_row_cell_measurement(
+ &self,
+ parent: Axes<usize>,
+ cell: &Cell,
+ breakable: bool,
+ row_group_data: Option<&UnbreakableRowGroup>,
+ ) -> CellMeasurementData<'_> {
+ let rowspan = self.grid.effective_rowspan_of_cell(cell);
+
+ // This variable is used to construct a custom backlog if the cell
+ // is a rowspan, or if headers or footers are used. When measuring, we
+ // join the heights from previous regions to the current backlog to
+ // form a rowspan's expected backlog. We also subtract the header's
+ // and footer's heights from all regions.
+ let mut custom_backlog: Vec<Abs> = vec![];
+
+ // This function is used to subtract the expected header and footer
+ // height from each upcoming region size in the current backlog and
+ // last region.
+ let mut subtract_header_footer_height_from_regions = || {
+ // Only breakable auto rows need to update their backlogs based
+ // on the presence of a header or footer, given that unbreakable
+ // auto rows don't depend on the backlog, as they only span one
+ // region.
+ if breakable
+ && (matches!(self.grid.header, Some(Repeatable::Repeated(_)))
+ || matches!(self.grid.footer, Some(Repeatable::Repeated(_))))
+ {
+ // Subtract header and footer height from all upcoming regions
+ // when measuring the cell, including the last repeated region.
+ //
+ // This will update the 'custom_backlog' vector with the
+ // updated heights of the upcoming regions.
+ let mapped_regions = self.regions.map(&mut custom_backlog, |size| {
+ Size::new(size.x, size.y - self.header_height - self.footer_height)
+ });
+
+ // Callees must use the custom backlog instead of the current
+ // backlog, so we return 'None'.
+ return (None, mapped_regions.last);
+ }
+
+ // No need to change the backlog or last region.
+ (Some(self.regions.backlog), self.regions.last)
+ };
+
+ // Each declaration, from top to bottom:
+ // 1. The height available to the cell in the first region.
+ // Usually, this will just be the size remaining in the current
+ // region.
+ // 2. The backlog of upcoming region heights to specify as
+ // available to the cell.
+ // 3. The full height of the first region of the cell.
+ // 4. Height of the last repeated region to use in the measurement pod.
+ // 5. The total height of the cell covered by previously spanned
+ // rows in this region. This is used by rowspans to be able to tell
+ // how much the auto row needs to expand.
+ // 6. The amount of frames laid out by this cell in previous
+ // regions. When the cell isn't a rowspan, this is always zero.
+ // These frames are skipped after measuring.
+ let height;
+ let backlog;
+ let full;
+ let last;
+ let height_in_this_region;
+ let frames_in_previous_regions;
+
+ if rowspan == 1 {
+ // Not a rowspan, so the cell only occupies this row. Therefore:
+ // 1. When we measure the cell below, use the available height
+ // remaining in the region as the height it has available.
+ // However, if the auto row is unbreakable, measure with infinite
+ // height instead to see how much content expands.
+ // 2. Use the region's backlog and last region when measuring,
+ // however subtract the expected header and footer heights from
+ // each upcoming size, if there is a header or footer.
+ // 3. Use the same full region height.
+ // 4. No height occupied by this cell in this region so far.
+ // 5. Yes, this cell started in this region.
+ height = if breakable { self.regions.size.y } else { Abs::inf() };
+ (backlog, last) = subtract_header_footer_height_from_regions();
+ full = if breakable { self.regions.full } else { Abs::inf() };
+ height_in_this_region = Abs::zero();
+ frames_in_previous_regions = 0;
+ } else {
+ // Height of the rowspan covered by spanned rows in the current
+ // region.
+ let laid_out_height: Abs = self
+ .lrows
+ .iter()
+ .filter_map(|row| match row {
+ Row::Frame(frame, y, _)
+ if (parent.y..parent.y + rowspan).contains(y) =>
+ {
+ Some(frame.height())
+ }
+ // Either we have a row outside of the rowspan, or a
+ // fractional row, whose size we can't really guess.
+ _ => None,
+ })
+ .sum();
+
+ // If we're currently simulating an unbreakable row group, also
+ // consider the height of previously spanned rows which are in
+ // the row group but not yet laid out.
+ let unbreakable_height: Abs = row_group_data
+ .into_iter()
+ .flat_map(|row_group| &row_group.rows)
+ .filter(|(y, _)| (parent.y..parent.y + rowspan).contains(y))
+ .map(|(_, height)| height)
+ .sum();
+
+ height_in_this_region = laid_out_height + unbreakable_height;
+
+ // Ensure we will measure the rowspan with the correct heights.
+ // For that, we will gather the total height spanned by this
+ // rowspan in previous regions.
+ if let Some((rowspan_full, [rowspan_height, rowspan_other_heights @ ..])) =
+ self.rowspans
+ .iter()
+ .find(|data| data.x == parent.x && data.y == parent.y)
+ .map(|data| (data.region_full, &*data.heights))
+ {
+ // The rowspan started in a previous region (as it already
+ // has at least one region height).
+ // Therefore, its initial height will be the height in its
+ // first spanned region, and the backlog will be the
+ // remaining heights, plus the current region's size, plus
+ // the current backlog.
+ frames_in_previous_regions = rowspan_other_heights.len() + 1;
+
+ let heights_up_to_current_region = rowspan_other_heights
+ .iter()
+ .copied()
+ .chain(std::iter::once(if breakable {
+ self.initial.y - self.header_height - self.footer_height
+ } else {
+ // When measuring unbreakable auto rows, infinite
+ // height is available for content to expand.
+ Abs::inf()
+ }));
+
+ custom_backlog = if breakable {
+ // This auto row is breakable. Therefore, join the
+ // rowspan's already laid out heights with the current
+ // region's height and current backlog to ensure a good
+ // level of accuracy in the measurements.
+ let backlog = self
+ .regions
+ .backlog
+ .iter()
+ .map(|&size| size - self.header_height - self.footer_height);
+
+ heights_up_to_current_region.chain(backlog).collect::<Vec<_>>()
+ } else {
+ // No extra backlog if this is an unbreakable auto row.
+ // Ensure, when measuring, that the rowspan can be laid
+ // out through all spanned rows which were already laid
+ // out so far, but don't go further than this region.
+ heights_up_to_current_region.collect::<Vec<_>>()
+ };
+
+ height = *rowspan_height;
+ backlog = None;
+ full = rowspan_full;
+ last = self
+ .regions
+ .last
+ .map(|size| size - self.header_height - self.footer_height);
+ } else {
+ // The rowspan started in the current region, as its vector
+ // of heights in regions is currently empty.
+ // Therefore, the initial height it has available will be
+ // the current available size, plus the size spanned in
+ // previous rows in this region (and/or unbreakable row
+ // group, if it's being simulated).
+ // The backlog and full will be that of the current region.
+ // However, use infinite height instead if we're measuring an
+ // unbreakable auto row.
+ height = if breakable {
+ height_in_this_region + self.regions.size.y
+ } else {
+ Abs::inf()
+ };
+ (backlog, last) = subtract_header_footer_height_from_regions();
+ full = if breakable { self.regions.full } else { Abs::inf() };
+ frames_in_previous_regions = 0;
+ }
+ }
+
+ let width = self.cell_spanned_width(cell, parent.x);
+ CellMeasurementData {
+ width,
+ height,
+ backlog,
+ custom_backlog,
+ full,
+ last,
+ height_in_this_region,
+ frames_in_previous_regions,
+ }
+ }
+
+ /// Used in `measure_auto_row` to prepare a rowspan's `sizes` vector.
+ /// Returns `true` if we'll need to run a simulation to more accurately
+ /// expand the auto row based on the rowspan's demanded size, or `false`
+ /// otherwise.
+ #[allow(clippy::too_many_arguments)]
+ pub fn prepare_rowspan_sizes(
+ &self,
+ auto_row_y: usize,
+ sizes: &mut Vec<Abs>,
+ cell: &Cell,
+ parent_y: usize,
+ rowspan: usize,
+ unbreakable_rows_left: usize,
+ measurement_data: &CellMeasurementData<'_>,
+ ) -> bool {
+ if sizes.len() <= 1
+ && sizes.first().map_or(true, |&first_frame_size| {
+ first_frame_size <= measurement_data.height_in_this_region
+ })
+ {
+ // Ignore a rowspan fully covered by rows in previous
+ // regions and/or in the current region.
+ sizes.clear();
+ return false;
+ }
+ if let Some(first_frame_size) = sizes.first_mut() {
+ // Subtract already covered height from the size requested
+ // by this rowspan to the auto row in the first region.
+ *first_frame_size = (*first_frame_size
+ - measurement_data.height_in_this_region)
+ .max(Abs::zero());
+ }
+
+ let last_spanned_row = parent_y + rowspan - 1;
+
+ // When the rowspan is unbreakable, or all of its upcoming
+ // spanned rows are in the same unbreakable row group, its
+ // spanned gutter will certainly be in the same region as all
+ // of its other spanned rows, thus gutters won't be removed,
+ // and we can safely reduce how much the auto row expands by
+ // without using simulation.
+ let is_effectively_unbreakable_rowspan =
+ !cell.breakable || auto_row_y + unbreakable_rows_left > last_spanned_row;
+
+ // If the rowspan doesn't end at this row and the grid has
+ // gutter, we will need to run a simulation to find out how
+ // much to expand this row by later. This is because gutters
+ // spanned by this rowspan might be removed if they appear
+ // around a pagebreak, so the auto row might have to expand a
+ // bit more to compensate for the missing gutter height.
+ // However, unbreakable rowspans aren't affected by that
+ // problem.
+ if auto_row_y != last_spanned_row
+ && !sizes.is_empty()
+ && self.grid.has_gutter
+ && !is_effectively_unbreakable_rowspan
+ {
+ return true;
+ }
+
+ // We can only predict the resolved size of upcoming fixed-size
+ // rows, but not fractional rows. In the future, we might be
+ // able to simulate and circumvent the problem with fractional
+ // rows. Relative rows are currently always measured relative
+ // to the first region as well.
+ // We can ignore auto rows since this is the last spanned auto
+ // row.
+ let will_be_covered_height: Abs = self
+ .grid
+ .rows
+ .iter()
+ .skip(auto_row_y + 1)
+ .take(last_spanned_row - auto_row_y)
+ .map(|row| match row {
+ Sizing::Rel(v) => {
+ v.resolve(self.styles).relative_to(self.regions.base().y)
+ }
+ _ => Abs::zero(),
+ })
+ .sum();
+
+ // Remove or reduce the sizes of the rowspan at the current or future
+ // regions where it will already be covered by further rows spanned by
+ // it.
+ subtract_end_sizes(sizes, will_be_covered_height);
+
+ // No need to run a simulation for this rowspan.
+ false
+ }
+
+ /// Performs a simulation to predict by how much height the last spanned
+ /// auto row will have to expand, given the current sizes of the auto row
+ /// in each region and the pending rowspans' data (parent Y, rowspan amount
+ /// and vector of requested sizes).
+ #[allow(clippy::too_many_arguments)]
+ pub fn simulate_and_measure_rowspans_in_auto_row(
+ &self,
+ y: usize,
+ resolved: &mut Vec<Abs>,
+ pending_rowspans: &[(usize, usize, Vec<Abs>)],
+ unbreakable_rows_left: usize,
+ row_group_data: Option<&UnbreakableRowGroup>,
+ mut disambiguator: usize,
+ engine: &mut Engine,
+ ) -> SourceResult<()> {
+ // To begin our simulation, we have to unify the sizes demanded by
+ // each rowspan into one simple vector of sizes, as if they were
+ // all a single rowspan. These sizes will be appended to
+ // 'resolved' once we finish our simulation.
+ let mut simulated_sizes: Vec<Abs> = vec![];
+ let last_resolved_size = resolved.last().copied();
+ let mut max_spanned_row = y;
+ for (parent_y, rowspan, sizes) in pending_rowspans {
+ let mut sizes = sizes.iter();
+ for (target, size) in resolved.iter_mut().zip(&mut sizes) {
+ // First, we update the already resolved sizes as required
+ // by this rowspan. No need to simulate this since the auto row
+ // will already expand throughout already resolved regions.
+ // Our simulation, therefore, won't otherwise change already
+ // resolved sizes, other than, perhaps, the last one (at the
+ // last currently resolved region, at which we can expand).
+ target.set_max(*size);
+ }
+ for (simulated_target, rowspan_size) in
+ simulated_sizes.iter_mut().zip(&mut sizes)
+ {
+ // The remaining sizes are exclusive to rowspans, since
+ // other cells in this row didn't require as many regions.
+ // We will perform a simulation to see how much of these sizes
+ // does the auto row actually need to expand by, and how much
+ // is already covered by upcoming rows spanned by the rowspans.
+ simulated_target.set_max(*rowspan_size);
+ }
+ simulated_sizes.extend(sizes);
+ max_spanned_row = max_spanned_row.max(parent_y + rowspan - 1);
+ }
+ if simulated_sizes.is_empty() && resolved.last() == last_resolved_size.as_ref() {
+ // The rowspans already fit in the already resolved sizes.
+ // No need for simulation.
+ return Ok(());
+ }
+
+ // We will be updating the last resolved size (expanding the auto
+ // row) as needed. Therefore, consider it as part of the simulation.
+ // At the end, we push it back.
+ if let Some(modified_last_resolved_size) = resolved.pop() {
+ simulated_sizes.insert(0, modified_last_resolved_size);
+ }
+
+ // Prepare regions for simulation.
+ // If we're currently inside an unbreakable row group simulation,
+ // subtract the current row group height from the available space
+ // when simulating rowspans in said group.
+ let mut simulated_regions = self.regions;
+ simulated_regions.size.y -=
+ row_group_data.map_or(Abs::zero(), |row_group| row_group.height);
+
+ for _ in 0..resolved.len() {
+ // Ensure we start at the region where we will expand the auto
+ // row.
+ // Note that we won't accidentally call '.next()' once more than
+ // desired (we won't skip the last resolved frame, where we will
+ // expand) because we popped the last resolved size from the
+ // resolved vector, above.
+ simulated_regions.next();
+ disambiguator += 1;
+
+ // Subtract the initial header and footer height, since that's the
+ // height we used when subtracting from the region backlog's
+ // heights while measuring cells.
+ simulated_regions.size.y -= self.header_height + self.footer_height;
+ }
+
+ if let Some(original_last_resolved_size) = last_resolved_size {
+ // We're now at the (current) last region of this auto row.
+ // Consider resolved height as already taken space.
+ simulated_regions.size.y -= original_last_resolved_size;
+ }
+
+ // Now we run the simulation to check how much the auto row needs to
+ // grow to ensure that rowspans have the height they need.
+ let simulations_stabilized = self.run_rowspan_simulation(
+ y,
+ max_spanned_row,
+ simulated_regions,
+ &mut simulated_sizes,
+ engine,
+ last_resolved_size,
+ unbreakable_rows_left,
+ disambiguator,
+ )?;
+
+ if !simulations_stabilized {
+ // If the simulation didn't stabilize above, we will just pretend
+ // all gutters were removed, as a best effort. That means the auto
+ // row will expand more than it normally should, but there isn't
+ // much we can do.
+ let will_be_covered_height = self
+ .grid
+ .rows
+ .iter()
+ .enumerate()
+ .skip(y + 1)
+ .take(max_spanned_row - y)
+ .filter(|(y, _)| !self.grid.is_gutter_track(*y))
+ .map(|(_, row)| match row {
+ Sizing::Rel(v) => {
+ v.resolve(self.styles).relative_to(self.regions.base().y)
+ }
+ _ => Abs::zero(),
+ })
+ .sum();
+
+ subtract_end_sizes(&mut simulated_sizes, will_be_covered_height);
+ }
+
+ resolved.extend(simulated_sizes);
+
+ Ok(())
+ }
+
+ /// Performs a simulation of laying out multiple rowspans (consolidated
+ /// into a single vector of simulated sizes) ending in a certain auto row
+ /// in order to find out how much the auto row will need to expand to cover
+ /// the rowspans' requested sizes, considering how much size has been
+ /// covered by other rows and by gutter between rows.
+ ///
+ /// For example, for a rowspan cell containing a block of 8pt of height
+ /// spanning rows (1pt, auto, 0.5pt, 0.5pt), with a gutter of 1pt between
+ /// each row, we have that the rows it spans provide 1pt + 0.5pt + 0.5pt
+ /// = 2pt of height, plus 1pt + 1pt + 1pt = 3pt of gutter, with a total of
+ /// 2pt + 3pt = 5pt of height already covered by fixed-size rows and
+ /// gutters. This means that the auto row must (under normal conditions)
+ /// expand by 3pt (8pt - 5pt) so that the rowspan has enough height across
+ /// rows to fully draw its contents.
+ ///
+ /// However, it's possible that the last row is sent to the next page to
+ /// respect a pagebreak, and then the 1pt gutter before it disappears. This
+ /// would lead to our rowspan having a height of 7pt available if we fail
+ /// to predict this situation when measuring the auto row.
+ ///
+ /// The algorithm below will, thus, attempt to simulate the layout of each
+ /// spanned row, considering the space available in the current page and in
+ /// upcoming pages (through the region backlog), in order to predict which
+ /// rows will be sent to a new page and thus have their preceding gutter
+ /// spacing removed (meaning the auto row has to grow a bit more). After
+ /// simulating, we subtract the total height spanned by upcoming rows and
+ /// gutter from the total rowspan height - this will be how much our auto
+ /// row has to expand. We then simulate again to check if, if the auto row
+ /// expanded by that amount, that would prompt the auto row to need to
+ /// expand even more, because expanding the auto row might cause some other
+ /// larger gutter spacing to disappear (leading to the rowspan having less
+ /// space available instead of more); if so, we update the amount to expand
+ /// and run the simulation again. Otherwise (if it should expand by the
+ /// same amount, meaning we predicted correctly, or by less, meaning the
+ /// auto row will be a bit larger than it should be, but that's a
+ /// compromise we're willing to accept), we conclude the simulation
+ /// (consider it stabilized) and return the result.
+ ///
+ /// Tries up to 5 times. If two consecutive simulations stabilize, then
+ /// we subtract the predicted expansion height ('amount_to_grow') from the
+ /// total height requested by rowspans (the 'requested_rowspan_height') to
+ /// obtain how much height is covered by upcoming rows, according to our
+ /// simulation, and the result of that operation is used to reduce or
+ /// remove heights from the end of the vector of simulated sizes, such that
+ /// the remaining heights are exactly how much the auto row should expand
+ /// by. Then, we return `true`.
+ ///
+ /// If the simulations don't stabilize (they return 5 different and
+ /// successively larger values), aborts and returns `false`.
+ #[allow(clippy::too_many_arguments)]
+ fn run_rowspan_simulation(
+ &self,
+ y: usize,
+ max_spanned_row: usize,
+ mut simulated_regions: Regions<'_>,
+ simulated_sizes: &mut Vec<Abs>,
+ engine: &mut Engine,
+ last_resolved_size: Option<Abs>,
+ unbreakable_rows_left: usize,
+ mut disambiguator: usize,
+ ) -> SourceResult<bool> {
+ // The max amount this row can expand will be the total size requested
+ // by rowspans which was not yet resolved. It is worth noting that,
+ // earlier, we pushed the last resolved size to 'simulated_sizes' as
+ // row expansion starts with it, so it's possible a rowspan requested
+ // to extend that size (we will see, through the simulation, if that's
+ // needed); however, we must subtract that resolved size from the total
+ // sum of sizes, as it was already resolved and thus the auto row will
+ // already grow by at least that much in the last resolved region (we
+ // would grow by the same size twice otherwise).
+ let requested_rowspan_height =
+ simulated_sizes.iter().sum::<Abs>() - last_resolved_size.unwrap_or_default();
+
+ // The amount the row will effectively grow by, according to the latest
+ // simulation.
+ let mut amount_to_grow = Abs::zero();
+
+ // Try to simulate up to 5 times. If it doesn't stabilize at a value
+ // which, when used and combined with upcoming spanned rows, covers all
+ // of the requested rowspan height, we give up.
+ for _attempt in 0..5 {
+ let rowspan_simulator = RowspanSimulator::new(
+ disambiguator,
+ simulated_regions,
+ self.header_height,
+ self.footer_height,
+ );
+
+ let total_spanned_height = rowspan_simulator.simulate_rowspan_layout(
+ y,
+ max_spanned_row,
+ amount_to_grow,
+ requested_rowspan_height,
+ unbreakable_rows_left,
+ self,
+ engine,
+ )?;
+
+ // If the total height spanned by upcoming spanned rows plus the
+ // current amount we predict the auto row will have to grow (from
+ // the previous iteration) are larger than the size requested by
+ // rowspans, this means the auto row will grow enough in order to
+ // cover the requested rowspan height, so we stop the simulation.
+ //
+ // If that's not yet the case, we will simulate again and make the
+ // auto row grow even more, and do so until either the auto row has
+ // grown enough, or we tried to do so over 5 times.
+ //
+ // A flaw of this approach is that we consider rowspans' content to
+ // be contiguous. That is, we treat rowspans' requested heights as
+ // a simple number, instead of properly using the vector of
+ // requested heights in each region. This can lead to some
+ // weirdness when using multi-page rowspans with content that
+ // reacts to the amount of space available, including paragraphs.
+ // However, this is probably the best we can do for now.
+ if (total_spanned_height + amount_to_grow).fits(requested_rowspan_height) {
+ // Reduce sizes by the amount to be covered by upcoming spanned
+ // rows, which is equivalent to the amount that we don't grow.
+ // We reduce from the end as that's where the spanned rows will
+ // cover. The remaining sizes will all be covered by the auto
+ // row instead (which will grow by those sizes).
+ subtract_end_sizes(
+ simulated_sizes,
+ requested_rowspan_height - amount_to_grow,
+ );
+
+ if let Some(last_resolved_size) = last_resolved_size {
+ // Ensure the first simulated size is at least as large as
+ // the last resolved size (its initial value). As it was
+ // already resolved before, we must not reduce below the
+ // resolved size to avoid problems with non-rowspan cells.
+ if let Some(first_simulated_size) = simulated_sizes.first_mut() {
+ first_simulated_size.set_max(last_resolved_size);
+ } else {
+ simulated_sizes.push(last_resolved_size);
+ }
+ }
+
+ return Ok(true);
+ }
+
+ // For the next simulation, we will test if the auto row can grow
+ // by precisely how much rowspan height is not covered by upcoming
+ // spanned rows, according to the current simulation.
+ // We know that the new amount to grow is larger (and thus the
+ // auto row only expands between each simulation), because we
+ // checked above if
+ // 'total_spanned_height + (now old_)amount_to_grow >= requested_rowspan_height',
+ // which was false, so it holds that
+ // 'total_spanned_height + old_amount_to_grow < requested_rowspan_height'
+ // Thus,
+ // 'old_amount_to_grow < requested_rowspan_height - total_spanned_height'
+ // Therefore, by definition, 'old_amount_to_grow < amount_to_grow'.
+ let old_amount_to_grow = std::mem::replace(
+ &mut amount_to_grow,
+ requested_rowspan_height - total_spanned_height,
+ );
+
+ // We advance the 'regions' variable accordingly, so that, in the
+ // next simulation, we consider already grown space as final.
+ // That is, we effectively simulate how rows would be placed if the
+ // auto row grew by precisely the new value of 'amount_to_grow'.
+ let mut extra_amount_to_grow = amount_to_grow - old_amount_to_grow;
+ while extra_amount_to_grow > Abs::zero()
+ && simulated_regions.size.y < extra_amount_to_grow
+ {
+ extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero());
+ simulated_regions.next();
+ simulated_regions.size.y -= self.header_height + self.footer_height;
+ disambiguator += 1;
+ }
+ simulated_regions.size.y -= extra_amount_to_grow;
+ }
+
+ // Simulation didn't succeed in 5 attempts.
+ Ok(false)
+ }
+}
+
+/// Auxiliary structure holding state during rowspan simulation.
+struct RowspanSimulator<'a> {
+ /// The number of finished regions.
+ finished: usize,
+ /// The state of regions during the simulation.
+ regions: Regions<'a>,
+ /// The height of the header in the currently simulated region.
+ header_height: Abs,
+ /// The height of the footer in the currently simulated region.
+ footer_height: Abs,
+ /// The total spanned height so far in the simulation.
+ total_spanned_height: Abs,
+ /// Height of the latest spanned gutter row in the simulation.
+ /// Zero if it was removed.
+ latest_spanned_gutter_height: Abs,
+}
+
+impl<'a> RowspanSimulator<'a> {
+ /// Creates new rowspan simulation state with the given regions and initial
+ /// header and footer heights. Other fields should always start as zero.
+ fn new(
+ finished: usize,
+ regions: Regions<'a>,
+ header_height: Abs,
+ footer_height: Abs,
+ ) -> Self {
+ Self {
+ finished,
+ regions,
+ header_height,
+ footer_height,
+ total_spanned_height: Abs::zero(),
+ latest_spanned_gutter_height: Abs::zero(),
+ }
+ }
+
+ /// Calculates the total spanned height of the rowspan.
+ /// Stops calculating if, at any point in the simulation, the value of
+ /// `total_spanned_height + amount_to_grow` becomes larger than
+ /// `requested_rowspan_height`, as the results are not going to become any
+ /// more useful after that point.
+ #[allow(clippy::too_many_arguments)]
+ fn simulate_rowspan_layout(
+ mut self,
+ y: usize,
+ max_spanned_row: usize,
+ amount_to_grow: Abs,
+ requested_rowspan_height: Abs,
+ mut unbreakable_rows_left: usize,
+ layouter: &GridLayouter<'_>,
+ engine: &mut Engine,
+ ) -> SourceResult<Abs> {
+ let spanned_rows = &layouter.grid.rows[y + 1..=max_spanned_row];
+ for (offset, row) in spanned_rows.iter().enumerate() {
+ if (self.total_spanned_height + amount_to_grow).fits(requested_rowspan_height)
+ {
+ // Stop the simulation, as the combination of upcoming
+ // spanned rows (so far) and the current amount the auto
+ // row expands by has already fully covered the height the
+ // rowspans need.
+ return Ok(self.total_spanned_height);
+ }
+ let spanned_y = y + 1 + offset;
+ let is_gutter = layouter.grid.is_gutter_track(spanned_y);
+
+ if unbreakable_rows_left == 0 {
+ // Simulate unbreakable row groups, and skip regions until
+ // they fit. There is no risk of infinite recursion, as
+ // no auto rows participate in the simulation, so the
+ // unbreakable row group simulator won't recursively call
+ // 'measure_auto_row' or (consequently) this function.
+ let row_group = layouter.simulate_unbreakable_row_group(
+ spanned_y,
+ None,
+ &self.regions,
+ engine,
+ 0,
+ )?;
+ while !self.regions.size.y.fits(row_group.height)
+ && !in_last_with_offset(
+ self.regions,
+ self.header_height + self.footer_height,
+ )
+ {
+ self.finish_region(layouter, engine)?;
+ }
+
+ unbreakable_rows_left = row_group.rows.len();
+ }
+
+ match row {
+ // Fixed-size spanned rows are what we are interested in.
+ // They contribute a fixed amount of height to our rowspan.
+ Sizing::Rel(v) => {
+ let height =
+ v.resolve(layouter.styles).relative_to(self.regions.base().y);
+ self.total_spanned_height += height;
+ if is_gutter {
+ self.latest_spanned_gutter_height = height;
+ }
+
+ let mut skipped_region = false;
+ while unbreakable_rows_left == 0
+ && !self.regions.size.y.fits(height)
+ && !in_last_with_offset(
+ self.regions,
+ self.header_height + self.footer_height,
+ )
+ {
+ self.finish_region(layouter, engine)?;
+
+ skipped_region = true;
+ }
+
+ if !skipped_region || !is_gutter {
+ // No gutter at the top of a new region, so don't
+ // account for it if we just skipped a region.
+ self.regions.size.y -= height;
+ }
+ }
+ Sizing::Auto => {
+ // We only simulate for rowspans which end at the
+ // current auto row. Therefore, there won't be any
+ // further auto rows.
+ unreachable!();
+ }
+ // For now, we ignore fractional rows on simulation.
+ Sizing::Fr(_) if is_gutter => {
+ self.latest_spanned_gutter_height = Abs::zero();
+ }
+ Sizing::Fr(_) => {}
+ }
+
+ unbreakable_rows_left = unbreakable_rows_left.saturating_sub(1);
+ }
+
+ Ok(self.total_spanned_height)
+ }
+
+ fn simulate_header_footer_layout(
+ &mut self,
+ layouter: &GridLayouter<'_>,
+ engine: &mut Engine,
+ ) -> SourceResult<()> {
+ // We can't just use the initial header/footer height on each region,
+ // because header/footer height might vary depending on region size if
+ // it contains rows with relative lengths. Therefore, we re-simulate
+ // headers and footers on each new region.
+ // It's true that, when measuring cells, we reduce each height in the
+ // backlog to consider the initial header and footer heights; however,
+ // our simulation checks what happens AFTER the auto row, so we can
+ // just use the original backlog from `self.regions`.
+ let disambiguator = self.finished;
+ let header_height =
+ if let Some(Repeatable::Repeated(header)) = &layouter.grid.header {
+ layouter
+ .simulate_header(header, &self.regions, engine, disambiguator)?
+ .height
+ } else {
+ Abs::zero()
+ };
+
+ let footer_height =
+ if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer {
+ layouter
+ .simulate_footer(footer, &self.regions, engine, disambiguator)?
+ .height
+ } else {
+ Abs::zero()
+ };
+
+ let mut skipped_region = false;
+
+ // Skip until we reach a fitting region for both header and footer.
+ while !self.regions.size.y.fits(header_height + footer_height)
+ && self.regions.may_progress()
+ {
+ self.regions.next();
+ self.finished += 1;
+ skipped_region = true;
+ }
+
+ if let Some(Repeatable::Repeated(header)) = &layouter.grid.header {
+ self.header_height = if skipped_region {
+ // Simulate headers again, at the new region, as
+ // the full region height may change.
+ layouter
+ .simulate_header(header, &self.regions, engine, disambiguator)?
+ .height
+ } else {
+ header_height
+ };
+ }
+
+ if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer {
+ self.footer_height = if skipped_region {
+ // Simulate footers again, at the new region, as
+ // the full region height may change.
+ layouter
+ .simulate_footer(footer, &self.regions, engine, disambiguator)?
+ .height
+ } else {
+ footer_height
+ };
+ }
+
+ // Consume the header's and footer's heights from the new region,
+ // but don't consider them spanned. The rowspan does not go over the
+ // header or footer (as an invariant, any rowspans spanning any header
+ // or footer rows are fully contained within that header's or footer's rows).
+ self.regions.size.y -= self.header_height + self.footer_height;
+
+ Ok(())
+ }
+
+ fn finish_region(
+ &mut self,
+ layouter: &GridLayouter<'_>,
+ engine: &mut Engine,
+ ) -> SourceResult<()> {
+ // If a row was pushed to the next region, the immediately
+ // preceding gutter row is removed.
+ self.total_spanned_height -= self.latest_spanned_gutter_height;
+ self.latest_spanned_gutter_height = Abs::zero();
+ self.regions.next();
+ self.finished += 1;
+
+ self.simulate_header_footer_layout(layouter, engine)
+ }
+}
+
+/// Subtracts some size from the end of a vector of sizes.
+/// For example, subtracting 5pt from \[2pt, 1pt, 3pt\] will result in \[1pt\].
+fn subtract_end_sizes(sizes: &mut Vec<Abs>, mut subtract: Abs) {
+ while subtract > Abs::zero() && sizes.last().is_some_and(|&size| size <= subtract) {
+ subtract -= sizes.pop().unwrap();
+ }
+ if subtract > Abs::zero() {
+ if let Some(last_size) = sizes.last_mut() {
+ *last_size -= subtract;
+ }
+ }
+}
diff --git a/crates/typst-layout/src/image.rs b/crates/typst-layout/src/image.rs
new file mode 100644
index 00000000..84a60282
--- /dev/null
+++ b/crates/typst-layout/src/image.rs
@@ -0,0 +1,142 @@
+use std::ffi::OsStr;
+
+use typst_library::diag::{bail, warning, At, SourceResult, StrResult};
+use typst_library::engine::Engine;
+use typst_library::foundations::{Packed, Smart, StyleChain};
+use typst_library::introspection::Locator;
+use typst_library::layout::{
+ Abs, Axes, FixedAlignment, Frame, FrameItem, Point, Region, Size,
+};
+use typst_library::loading::Readable;
+use typst_library::text::families;
+use typst_library::visualize::{
+ Image, ImageElem, ImageFit, ImageFormat, Path, RasterFormat, VectorFormat,
+};
+
+/// Layout the image.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_image(
+ elem: &Packed<ImageElem>,
+ engine: &mut Engine,
+ _: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ let span = elem.span();
+
+ // Take the format that was explicitly defined, or parse the extension,
+ // or try to detect the format.
+ let data = elem.data();
+ let format = match elem.format(styles) {
+ Smart::Custom(v) => v,
+ Smart::Auto => determine_format(elem.path().as_str(), data).at(span)?,
+ };
+
+ // Warn the user if the image contains a foreign object. Not perfect
+ // because the svg could also be encoded, but that's an edge case.
+ if format == ImageFormat::Vector(VectorFormat::Svg) {
+ let has_foreign_object =
+ data.as_str().is_some_and(|s| s.contains("<foreignObject"));
+
+ if has_foreign_object {
+ engine.sink.warn(warning!(
+ span,
+ "image contains foreign object";
+ hint: "SVG images with foreign objects might render incorrectly in typst";
+ hint: "see https://github.com/typst/typst/issues/1421 for more information"
+ ));
+ }
+ }
+
+ // Construct the image itself.
+ let image = Image::with_fonts(
+ data.clone().into(),
+ format,
+ elem.alt(styles),
+ engine.world,
+ &families(styles).collect::<Vec<_>>(),
+ )
+ .at(span)?;
+
+ // Determine the image's pixel aspect ratio.
+ let pxw = image.width();
+ let pxh = image.height();
+ let px_ratio = pxw / pxh;
+
+ // Determine the region's aspect ratio.
+ let region_ratio = region.size.x / region.size.y;
+
+ // Find out whether the image is wider or taller than the region.
+ let wide = px_ratio > region_ratio;
+
+ // The space into which the image will be placed according to its fit.
+ let target = if region.expand.x && region.expand.y {
+ // If both width and height are forced, take them.
+ region.size
+ } else if region.expand.x {
+ // If just width is forced, take it.
+ Size::new(region.size.x, region.size.y.min(region.size.x / px_ratio))
+ } else if region.expand.y {
+ // If just height is forced, take it.
+ Size::new(region.size.x.min(region.size.y * px_ratio), region.size.y)
+ } else {
+ // If neither is forced, take the natural image size at the image's
+ // DPI bounded by the available space.
+ let dpi = image.dpi().unwrap_or(Image::DEFAULT_DPI);
+ let natural = Axes::new(pxw, pxh).map(|v| Abs::inches(v / dpi));
+ Size::new(
+ natural.x.min(region.size.x).min(region.size.y * px_ratio),
+ natural.y.min(region.size.y).min(region.size.x / px_ratio),
+ )
+ };
+
+ // Compute the actual size of the fitted image.
+ let fit = elem.fit(styles);
+ let fitted = match fit {
+ ImageFit::Cover | ImageFit::Contain => {
+ if wide == (fit == ImageFit::Contain) {
+ Size::new(target.x, target.x / px_ratio)
+ } else {
+ Size::new(target.y * px_ratio, target.y)
+ }
+ }
+ ImageFit::Stretch => target,
+ };
+
+ // First, place the image in a frame of exactly its size and then resize
+ // the frame to the target size, center aligning the image in the
+ // process.
+ let mut frame = Frame::soft(fitted);
+ frame.push(Point::zero(), FrameItem::Image(image, fitted, span));
+ frame.resize(target, Axes::splat(FixedAlignment::Center));
+
+ // Create a clipping group if only part of the image should be visible.
+ if fit == ImageFit::Cover && !target.fits(fitted) {
+ frame.clip(Path::rect(frame.size()));
+ }
+
+ Ok(frame)
+}
+
+/// Determine the image format based on path and data.
+fn determine_format(path: &str, data: &Readable) -> StrResult<ImageFormat> {
+ let ext = std::path::Path::new(path)
+ .extension()
+ .and_then(OsStr::to_str)
+ .unwrap_or_default()
+ .to_lowercase();
+
+ Ok(match ext.as_str() {
+ "png" => ImageFormat::Raster(RasterFormat::Png),
+ "jpg" | "jpeg" => ImageFormat::Raster(RasterFormat::Jpg),
+ "gif" => ImageFormat::Raster(RasterFormat::Gif),
+ "svg" | "svgz" => ImageFormat::Vector(VectorFormat::Svg),
+ _ => match &data {
+ Readable::Str(_) => ImageFormat::Vector(VectorFormat::Svg),
+ Readable::Bytes(bytes) => match RasterFormat::detect(bytes) {
+ Some(f) => ImageFormat::Raster(f),
+ None => bail!("unknown image format"),
+ },
+ },
+ })
+}
diff --git a/crates/typst-layout/src/inline/box.rs b/crates/typst-layout/src/inline/box.rs
new file mode 100644
index 00000000..30572e4e
--- /dev/null
+++ b/crates/typst-layout/src/inline/box.rs
@@ -0,0 +1,87 @@
+use once_cell::unsync::Lazy;
+use typst_library::diag::SourceResult;
+use typst_library::engine::Engine;
+use typst_library::foundations::{Packed, StyleChain};
+use typst_library::introspection::Locator;
+use typst_library::layout::{BoxElem, Frame, FrameKind, Size};
+use typst_library::visualize::Stroke;
+use typst_utils::Numeric;
+
+use crate::flow::unbreakable_pod;
+use crate::shapes::{clip_rect, fill_and_stroke};
+
+/// Lay out a box as part of a paragraph.
+#[typst_macros::time(name = "box", span = elem.span())]
+pub fn layout_box(
+ elem: &Packed<BoxElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Size,
+) -> SourceResult<Frame> {
+ // Fetch sizing properties.
+ let width = elem.width(styles);
+ let height = elem.height(styles);
+ let inset = elem.inset(styles).unwrap_or_default();
+
+ // Build the pod region.
+ let pod = unbreakable_pod(&width, &height.into(), &inset, styles, region);
+
+ // Layout the body.
+ let mut frame = match elem.body(styles) {
+ // If we have no body, just create an empty frame. If necessary,
+ // its size will be adjusted below.
+ None => Frame::hard(Size::zero()),
+
+ // If we have a child, layout it into the body. Boxes are boundaries
+ // for gradient relativeness, so we set the `FrameKind` to `Hard`.
+ Some(body) => crate::layout_frame(engine, body, locator, styles, pod)?
+ .with_kind(FrameKind::Hard),
+ };
+
+ // Enforce a correct frame size on the expanded axes. Do this before
+ // applying the inset, since the pod shrunk.
+ frame.set_size(pod.expand.select(pod.size, frame.size()));
+
+ // Apply the inset.
+ if !inset.is_zero() {
+ crate::pad::grow(&mut frame, &inset);
+ }
+
+ // Prepare fill and stroke.
+ let fill = elem.fill(styles);
+ let stroke = elem
+ .stroke(styles)
+ .unwrap_or_default()
+ .map(|s| s.map(Stroke::unwrap_or_default));
+
+ // Only fetch these if necessary (for clipping or filling/stroking).
+ let outset = Lazy::new(|| elem.outset(styles).unwrap_or_default());
+ let radius = Lazy::new(|| elem.radius(styles).unwrap_or_default());
+
+ // Clip the contents, if requested.
+ if elem.clip(styles) {
+ let size = frame.size() + outset.relative_to(frame.size()).sum_by_axis();
+ frame.clip(clip_rect(size, &radius, &stroke));
+ }
+
+ // Add fill and/or stroke.
+ if fill.is_some() || stroke.iter().any(Option::is_some) {
+ fill_and_stroke(&mut frame, fill, &stroke, &outset, &radius, elem.span());
+ }
+
+ // Assign label to the frame.
+ if let Some(label) = elem.label() {
+ frame.label(label);
+ }
+
+ // Apply baseline shift. Do this after setting the size and applying the
+ // inset, so that a relative shift is resolved relative to the final
+ // height.
+ let shift = elem.baseline(styles).relative_to(frame.height());
+ if !shift.is_zero() {
+ frame.set_baseline(frame.baseline() - shift);
+ }
+
+ Ok(frame)
+}
diff --git a/crates/typst-layout/src/inline/collect.rs b/crates/typst-layout/src/inline/collect.rs
new file mode 100644
index 00000000..fbcddee5
--- /dev/null
+++ b/crates/typst-layout/src/inline/collect.rs
@@ -0,0 +1,328 @@
+use typst_library::diag::bail;
+use typst_library::foundations::{Packed, Resolve};
+use typst_library::introspection::{SplitLocator, Tag, TagElem};
+use typst_library::layout::{
+ Abs, AlignElem, BoxElem, Dir, Fr, Frame, HElem, InlineElem, InlineItem, Sizing,
+ Spacing,
+};
+use typst_library::text::{
+ is_default_ignorable, LinebreakElem, SmartQuoteElem, SmartQuoter, SmartQuotes,
+ SpaceElem, TextElem,
+};
+use typst_syntax::Span;
+use typst_utils::Numeric;
+
+use super::*;
+
+// The characters by which spacing, inline content and pins are replaced in the
+// paragraph's full text.
+const SPACING_REPLACE: &str = " "; // Space
+const OBJ_REPLACE: &str = "\u{FFFC}"; // Object Replacement Character
+
+// Unicode BiDi control characters.
+const LTR_EMBEDDING: &str = "\u{202A}";
+const RTL_EMBEDDING: &str = "\u{202B}";
+const POP_EMBEDDING: &str = "\u{202C}";
+const LTR_ISOLATE: &str = "\u{2066}";
+const POP_ISOLATE: &str = "\u{2069}";
+
+/// A prepared item in a paragraph layout.
+#[derive(Debug)]
+pub enum Item<'a> {
+ /// A shaped text run with consistent style and direction.
+ Text(ShapedText<'a>),
+ /// Absolute spacing between other items, and whether it is weak.
+ Absolute(Abs, bool),
+ /// Fractional spacing between other items.
+ Fractional(Fr, Option<(&'a Packed<BoxElem>, Locator<'a>, StyleChain<'a>)>),
+ /// Layouted inline-level content.
+ Frame(Frame, StyleChain<'a>),
+ /// A tag.
+ Tag(&'a Tag),
+ /// An item that is invisible and needs to be skipped, e.g. a Unicode
+ /// isolate.
+ Skip(&'static str),
+}
+
+impl<'a> Item<'a> {
+ /// If this a text item, return it.
+ pub fn text(&self) -> Option<&ShapedText<'a>> {
+ match self {
+ Self::Text(shaped) => Some(shaped),
+ _ => None,
+ }
+ }
+
+ /// If this a text item, return it mutably.
+ pub fn text_mut(&mut self) -> Option<&mut ShapedText<'a>> {
+ match self {
+ Self::Text(shaped) => Some(shaped),
+ _ => None,
+ }
+ }
+
+ /// Return the textual representation of this item: Either just itself (for
+ /// a text item) or a replacement string (for any other item).
+ pub fn textual(&self) -> &str {
+ match self {
+ Self::Text(shaped) => shaped.text,
+ Self::Absolute(_, _) | Self::Fractional(_, _) => SPACING_REPLACE,
+ Self::Frame(_, _) => OBJ_REPLACE,
+ Self::Tag(_) => "",
+ Self::Skip(s) => s,
+ }
+ }
+
+ /// The text length of the item.
+ pub fn textual_len(&self) -> usize {
+ self.textual().len()
+ }
+
+ /// The natural layouted width of the item.
+ pub fn natural_width(&self) -> Abs {
+ match self {
+ Self::Text(shaped) => shaped.width,
+ Self::Absolute(v, _) => *v,
+ Self::Frame(frame, _) => frame.width(),
+ Self::Fractional(_, _) | Self::Tag(_) => Abs::zero(),
+ Self::Skip(_) => Abs::zero(),
+ }
+ }
+}
+
+/// An item or not-yet shaped text. We can't shape text until we have collected
+/// all items because only then we can compute BiDi, and we need to split shape
+/// runs at level boundaries.
+#[derive(Debug)]
+pub enum Segment<'a> {
+ /// One or multiple collapsed text children. Stores how long the segment is
+ /// (in bytes of the full text string).
+ Text(usize, StyleChain<'a>),
+ /// An already prepared item.
+ Item(Item<'a>),
+}
+
+impl Segment<'_> {
+ /// The text length of the item.
+ pub fn textual_len(&self) -> usize {
+ match self {
+ Self::Text(len, _) => *len,
+ Self::Item(item) => item.textual_len(),
+ }
+ }
+}
+
+/// Collects all text of the paragraph into one string and a collection of
+/// segments that correspond to pieces of that string. This also performs
+/// string-level preprocessing like case transformations.
+#[typst_macros::time]
+pub fn collect<'a>(
+ children: &'a StyleVec,
+ engine: &mut Engine<'_>,
+ locator: &mut SplitLocator<'a>,
+ styles: &'a StyleChain<'a>,
+ region: Size,
+ consecutive: bool,
+) -> SourceResult<(String, Vec<Segment<'a>>, SpanMapper)> {
+ let mut collector = Collector::new(2 + children.len());
+ let mut quoter = SmartQuoter::new();
+
+ let outer_dir = TextElem::dir_in(*styles);
+ let first_line_indent = ParElem::first_line_indent_in(*styles);
+ if !first_line_indent.is_zero()
+ && consecutive
+ && AlignElem::alignment_in(*styles).resolve(*styles).x == outer_dir.start().into()
+ {
+ collector.push_item(Item::Absolute(first_line_indent.resolve(*styles), false));
+ collector.spans.push(1, Span::detached());
+ }
+
+ let hang = ParElem::hanging_indent_in(*styles);
+ if !hang.is_zero() {
+ collector.push_item(Item::Absolute(-hang, false));
+ collector.spans.push(1, Span::detached());
+ }
+
+ for (child, styles) in children.iter(styles) {
+ let prev_len = collector.full.len();
+
+ if child.is::<SpaceElem>() {
+ collector.push_text(" ", styles);
+ } else if let Some(elem) = child.to_packed::<TextElem>() {
+ collector.build_text(styles, |full| {
+ let dir = TextElem::dir_in(styles);
+ if dir != outer_dir {
+ // Insert "Explicit Directional Embedding".
+ match dir {
+ Dir::LTR => full.push_str(LTR_EMBEDDING),
+ Dir::RTL => full.push_str(RTL_EMBEDDING),
+ _ => {}
+ }
+ }
+
+ if let Some(case) = TextElem::case_in(styles) {
+ full.push_str(&case.apply(elem.text()));
+ } else {
+ full.push_str(elem.text());
+ }
+
+ if dir != outer_dir {
+ // Insert "Pop Directional Formatting".
+ full.push_str(POP_EMBEDDING);
+ }
+ });
+ } else if let Some(elem) = child.to_packed::<HElem>() {
+ let amount = elem.amount();
+ if amount.is_zero() {
+ continue;
+ }
+
+ collector.push_item(match amount {
+ Spacing::Fr(fr) => Item::Fractional(*fr, None),
+ Spacing::Rel(rel) => Item::Absolute(
+ rel.resolve(styles).relative_to(region.x),
+ elem.weak(styles),
+ ),
+ });
+ } else if let Some(elem) = child.to_packed::<LinebreakElem>() {
+ collector
+ .push_text(if elem.justify(styles) { "\u{2028}" } else { "\n" }, styles);
+ } else if let Some(elem) = child.to_packed::<SmartQuoteElem>() {
+ let double = elem.double(styles);
+ if elem.enabled(styles) {
+ let quotes = SmartQuotes::get(
+ elem.quotes(styles),
+ TextElem::lang_in(styles),
+ TextElem::region_in(styles),
+ elem.alternative(styles),
+ );
+ let before =
+ collector.full.chars().rev().find(|&c| !is_default_ignorable(c));
+ let quote = quoter.quote(before, &quotes, double);
+ collector.push_text(quote, styles);
+ } else {
+ collector.push_text(if double { "\"" } else { "'" }, styles);
+ }
+ } else if let Some(elem) = child.to_packed::<InlineElem>() {
+ collector.push_item(Item::Skip(LTR_ISOLATE));
+
+ for item in elem.layout(engine, locator.next(&elem.span()), styles, region)? {
+ match item {
+ InlineItem::Space(space, weak) => {
+ collector.push_item(Item::Absolute(space, weak));
+ }
+ InlineItem::Frame(frame) => {
+ collector.push_item(Item::Frame(frame, styles));
+ }
+ }
+ }
+
+ collector.push_item(Item::Skip(POP_ISOLATE));
+ } else if let Some(elem) = child.to_packed::<BoxElem>() {
+ let loc = locator.next(&elem.span());
+ if let Sizing::Fr(v) = elem.width(styles) {
+ collector.push_item(Item::Fractional(v, Some((elem, loc, styles))));
+ } else {
+ let frame = layout_box(elem, engine, loc, styles, region)?;
+ collector.push_item(Item::Frame(frame, styles));
+ }
+ } else if let Some(elem) = child.to_packed::<TagElem>() {
+ collector.push_item(Item::Tag(&elem.tag));
+ } else {
+ bail!(child.span(), "unexpected paragraph child");
+ };
+
+ let len = collector.full.len() - prev_len;
+ collector.spans.push(len, child.span());
+ }
+
+ Ok((collector.full, collector.segments, collector.spans))
+}
+
+/// Collects segments.
+struct Collector<'a> {
+ full: String,
+ segments: Vec<Segment<'a>>,
+ spans: SpanMapper,
+}
+
+impl<'a> Collector<'a> {
+ fn new(capacity: usize) -> Self {
+ Self {
+ full: String::new(),
+ segments: Vec::with_capacity(capacity),
+ spans: SpanMapper::new(),
+ }
+ }
+
+ fn push_text(&mut self, text: &str, styles: StyleChain<'a>) {
+ self.full.push_str(text);
+ self.push_segment(Segment::Text(text.len(), styles));
+ }
+
+ fn build_text<F>(&mut self, styles: StyleChain<'a>, f: F)
+ where
+ F: FnOnce(&mut String),
+ {
+ let prev = self.full.len();
+ f(&mut self.full);
+ let len = self.full.len() - prev;
+ self.push_segment(Segment::Text(len, styles));
+ }
+
+ fn push_item(&mut self, item: Item<'a>) {
+ self.full.push_str(item.textual());
+ self.push_segment(Segment::Item(item));
+ }
+
+ fn push_segment(&mut self, segment: Segment<'a>) {
+ match (self.segments.last_mut(), &segment) {
+ // Merge adjacent text segments with the same styles.
+ (Some(Segment::Text(last_len, last_styles)), Segment::Text(len, styles))
+ if *last_styles == *styles =>
+ {
+ *last_len += *len;
+ }
+
+ // Merge adjacent weak spacing by taking the maximum.
+ (
+ Some(Segment::Item(Item::Absolute(prev_amount, true))),
+ Segment::Item(Item::Absolute(amount, true)),
+ ) => {
+ *prev_amount = (*prev_amount).max(*amount);
+ }
+
+ _ => self.segments.push(segment),
+ }
+ }
+}
+
+/// 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)
+ }
+}
diff --git a/crates/typst-layout/src/inline/deco.rs b/crates/typst-layout/src/inline/deco.rs
new file mode 100644
index 00000000..c01b369b
--- /dev/null
+++ b/crates/typst-layout/src/inline/deco.rs
@@ -0,0 +1,213 @@
+use kurbo::{BezPath, Line, ParamCurve};
+use ttf_parser::{GlyphId, OutlineBuilder};
+use typst_library::layout::{Abs, Em, Frame, FrameItem, Point, Size};
+use typst_library::text::{
+ BottomEdge, DecoLine, Decoration, TextEdgeBounds, TextItem, TopEdge,
+};
+use typst_library::visualize::{FixedStroke, Geometry};
+use typst_syntax::Span;
+
+use crate::shapes::styled_rect;
+
+/// Add line decorations to a single run of shaped text.
+pub fn decorate(
+ frame: &mut Frame,
+ deco: &Decoration,
+ text: &TextItem,
+ width: Abs,
+ shift: Abs,
+ pos: Point,
+) {
+ let font_metrics = text.font.metrics();
+
+ if let DecoLine::Highlight { fill, stroke, top_edge, bottom_edge, radius } =
+ &deco.line
+ {
+ let (top, bottom) = determine_edges(text, *top_edge, *bottom_edge);
+ let size = Size::new(width + 2.0 * deco.extent, top + bottom);
+ let rects = styled_rect(size, radius, fill.clone(), stroke);
+ let origin = Point::new(pos.x - deco.extent, pos.y - top - shift);
+ frame.prepend_multiple(
+ rects
+ .into_iter()
+ .map(|shape| (origin, FrameItem::Shape(shape, Span::detached()))),
+ );
+ return;
+ }
+
+ let (stroke, metrics, offset, evade, background) = match &deco.line {
+ DecoLine::Strikethrough { stroke, offset, background } => {
+ (stroke, font_metrics.strikethrough, offset, false, *background)
+ }
+ DecoLine::Overline { stroke, offset, evade, background } => {
+ (stroke, font_metrics.overline, offset, *evade, *background)
+ }
+ DecoLine::Underline { stroke, offset, evade, background } => {
+ (stroke, font_metrics.underline, offset, *evade, *background)
+ }
+ _ => return,
+ };
+
+ let offset = offset.unwrap_or(-metrics.position.at(text.size)) - shift;
+ let stroke = stroke.clone().unwrap_or(FixedStroke::from_pair(
+ text.fill.as_decoration(),
+ metrics.thickness.at(text.size),
+ ));
+
+ let gap_padding = 0.08 * text.size;
+ let min_width = 0.162 * text.size;
+
+ let start = pos.x - deco.extent;
+ let end = pos.x + width + deco.extent;
+
+ let mut push_segment = |from: Abs, to: Abs, prepend: bool| {
+ let origin = Point::new(from, pos.y + offset);
+ let target = Point::new(to - from, Abs::zero());
+
+ if target.x >= min_width || !evade {
+ let shape = Geometry::Line(target).stroked(stroke.clone());
+
+ if prepend {
+ frame.prepend(origin, FrameItem::Shape(shape, Span::detached()));
+ } else {
+ frame.push(origin, FrameItem::Shape(shape, Span::detached()));
+ }
+ }
+ };
+
+ if !evade {
+ push_segment(start, end, background);
+ return;
+ }
+
+ let line = Line::new(
+ kurbo::Point::new(pos.x.to_raw(), offset.to_raw()),
+ kurbo::Point::new((pos.x + width).to_raw(), offset.to_raw()),
+ );
+
+ let mut x = pos.x;
+ let mut intersections = vec![];
+
+ for glyph in text.glyphs.iter() {
+ let dx = glyph.x_offset.at(text.size) + x;
+ let mut builder =
+ BezPathBuilder::new(font_metrics.units_per_em, text.size, dx.to_raw());
+
+ let bbox = text.font.ttf().outline_glyph(GlyphId(glyph.id), &mut builder);
+ let path = builder.finish();
+
+ x += glyph.x_advance.at(text.size);
+
+ // Only do the costly segments intersection test if the line
+ // intersects the bounding box.
+ let intersect = bbox.is_some_and(|bbox| {
+ let y_min = -text.font.to_em(bbox.y_max).at(text.size);
+ let y_max = -text.font.to_em(bbox.y_min).at(text.size);
+ offset >= y_min && offset <= y_max
+ });
+
+ if intersect {
+ // Find all intersections of segments with the line.
+ intersections.extend(
+ path.segments()
+ .flat_map(|seg| seg.intersect_line(line))
+ .map(|is| Abs::raw(line.eval(is.line_t).x)),
+ );
+ }
+ }
+
+ // Add start and end points, taking padding into account.
+ intersections.push(start - gap_padding);
+ intersections.push(end + gap_padding);
+ // When emitting the decorative line segments, we move from left to
+ // right. The intersections are not necessarily in this order, yet.
+ intersections.sort();
+
+ for edge in intersections.windows(2) {
+ let l = edge[0];
+ let r = edge[1];
+
+ // If we are too close, don't draw the segment
+ if r - l < gap_padding {
+ continue;
+ } else {
+ push_segment(l + gap_padding, r - gap_padding, background);
+ }
+ }
+}
+
+// Return the top/bottom edge of the text given the metric of the font.
+fn determine_edges(
+ text: &TextItem,
+ top_edge: TopEdge,
+ bottom_edge: BottomEdge,
+) -> (Abs, Abs) {
+ let mut top = Abs::zero();
+ let mut bottom = Abs::zero();
+
+ for g in text.glyphs.iter() {
+ let (t, b) = text.font.edges(
+ top_edge,
+ bottom_edge,
+ text.size,
+ TextEdgeBounds::Glyph(g.id),
+ );
+ top.set_max(t);
+ bottom.set_max(b);
+ }
+
+ (top, bottom)
+}
+
+/// Builds a kurbo [`BezPath`] for a glyph.
+struct BezPathBuilder {
+ path: BezPath,
+ units_per_em: f64,
+ font_size: Abs,
+ x_offset: f64,
+}
+
+impl BezPathBuilder {
+ fn new(units_per_em: f64, font_size: Abs, x_offset: f64) -> Self {
+ Self {
+ path: BezPath::new(),
+ units_per_em,
+ font_size,
+ x_offset,
+ }
+ }
+
+ fn finish(self) -> BezPath {
+ self.path
+ }
+
+ fn p(&self, x: f32, y: f32) -> kurbo::Point {
+ kurbo::Point::new(self.s(x) + self.x_offset, -self.s(y))
+ }
+
+ fn s(&self, v: f32) -> f64 {
+ Em::from_units(v, self.units_per_em).at(self.font_size).to_raw()
+ }
+}
+
+impl OutlineBuilder for BezPathBuilder {
+ fn move_to(&mut self, x: f32, y: f32) {
+ self.path.move_to(self.p(x, y));
+ }
+
+ fn line_to(&mut self, x: f32, y: f32) {
+ self.path.line_to(self.p(x, y));
+ }
+
+ fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
+ self.path.quad_to(self.p(x1, y1), self.p(x, y));
+ }
+
+ fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
+ self.path.curve_to(self.p(x1, y1), self.p(x2, y2), self.p(x, y));
+ }
+
+ fn close(&mut self) {
+ self.path.close_path();
+ }
+}
diff --git a/crates/typst-layout/src/inline/finalize.rs b/crates/typst-layout/src/inline/finalize.rs
new file mode 100644
index 00000000..599ace9d
--- /dev/null
+++ b/crates/typst-layout/src/inline/finalize.rs
@@ -0,0 +1,36 @@
+use typst_library::introspection::SplitLocator;
+use typst_utils::Numeric;
+
+use super::*;
+
+/// Turns the selected lines into frames.
+#[typst_macros::time]
+pub fn finalize(
+ engine: &mut Engine,
+ p: &Preparation,
+ lines: &[Line],
+ styles: StyleChain,
+ region: Size,
+ expand: bool,
+ locator: &mut SplitLocator<'_>,
+) -> 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()))
+ {
+ region
+ .x
+ .min(p.hang + lines.iter().map(|line| line.width).max().unwrap_or_default())
+ } else {
+ region.x
+ };
+
+ // Stack the lines into one frame per region.
+ let shrink = ParElem::shrink_in(styles);
+ lines
+ .iter()
+ .map(|line| commit(engine, p, line, width, region.y, shrink, locator, styles))
+ .collect::<SourceResult<_>>()
+ .map(Fragment::frames)
+}
diff --git a/crates/typst-layout/src/inline/line.rs b/crates/typst-layout/src/inline/line.rs
new file mode 100644
index 00000000..596e109e
--- /dev/null
+++ b/crates/typst-layout/src/inline/line.rs
@@ -0,0 +1,750 @@
+use std::fmt::{self, Debug, Formatter};
+use std::ops::{Deref, DerefMut};
+
+use typst_library::engine::Engine;
+use typst_library::foundations::NativeElement;
+use typst_library::introspection::{SplitLocator, Tag};
+use typst_library::layout::{Abs, Dir, Em, Fr, Frame, FrameItem, Point};
+use typst_library::model::{ParLine, ParLineMarker};
+use typst_library::text::{Lang, TextElem};
+use typst_utils::Numeric;
+
+use super::*;
+
+const SHY: char = '\u{ad}';
+const HYPHEN: char = '-';
+const EN_DASH: char = '–';
+const EM_DASH: char = '—';
+const LINE_SEPARATOR: char = '\u{2028}'; // We use LS to distinguish justified breaks.
+
+/// 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.
+pub struct Line<'a> {
+ /// The items the line is made of.
+ pub items: Items<'a>,
+ /// The exact natural width of the line.
+ pub width: Abs,
+ /// Whether the line should be justified.
+ pub justify: bool,
+ /// Whether the line ends with a hyphen or dash, either naturally or through
+ /// hyphenation.
+ pub dash: Option<Dash>,
+}
+
+impl<'a> Line<'a> {
+ /// Create an empty line.
+ pub fn empty() -> Self {
+ Self {
+ items: Items::new(),
+ width: Abs::zero(),
+ justify: false,
+ dash: None,
+ }
+ }
+
+ /// How many glyphs are in the text where we can insert additional
+ /// space when encountering underfull lines.
+ pub fn justifiables(&self) -> usize {
+ let mut count = 0;
+ for shaped in self.items.iter().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 the line can stretch.
+ pub fn stretchability(&self) -> Abs {
+ self.items
+ .iter()
+ .filter_map(Item::text)
+ .map(|s| s.stretchability())
+ .sum()
+ }
+
+ /// How much the line can shrink.
+ pub fn shrinkability(&self) -> Abs {
+ self.items
+ .iter()
+ .filter_map(Item::text)
+ .map(|s| s.shrinkability())
+ .sum()
+ }
+
+ /// Whether the line has items with negative width.
+ pub fn has_negative_width_items(&self) -> bool {
+ self.items.iter().any(|item| match item {
+ Item::Absolute(amount, _) => *amount < Abs::zero(),
+ Item::Frame(frame, _) => frame.width() < Abs::zero(),
+ _ => false,
+ })
+ }
+
+ /// The sum of fractions in the line.
+ pub fn fr(&self) -> Fr {
+ self.items
+ .iter()
+ .filter_map(|item| match item {
+ Item::Fractional(fr, _) => Some(*fr),
+ _ => None,
+ })
+ .sum()
+ }
+}
+
+/// A dash at the end of a line.
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+pub enum Dash {
+ /// A soft hyphen added to break a word.
+ Soft,
+ /// A regular hyphen, present in a compound word, e.g. beija-flor.
+ Hard,
+ /// Another kind of dash. Only relevant for cost computation.
+ Other,
+}
+
+/// Create a line which spans the given range.
+pub fn line<'a>(
+ engine: &Engine,
+ p: &'a Preparation,
+ range: Range,
+ breakpoint: Breakpoint,
+ pred: Option<&Line>,
+) -> Line<'a> {
+ // The line's full text.
+ let full = &p.text[range.clone()];
+
+ // Whether the line is justified.
+ let justify = full.ends_with(LINE_SEPARATOR)
+ || (p.justify && breakpoint != Breakpoint::Mandatory);
+
+ // Process dashes.
+ let dash = if breakpoint.is_hyphen() || full.ends_with(SHY) {
+ Some(Dash::Soft)
+ } else if full.ends_with(HYPHEN) {
+ Some(Dash::Hard)
+ } else if full.ends_with([EN_DASH, EM_DASH]) {
+ Some(Dash::Other)
+ } else {
+ None
+ };
+
+ // Trim the line at the end, if necessary for this breakpoint.
+ let trim = range.start + breakpoint.trim(full).len();
+
+ // Collect the items for the line.
+ let mut items = collect_items(engine, p, range, trim);
+
+ // Add a hyphen at the line start, if a previous dash should be repeated.
+ if pred.map_or(false, |pred| should_repeat_hyphen(pred, full)) {
+ if let Some(shaped) = items.first_text_mut() {
+ shaped.prepend_hyphen(engine, p.fallback);
+ }
+ }
+
+ // Add a hyphen at the line end, if we ended on a soft hyphen.
+ if dash == Some(Dash::Soft) {
+ if let Some(shaped) = items.last_text_mut() {
+ shaped.push_hyphen(engine, p.fallback);
+ }
+ }
+
+ // Deal with CJ characters at line boundaries.
+ adjust_cj_at_line_boundaries(p, full, &mut items);
+
+ // Compute the line's width.
+ let width = items.iter().map(Item::natural_width).sum();
+
+ Line { items, width, justify, dash }
+}
+
+/// Collects / reshapes all items for the line with the given `range`.
+///
+/// The `trim` defines an end position to which text items are trimmed. For
+/// example, the `range` may span "hello\n", but the `trim` specifies that the
+/// linebreak is trimmed.
+///
+/// We do not factor the `trim` directly into the `range` because we still want
+/// to keep non-text items after the trim (e.g. tags).
+fn collect_items<'a>(
+ engine: &Engine,
+ p: &'a Preparation,
+ range: Range,
+ trim: usize,
+) -> Items<'a> {
+ let mut items = Items::new();
+ let mut fallback = None;
+
+ // Collect the items for each consecutively ordered run.
+ reorder(p, range.clone(), |subrange, rtl| {
+ let from = items.len();
+ collect_range(engine, p, subrange, trim, &mut items, &mut fallback);
+ if rtl {
+ items.reorder(from);
+ }
+ });
+
+ // Trim weak spacing at the start of the line.
+ let prefix = items
+ .iter()
+ .take_while(|item| matches!(item, Item::Absolute(_, true)))
+ .count();
+ if prefix > 0 {
+ items.drain(..prefix);
+ }
+
+ // Trim weak spacing at the end of the line.
+ while matches!(items.last(), Some(Item::Absolute(_, true))) {
+ items.pop();
+ }
+
+ // Add fallback text to expand the line height, if necessary.
+ if !items.iter().any(|item| matches!(item, Item::Text(_))) {
+ if let Some(fallback) = fallback {
+ items.push(fallback);
+ }
+ }
+
+ items
+}
+
+/// Calls `f` for the BiDi-reordered ranges of a line.
+fn reorder<F>(p: &Preparation, range: Range, mut f: F)
+where
+ F: FnMut(Range, bool),
+{
+ // If there is nothing bidirectional going on, skip reordering.
+ let Some(bidi) = &p.bidi else {
+ f(range, p.dir == Dir::RTL);
+ return;
+ };
+
+ // The bidi crate panics for empty lines.
+ if range.is_empty() {
+ f(range, p.dir == Dir::RTL);
+ return;
+ }
+
+ // Find the paragraph that contains the line.
+ let para = bidi
+ .paragraphs
+ .iter()
+ .find(|para| para.range.contains(&range.start))
+ .unwrap();
+
+ // Compute the reordered ranges in visual order (left to right).
+ let (levels, runs) = bidi.visual_runs(para, range.clone());
+
+ // Call `f` for each run.
+ for run in runs {
+ let rtl = levels[run.start].is_rtl();
+ f(run, rtl)
+ }
+}
+
+/// Collects / reshapes all items for the given `subrange` with continuous
+/// direction.
+fn collect_range<'a>(
+ engine: &Engine,
+ p: &'a Preparation,
+ range: Range,
+ trim: usize,
+ items: &mut Items<'a>,
+ fallback: &mut Option<ItemEntry<'a>>,
+) {
+ for (subrange, item) in p.slice(range.clone()) {
+ // All non-text items are just kept, they can't be split.
+ let Item::Text(shaped) = item else {
+ items.push(item);
+ continue;
+ };
+
+ // The intersection range of the item, the subrange, and the line's
+ // trimming.
+ let sliced =
+ range.start.max(subrange.start)..range.end.min(subrange.end).min(trim);
+
+ // Whether the item is split by the line.
+ let split = subrange.start < sliced.start || sliced.end < subrange.end;
+
+ if sliced.is_empty() {
+ // When there is no text, still keep this as a fallback item, which
+ // we can use to force a non-zero line-height when the line doesn't
+ // contain any other text.
+ *fallback = Some(ItemEntry::from(Item::Text(shaped.empty())));
+ } else if split {
+ // When the item is split in half, reshape it.
+ let reshaped = shaped.reshape(engine, sliced);
+ items.push(Item::Text(reshaped));
+ } else {
+ // When the item is fully contained, just keep it.
+ items.push(item);
+ }
+ }
+}
+
+/// Add spacing around punctuation marks for CJ glyphs at line boundaries.
+///
+/// See Requirements for Chinese Text Layout, Section 3.1.6.3 Compression of
+/// punctuation marks at line start or line end.
+fn adjust_cj_at_line_boundaries(p: &Preparation, text: &str, items: &mut Items) {
+ if text.starts_with(BEGIN_PUNCT_PAT)
+ || (p.cjk_latin_spacing && text.starts_with(is_of_cj_script))
+ {
+ adjust_cj_at_line_start(p, items);
+ }
+
+ if text.ends_with(END_PUNCT_PAT)
+ || (p.cjk_latin_spacing && text.ends_with(is_of_cj_script))
+ {
+ adjust_cj_at_line_end(p, items);
+ }
+}
+
+/// Add spacing around punctuation marks for CJ glyphs at the line start.
+fn adjust_cj_at_line_start(p: &Preparation, items: &mut Items) {
+ let Some(shaped) = items.first_text_mut() else { return };
+ let Some(glyph) = shaped.glyphs.first() else { return };
+
+ if glyph.is_cjk_right_aligned_punctuation() {
+ // If the first glyph is a CJK punctuation, we want to
+ // shrink it.
+ let glyph = shaped.glyphs.to_mut().first_mut().unwrap();
+ let shrink = glyph.shrinkability().0;
+ glyph.shrink_left(shrink);
+ shaped.width -= shrink.at(shaped.size);
+ } else if p.cjk_latin_spacing && glyph.is_cj_script() && glyph.x_offset > Em::zero() {
+ // If the first glyph is a CJK character adjusted by
+ // [`add_cjk_latin_spacing`], restore the original width.
+ let glyph = shaped.glyphs.to_mut().first_mut().unwrap();
+ let shrink = glyph.x_offset;
+ glyph.x_advance -= shrink;
+ glyph.x_offset = Em::zero();
+ glyph.adjustability.shrinkability.0 = Em::zero();
+ shaped.width -= shrink.at(shaped.size);
+ }
+}
+
+/// Add spacing around punctuation marks for CJ glyphs at the line end.
+fn adjust_cj_at_line_end(p: &Preparation, items: &mut Items) {
+ let Some(shaped) = items.last_text_mut() else { return };
+ let Some(glyph) = shaped.glyphs.last() else { return };
+
+ // Deal with CJK punctuation at line ends.
+ let style = cjk_punct_style(shaped.lang, shaped.region);
+
+ if glyph.is_cjk_left_aligned_punctuation(style) {
+ // If the last glyph is a CJK punctuation, we want to
+ // shrink it.
+ let shrink = glyph.shrinkability().1;
+ let punct = shaped.glyphs.to_mut().last_mut().unwrap();
+ punct.shrink_right(shrink);
+ shaped.width -= shrink.at(shaped.size);
+ } else if p.cjk_latin_spacing
+ && glyph.is_cj_script()
+ && (glyph.x_advance - glyph.x_offset) > Em::one()
+ {
+ // If the last glyph is a CJK character adjusted by
+ // [`add_cjk_latin_spacing`], restore the original width.
+ let shrink = glyph.x_advance - glyph.x_offset - Em::one();
+ let glyph = shaped.glyphs.to_mut().last_mut().unwrap();
+ glyph.x_advance -= shrink;
+ glyph.adjustability.shrinkability.1 = Em::zero();
+ shaped.width -= shrink.at(shaped.size);
+ }
+}
+
+/// Whether a hyphen should be inserted at the start of the next line.
+fn should_repeat_hyphen(pred_line: &Line, text: &str) -> bool {
+ // If the predecessor line does not end with a `Dash::Hard`, we shall
+ // not place a hyphen at the start of the next line.
+ if pred_line.dash != Some(Dash::Hard) {
+ return false;
+ }
+
+ // The hyphen should repeat only in the languages that require that feature.
+ // For more information see the discussion at https://github.com/typst/typst/issues/3235
+ let Some(Item::Text(shaped)) = pred_line.items.last() else { return false };
+
+ match shaped.lang {
+ // - Lower Sorbian: see https://dolnoserbski.de/ortografija/psawidla/K3
+ // - Czech: see https://prirucka.ujc.cas.cz/?id=164
+ // - Croatian: see http://pravopis.hr/pravilo/spojnica/68/
+ // - Polish: see https://www.ortograf.pl/zasady-pisowni/lacznik-zasady-pisowni
+ // - Portuguese: see https://www2.senado.leg.br/bdsf/bitstream/handle/id/508145/000997415.pdf (Base XX)
+ // - Slovak: see https://www.zones.sk/studentske-prace/gramatika/10620-pravopis-rozdelovanie-slov/
+ Lang::LOWER_SORBIAN
+ | Lang::CZECH
+ | Lang::CROATIAN
+ | Lang::POLISH
+ | Lang::PORTUGUESE
+ | Lang::SLOVAK => true,
+
+ // In Spanish the hyphen is required only if the word next to hyphen is
+ // not capitalized. Otherwise, the hyphen must not be repeated.
+ //
+ // See § 4.1.1.1.2.e on the "Ortografía de la lengua española"
+ // https://www.rae.es/ortografía/como-signo-de-división-de-palabras-a-final-de-línea
+ Lang::SPANISH => text.chars().next().map_or(false, |c| !c.is_uppercase()),
+
+ _ => false,
+ }
+}
+
+/// Commit to a line and build its frame.
+#[allow(clippy::too_many_arguments)]
+pub fn commit(
+ engine: &mut Engine,
+ p: &Preparation,
+ line: &Line,
+ width: Abs,
+ full: Abs,
+ shrink: bool,
+ locator: &mut SplitLocator<'_>,
+ styles: StyleChain,
+) -> SourceResult<Frame> {
+ let mut remaining = width - line.width - p.hang;
+ let mut offset = Abs::zero();
+
+ // We always build the line from left to right. In an LTR paragraph, we must
+ // thus add the hanging indent to the offset. When the paragraph is RTL, the
+ // hanging indent arises naturally due to the line width.
+ if p.dir == Dir::LTR {
+ offset += p.hang;
+ }
+
+ // Handle hanging punctuation to the left.
+ if let Some(Item::Text(text)) = line.items.first() {
+ if let Some(glyph) = text.glyphs.first() {
+ if !text.dir.is_positive()
+ && TextElem::overhang_in(text.styles)
+ && (line.items.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)) = line.items.last() {
+ if let Some(glyph) = text.glyphs.last() {
+ if text.dir.is_positive()
+ && TextElem::overhang_in(text.styles)
+ && (line.items.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 justification_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 shrinkability = line.shrinkability();
+ let stretchability = line.stretchability();
+ if remaining < Abs::zero() && shrinkability > Abs::zero() && shrink {
+ // Attempt to reduce the length of the line, using shrinkability.
+ justification_ratio = (remaining / shrinkability).max(-1.0);
+ remaining = (remaining + shrinkability).min(Abs::zero());
+ } else if line.justify && fr.is_zero() {
+ // Attempt to increase the length of the line, using stretchability.
+ if stretchability > Abs::zero() {
+ justification_ratio = (remaining / stretchability).min(1.0);
+ remaining = (remaining - stretchability).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 line.items.iter() {
+ 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, loc, styles)) = elem {
+ let region = Size::new(amount, full);
+ let mut frame =
+ layout_box(elem, engine, loc.relayout(), *styles, region)?;
+ frame.translate(Point::with_y(TextElem::baseline_in(*styles)));
+ push(&mut offset, frame.post_processed(*styles));
+ } else {
+ offset += amount;
+ }
+ }
+ Item::Text(shaped) => {
+ let frame = shaped.build(
+ engine,
+ &p.spans,
+ justification_ratio,
+ extra_justification,
+ );
+ push(&mut offset, frame.post_processed(shaped.styles));
+ }
+ Item::Frame(frame, styles) => {
+ let mut frame = frame.clone();
+ frame.translate(Point::with_y(TextElem::baseline_in(*styles)));
+ push(&mut offset, frame.post_processed(*styles));
+ }
+ Item::Tag(tag) => {
+ let mut frame = Frame::soft(Size::zero());
+ frame.push(Point::zero(), FrameItem::Tag((*tag).clone()));
+ frames.push((offset, frame));
+ }
+ Item::Skip(_) => {}
+ }
+ }
+
+ // Remaining space is distributed now.
+ if !fr.is_zero() {
+ remaining = Abs::zero();
+ }
+
+ let size = Size::new(width, top + bottom);
+ let mut output = Frame::soft(size);
+ output.set_baseline(top);
+
+ add_par_line_marker(&mut output, styles, engine, locator, 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)
+}
+
+/// Adds a paragraph line marker to a paragraph line's output frame if
+/// line numbering is not `None` at this point. Ensures other style properties,
+/// namely number margin, number align and number clearance, are stored in the
+/// marker as well.
+///
+/// The `top` parameter is used to ensure the marker, and thus the line's
+/// number in the margin, is aligned to the line's baseline.
+fn add_par_line_marker(
+ output: &mut Frame,
+ styles: StyleChain,
+ engine: &mut Engine,
+ locator: &mut SplitLocator,
+ top: Abs,
+) {
+ let Some(numbering) = ParLine::numbering_in(styles) else { return };
+ let margin = ParLine::number_margin_in(styles);
+ let align = ParLine::number_align_in(styles);
+
+ // Delay resolving the number clearance until line numbers are laid out to
+ // avoid inconsistent spacing depending on varying font size.
+ let clearance = ParLine::number_clearance_in(styles);
+
+ // Elements in tags must have a location for introspection to work. We do
+ // the work here instead of going through all of the realization process
+ // just for this, given we don't need to actually place the marker as we
+ // manually search for it in the frame later (when building a root flow,
+ // where line numbers can be displayed), so we just need it to be in a tag
+ // and to be valid (to have a location).
+ let mut marker = ParLineMarker::new(numbering, align, margin, clearance).pack();
+ let key = typst_utils::hash128(&marker);
+ let loc = locator.next_location(engine.introspector, key);
+ marker.set_location(loc);
+
+ // Create start and end tags through which we can search for this line's
+ // marker later. The 'x' coordinate is not important, just the 'y'
+ // coordinate, as that's what is used for line numbers. We will place the
+ // tags among other subframes in the line such that it is aligned with the
+ // line's general baseline. However, the line number will still need to
+ // manually adjust its own 'y' position based on its own baseline.
+ let pos = Point::with_y(top);
+ output.push(pos, FrameItem::Tag(Tag::Start(marker)));
+ output.push(pos, FrameItem::Tag(Tag::End(loc, key)));
+}
+
+/// 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,
+ }
+}
+
+/// A collection of owned or borrowed paragraph items.
+pub struct Items<'a>(Vec<ItemEntry<'a>>);
+
+impl<'a> Items<'a> {
+ /// Create empty items.
+ pub fn new() -> Self {
+ Self(vec![])
+ }
+
+ /// Push a new item.
+ pub fn push(&mut self, entry: impl Into<ItemEntry<'a>>) {
+ self.0.push(entry.into());
+ }
+
+ /// Iterate over the items
+ pub fn iter(&self) -> impl Iterator<Item = &Item<'a>> {
+ self.0.iter().map(|item| &**item)
+ }
+
+ /// Access the first item.
+ pub fn first(&self) -> Option<&Item<'a>> {
+ self.0.first().map(|item| &**item)
+ }
+
+ /// Access the last item.
+ pub fn last(&self) -> Option<&Item<'a>> {
+ self.0.last().map(|item| &**item)
+ }
+
+ /// Access the first item mutably, if it is text.
+ pub fn first_text_mut(&mut self) -> Option<&mut ShapedText<'a>> {
+ self.0.first_mut()?.text_mut()
+ }
+
+ /// Access the last item mutably, if it is text.
+ pub fn last_text_mut(&mut self) -> Option<&mut ShapedText<'a>> {
+ self.0.last_mut()?.text_mut()
+ }
+
+ /// Reorder the items starting at the given index to RTL.
+ pub fn reorder(&mut self, from: usize) {
+ self.0[from..].reverse()
+ }
+}
+
+impl<'a> FromIterator<ItemEntry<'a>> for Items<'a> {
+ fn from_iter<I: IntoIterator<Item = ItemEntry<'a>>>(iter: I) -> Self {
+ Self(iter.into_iter().collect())
+ }
+}
+
+impl<'a> Deref for Items<'a> {
+ type Target = Vec<ItemEntry<'a>>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl<'a> DerefMut for Items<'a> {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.0
+ }
+}
+
+impl Debug for Items<'_> {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ f.debug_list().entries(&self.0).finish()
+ }
+}
+
+/// A reference to or a boxed item.
+pub enum ItemEntry<'a> {
+ Ref(&'a Item<'a>),
+ Box(Box<Item<'a>>),
+}
+
+impl<'a> ItemEntry<'a> {
+ fn text_mut(&mut self) -> Option<&mut ShapedText<'a>> {
+ match self {
+ Self::Ref(item) => {
+ let text = item.text()?;
+ *self = Self::Box(Box::new(Item::Text(text.clone())));
+ match self {
+ Self::Box(item) => item.text_mut(),
+ _ => unreachable!(),
+ }
+ }
+ Self::Box(item) => item.text_mut(),
+ }
+ }
+}
+
+impl<'a> Deref for ItemEntry<'a> {
+ type Target = Item<'a>;
+
+ fn deref(&self) -> &Self::Target {
+ match self {
+ Self::Ref(item) => item,
+ Self::Box(item) => item,
+ }
+ }
+}
+
+impl Debug for ItemEntry<'_> {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ (**self).fmt(f)
+ }
+}
+
+impl<'a> From<&'a Item<'a>> for ItemEntry<'a> {
+ fn from(item: &'a Item<'a>) -> Self {
+ Self::Ref(item)
+ }
+}
+
+impl<'a> From<Item<'a>> for ItemEntry<'a> {
+ fn from(item: Item<'a>) -> Self {
+ Self::Box(Box::new(item))
+ }
+}
diff --git a/crates/typst-layout/src/inline/linebreak.rs b/crates/typst-layout/src/inline/linebreak.rs
new file mode 100644
index 00000000..7fc8b368
--- /dev/null
+++ b/crates/typst-layout/src/inline/linebreak.rs
@@ -0,0 +1,980 @@
+use std::ops::{Add, Sub};
+
+use az::SaturatingAs;
+use icu_properties::maps::{CodePointMapData, CodePointMapDataBorrowed};
+use icu_properties::LineBreak;
+use icu_provider::AsDeserializingBufferProvider;
+use icu_provider_adapters::fork::ForkByKeyProvider;
+use icu_provider_blob::BlobDataProvider;
+use icu_segmenter::LineSegmenter;
+use once_cell::sync::Lazy;
+use typst_library::engine::Engine;
+use typst_library::layout::{Abs, Em};
+use typst_library::model::Linebreaks;
+use typst_library::text::{is_default_ignorable, Lang, TextElem};
+use typst_syntax::link_prefix;
+use unicode_segmentation::UnicodeSegmentation;
+
+use super::*;
+
+/// The cost of a line or paragraph layout.
+type Cost = f64;
+
+// Cost parameters.
+//
+// We choose higher costs than the Knuth-Plass paper (which would be 50) because
+// it hyphenates way to eagerly in Typst otherwise. Could be related to the
+// ratios coming out differently since Typst doesn't have the concept of glue,
+// so things work a bit differently.
+const DEFAULT_HYPH_COST: Cost = 135.0;
+const DEFAULT_RUNT_COST: Cost = 100.0;
+
+// Other parameters.
+const MIN_RATIO: f64 = -1.0;
+const MIN_APPROX_RATIO: f64 = -0.5;
+const BOUND_EPS: f64 = 1e-3;
+
+/// The ICU blob data.
+fn blob() -> BlobDataProvider {
+ BlobDataProvider::try_new_from_static_blob(typst_assets::icu::ICU).unwrap()
+}
+
+/// The general line break segmenter.
+static SEGMENTER: Lazy<LineSegmenter> =
+ Lazy::new(|| LineSegmenter::try_new_lstm_with_buffer_provider(&blob()).unwrap());
+
+/// The line break segmenter for Chinese/Japanese text.
+static CJ_SEGMENTER: Lazy<LineSegmenter> = Lazy::new(|| {
+ let cj_blob =
+ BlobDataProvider::try_new_from_static_blob(typst_assets::icu::ICU_CJ_SEGMENT)
+ .unwrap();
+ let cj_provider = ForkByKeyProvider::new(cj_blob, blob());
+ LineSegmenter::try_new_lstm_with_buffer_provider(&cj_provider).unwrap()
+});
+
+/// The Unicode line break properties for each code point.
+static LINEBREAK_DATA: Lazy<CodePointMapData<LineBreak>> = Lazy::new(|| {
+ icu_properties::maps::load_line_break(&blob().as_deserializing()).unwrap()
+});
+
+/// A line break opportunity.
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+pub enum Breakpoint {
+ /// Just a normal opportunity (e.g. after a space).
+ Normal,
+ /// A mandatory breakpoint (after '\n' or at the end of the text).
+ Mandatory,
+ /// An opportunity for hyphenating and how many chars are before/after it
+ /// in the word.
+ Hyphen(u8, u8),
+}
+
+impl Breakpoint {
+ /// Trim a line before this breakpoint.
+ pub fn trim(self, line: &str) -> &str {
+ // Trim default ignorables.
+ let line = line.trim_end_matches(is_default_ignorable);
+
+ match self {
+ // Trim whitespace.
+ Self::Normal => line.trim_end_matches(char::is_whitespace),
+
+ // Trim linebreaks.
+ Self::Mandatory => {
+ let lb = LINEBREAK_DATA.as_borrowed();
+ line.trim_end_matches(|c| {
+ matches!(
+ lb.get(c),
+ LineBreak::MandatoryBreak
+ | LineBreak::CarriageReturn
+ | LineBreak::LineFeed
+ | LineBreak::NextLine
+ )
+ })
+ }
+
+ // Trim nothing further.
+ Self::Hyphen(..) => line,
+ }
+ }
+
+ /// Whether this is a hyphen breakpoint.
+ pub fn is_hyphen(self) -> bool {
+ matches!(self, Self::Hyphen(..))
+ }
+}
+
+/// Breaks the paragraph into lines.
+pub fn linebreak<'a>(
+ engine: &Engine,
+ p: &'a Preparation<'a>,
+ width: Abs,
+) -> Vec<Line<'a>> {
+ let linebreaks = p.linebreaks.unwrap_or_else(|| {
+ if p.justify {
+ Linebreaks::Optimized
+ } else {
+ Linebreaks::Simple
+ }
+ });
+
+ match linebreaks {
+ Linebreaks::Simple => linebreak_simple(engine, p, width),
+ Linebreaks::Optimized => linebreak_optimized(engine, p, width),
+ }
+}
+
+/// Performs 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.
+#[typst_macros::time]
+fn linebreak_simple<'a>(
+ engine: &Engine,
+ p: &'a Preparation<'a>,
+ width: Abs,
+) -> Vec<Line<'a>> {
+ let mut lines = Vec::with_capacity(16);
+ let mut start = 0;
+ let mut last = None;
+
+ breakpoints(p, |end, breakpoint| {
+ // Compute the line and its size.
+ let mut attempt = line(engine, p, start..end, breakpoint, lines.last());
+
+ // 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(engine, p, start..end, breakpoint, lines.last());
+ }
+ }
+
+ // 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 breakpoint == Breakpoint::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
+}
+
+/// Performs 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.
+#[typst_macros::time]
+fn linebreak_optimized<'a>(
+ engine: &Engine,
+ p: &'a Preparation<'a>,
+ width: Abs,
+) -> Vec<Line<'a>> {
+ let metrics = CostMetrics::compute(p);
+
+ // Determines the exact costs of a likely good layout through Knuth-Plass
+ // with approximate metrics. We can use this cost as an upper bound to prune
+ // the search space in our proper optimization pass below.
+ let upper_bound = linebreak_optimized_approximate(engine, p, width, &metrics);
+
+ // Using the upper bound, perform exact optimized linebreaking.
+ linebreak_optimized_bounded(engine, p, width, &metrics, upper_bound)
+}
+
+/// Performs line breaking in optimized Knuth-Plass style, but with an upper
+/// bound on the cost. This allows us to skip many parts of the search space.
+#[typst_macros::time]
+fn linebreak_optimized_bounded<'a>(
+ engine: &Engine,
+ p: &'a Preparation<'a>,
+ width: Abs,
+ metrics: &CostMetrics,
+ upper_bound: Cost,
+) -> Vec<Line<'a>> {
+ /// An entry in the dynamic programming table for paragraph optimization.
+ struct Entry<'a> {
+ pred: usize,
+ total: Cost,
+ line: Line<'a>,
+ end: usize,
+ }
+
+ // Dynamic programming table.
+ let mut table = vec![Entry { pred: 0, total: 0.0, line: Line::empty(), end: 0 }];
+
+ let mut active = 0;
+ let mut prev_end = 0;
+
+ breakpoints(p, |end, breakpoint| {
+ // Find the optimal predecessor.
+ let mut best: Option<Entry> = None;
+
+ // A lower bound for the cost of all following line attempts.
+ let mut line_lower_bound = None;
+
+ for (pred_index, pred) in table.iter().enumerate().skip(active) {
+ let start = pred.end;
+ let unbreakable = prev_end == start;
+
+ // If the minimum cost we've established for the line is already
+ // too much, skip this attempt.
+ if line_lower_bound
+ .is_some_and(|lower| pred.total + lower > upper_bound + BOUND_EPS)
+ {
+ continue;
+ }
+
+ // Build the line.
+ let attempt = line(engine, p, start..end, breakpoint, Some(&pred.line));
+
+ // Determine the cost of the line and its stretch ratio.
+ let (line_ratio, line_cost) = ratio_and_cost(
+ p,
+ metrics,
+ width,
+ &pred.line,
+ &attempt,
+ breakpoint,
+ unbreakable,
+ );
+
+ // If the line is overfull, we adjust the set of active candidate
+ // line starts. 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
+ //
+ // If this is the earliest breakpoint in the active set
+ // (active == i), remove it from the active set. If there is an
+ // earlier one (active < i), then the logically shorter line was
+ // in fact longer (can happen with negative spacing) and we
+ // can't trim the active set just yet.
+ if line_ratio < metrics.min_ratio && active == pred_index {
+ active += 1;
+ }
+
+ // The total cost of this line and its chain of predecessors.
+ let total = pred.total + line_cost;
+
+ // If the line is already underfull (`line_ratio > 0`), any shorter
+ // slice of the line will be even more underfull. So it'll only get
+ // worse from here and further attempts would also have a cost
+ // exceeding `bound`. There is one exception: When the line has
+ // negative spacing, we can't know for sure, so we don't assign the
+ // lower bound in that case.
+ if line_ratio > 0.0
+ && line_lower_bound.is_none()
+ && !attempt.has_negative_width_items()
+ {
+ line_lower_bound = Some(line_cost);
+ }
+
+ // If the cost already exceeds the upper bound, we don't need to
+ // integrate this result into the table.
+ if total > upper_bound + BOUND_EPS {
+ continue;
+ }
+
+ // 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: pred_index, total, line: attempt, end });
+ }
+ }
+
+ // If this is a mandatory break, all breakpoints before this one become
+ // inactive since no line can span over the mandatory break.
+ if breakpoint == Breakpoint::Mandatory {
+ active = table.len();
+ }
+
+ table.extend(best);
+ prev_end = end;
+ });
+
+ // Retrace the best path.
+ let mut lines = Vec::with_capacity(16);
+ let mut idx = table.len() - 1;
+
+ // This should only happen if our bound was faulty. Which shouldn't happen!
+ if table[idx].end != p.text.len() {
+ #[cfg(debug_assertions)]
+ panic!("bounded paragraph layout is incomplete");
+
+ #[cfg(not(debug_assertions))]
+ return linebreak_optimized_bounded(engine, p, width, metrics, Cost::INFINITY);
+ }
+
+ while idx != 0 {
+ table.truncate(idx + 1);
+ let entry = table.pop().unwrap();
+ lines.push(entry.line);
+ idx = entry.pred;
+ }
+
+ lines.reverse();
+ lines
+}
+
+/// Runs the normal Knuth-Plass algorithm, but instead of building proper lines
+/// (which is costly) to determine costs, it determines approximate costs using
+/// cumulative arrays.
+///
+/// This results in a likely good paragraph layouts, for which we then compute
+/// the exact cost. This cost is an upper bound for proper optimized
+/// linebreaking. We can use it to heavily prune the search space.
+#[typst_macros::time]
+fn linebreak_optimized_approximate(
+ engine: &Engine,
+ p: &Preparation,
+ width: Abs,
+ metrics: &CostMetrics,
+) -> Cost {
+ // Determine the cumulative estimation metrics.
+ let estimates = Estimates::compute(p);
+
+ /// An entry in the dynamic programming table for paragraph optimization.
+ struct Entry {
+ pred: usize,
+ total: Cost,
+ end: usize,
+ unbreakable: bool,
+ breakpoint: Breakpoint,
+ }
+
+ // Dynamic programming table.
+ let mut table = vec![Entry {
+ pred: 0,
+ total: 0.0,
+ end: 0,
+ unbreakable: false,
+ breakpoint: Breakpoint::Mandatory,
+ }];
+
+ let mut active = 0;
+ let mut prev_end = 0;
+
+ breakpoints(p, |end, breakpoint| {
+ // Find the optimal predecessor.
+ let mut best: Option<Entry> = None;
+ for (pred_index, pred) in table.iter().enumerate().skip(active) {
+ let start = pred.end;
+ let unbreakable = prev_end == start;
+
+ // Whether the line is justified. This is not 100% accurate w.r.t
+ // to line()'s behaviour, but good enough.
+ let justify = p.justify && breakpoint != Breakpoint::Mandatory;
+
+ // We don't really know whether the line naturally ends with a dash
+ // here, so we can miss that case, but it's ok, since all of this
+ // just an estimate.
+ let consecutive_dash = pred.breakpoint.is_hyphen() && breakpoint.is_hyphen();
+
+ // Estimate how much the line's spaces would need to be stretched to
+ // make it the desired width. We trim at the end to not take into
+ // account trailing spaces. This is, again, only an approximation of
+ // the real behaviour of `line`.
+ let trimmed_end = start + p.text[start..end].trim_end().len();
+ let line_ratio = raw_ratio(
+ p,
+ width,
+ estimates.widths.estimate(start..trimmed_end)
+ + if breakpoint.is_hyphen() {
+ metrics.approx_hyphen_width
+ } else {
+ Abs::zero()
+ },
+ estimates.stretchability.estimate(start..trimmed_end),
+ estimates.shrinkability.estimate(start..trimmed_end),
+ estimates.justifiables.estimate(start..trimmed_end),
+ );
+
+ // Determine the line's cost.
+ let line_cost = raw_cost(
+ metrics,
+ breakpoint,
+ line_ratio,
+ justify,
+ unbreakable,
+ consecutive_dash,
+ true,
+ );
+
+ // Adjust the set of active breakpoints.
+ // See `linebreak_optimized` for details.
+ if line_ratio < metrics.min_ratio && active == pred_index {
+ active += 1;
+ }
+
+ // The total cost of this line and its chain of predecessors.
+ let total = pred.total + line_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: pred_index,
+ total,
+ end,
+ unbreakable,
+ breakpoint,
+ });
+ }
+ }
+
+ // If this is a mandatory break, all breakpoints before this one become
+ // inactive.
+ if breakpoint == Breakpoint::Mandatory {
+ active = table.len();
+ }
+
+ table.extend(best);
+ prev_end = end;
+ });
+
+ // Retrace the best path.
+ let mut indices = Vec::with_capacity(16);
+ let mut idx = table.len() - 1;
+ while idx != 0 {
+ indices.push(idx);
+ idx = table[idx].pred;
+ }
+
+ let mut pred = Line::empty();
+ let mut start = 0;
+ let mut exact = 0.0;
+
+ // The cost that we optimized was only an approximate cost, so the layout we
+ // got here is only likely to be good, not guaranteed to be the best. We now
+ // computes its exact cost as that gives us a sound upper bound for the
+ // proper optimization pass.
+ for idx in indices.into_iter().rev() {
+ let Entry { end, breakpoint, unbreakable, .. } = table[idx];
+
+ let attempt = line(engine, p, start..end, breakpoint, Some(&pred));
+ let (ratio, line_cost) =
+ ratio_and_cost(p, metrics, width, &pred, &attempt, breakpoint, unbreakable);
+
+ // If approximation produces a valid layout without too much shrinking,
+ // exact layout is guaranteed to find the same layout. If, however, the
+ // line is overfull, we do not have this guarantee. Then, our bound
+ // becomes useless and actively harmful (it could be lower than what
+ // optimal layout produces). Thus, we immediately bail with an infinite
+ // bound in this case.
+ if ratio < metrics.min_ratio {
+ return Cost::INFINITY;
+ }
+
+ pred = attempt;
+ start = end;
+ exact += line_cost;
+ }
+
+ exact
+}
+
+/// Compute the stretch ratio and cost of a line.
+#[allow(clippy::too_many_arguments)]
+fn ratio_and_cost(
+ p: &Preparation,
+ metrics: &CostMetrics,
+ available_width: Abs,
+ pred: &Line,
+ attempt: &Line,
+ breakpoint: Breakpoint,
+ unbreakable: bool,
+) -> (f64, Cost) {
+ let ratio = raw_ratio(
+ p,
+ available_width,
+ attempt.width,
+ attempt.stretchability(),
+ attempt.shrinkability(),
+ attempt.justifiables(),
+ );
+
+ let cost = raw_cost(
+ metrics,
+ breakpoint,
+ ratio,
+ attempt.justify,
+ unbreakable,
+ pred.dash.is_some() && attempt.dash.is_some(),
+ false,
+ );
+
+ (ratio, cost)
+}
+
+/// Determine the stretch ratio for a line given raw metrics.
+///
+/// - A ratio < min_ratio indicates an overfull line.
+/// - A negative ratio indicates a line that needs shrinking.
+/// - A ratio of zero indicates a perfect line.
+/// - A positive ratio indicates a line that needs stretching.
+fn raw_ratio(
+ p: &Preparation,
+ available_width: Abs,
+ line_width: Abs,
+ stretchability: Abs,
+ shrinkability: Abs,
+ justifiables: usize,
+) -> f64 {
+ // Determine how much the line's spaces would need to be stretched
+ // to make it the desired width.
+ let delta = available_width - line_width;
+
+ // Determine how much stretch or shrink is natural.
+ let adjustability = if delta >= Abs::zero() { stretchability } else { shrinkability };
+
+ // Observations:
+ // - `delta` is negative for a line that needs shrinking and positive for a
+ // line that needs stretching.
+ // - `adjustability` must be non-negative to make sense.
+ // - `ratio` inherits the sign of `delta`.
+ let mut ratio = delta / adjustability.max(Abs::zero());
+
+ // The most likely cause of a NaN result is that `delta` was zero. This
+ // often happens with monospace fonts and CJK texts. It means that the line
+ // already fits perfectly, so `ratio` should be zero then.
+ if ratio.is_nan() {
+ ratio = 0.0;
+ }
+
+ // If the ratio exceeds 1, we should stretch above the natural
+ // stretchability using justifiables.
+ if ratio > 1.0 {
+ // We should stretch the line above its stretchability. Now
+ // calculate the extra amount. Also, don't divide by zero.
+ let extra_stretch = (delta - adjustability) / justifiables.max(1) as f64;
+ // Normalize the amount by half the em size.
+ ratio = 1.0 + extra_stretch / (p.size / 2.0);
+ }
+
+ // The min value must be < MIN_RATIO, but how much smaller doesn't matter
+ // since overfull lines have hard-coded huge costs anyway.
+ //
+ // The max value is clamped to 10 since it doesn't really matter whether a
+ // line is stretched 10x or 20x.
+ ratio.clamp(MIN_RATIO - 1.0, 10.0)
+}
+
+/// Compute the cost of a line given raw metrics.
+///
+/// This mostly follows the formula in the Knuth-Plass paper, but there are some
+/// adjustments.
+fn raw_cost(
+ metrics: &CostMetrics,
+ breakpoint: Breakpoint,
+ ratio: f64,
+ justify: bool,
+ unbreakable: bool,
+ consecutive_dash: bool,
+ approx: bool,
+) -> Cost {
+ // Determine the stretch/shrink cost of the line.
+ let badness = if ratio < metrics.min_ratio(approx) {
+ // Overfull line always has maximum cost.
+ 1_000_000.0
+ } else if breakpoint != Breakpoint::Mandatory || justify || ratio < 0.0 {
+ // If the line shall be justified or needs shrinking, it has normal
+ // badness with cost 100|ratio|^3. We limit the ratio to 10 as to not
+ // get to close to our maximum cost.
+ 100.0 * ratio.abs().powi(3)
+ } else {
+ // If the line shouldn't be justified and doesn't need shrink, we don't
+ // pay any cost.
+ 0.0
+ };
+
+ // Compute penalties.
+ let mut penalty = 0.0;
+
+ // Penalize runts (lone words before a mandatory break / at the end).
+ if unbreakable && breakpoint == Breakpoint::Mandatory {
+ penalty += metrics.runt_cost;
+ }
+
+ // Penalize hyphenation.
+ if let Breakpoint::Hyphen(l, r) = breakpoint {
+ // We penalize hyphenations close to the edges of the word (< LIMIT
+ // chars) extra. For each step of distance from the limit, we add 15%
+ // to the cost.
+ const LIMIT: u8 = 5;
+ let steps = LIMIT.saturating_sub(l) + LIMIT.saturating_sub(r);
+ let extra = 0.15 * steps as f64;
+ penalty += (1.0 + extra) * metrics.hyph_cost;
+ }
+
+ // Penalize two consecutive dashes extra (not necessarily hyphens).
+ // Knuth-Plass does this separately after the squaring, with a higher cost,
+ // but I couldn't find any explanation as to why.
+ if consecutive_dash {
+ penalty += metrics.hyph_cost;
+ }
+
+ // From the Knuth-Plass Paper: $ (1 + beta_j + pi_j)^2 $.
+ //
+ // We add one to minimize the number of lines when everything else is more
+ // or less equal.
+ (1.0 + badness + penalty).powi(2)
+}
+
+/// Calls `f` for all possible points in the text where lines can broken.
+///
+/// Yields 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).
+///
+/// This is an internal instead of an external iterator because it makes the
+/// code much simpler and the consumers of this function don't need the
+/// composability and flexibility of external iteration anyway.
+fn breakpoints(p: &Preparation, mut f: impl FnMut(usize, Breakpoint)) {
+ let text = p.text;
+
+ // Single breakpoint at the end for empty text.
+ if text.is_empty() {
+ f(0, Breakpoint::Mandatory);
+ return;
+ }
+
+ let hyphenate = p.hyphenate != Some(false);
+ let lb = LINEBREAK_DATA.as_borrowed();
+ let segmenter = match p.lang {
+ Some(Lang::CHINESE | Lang::JAPANESE) => &CJ_SEGMENTER,
+ _ => &SEGMENTER,
+ };
+
+ let mut last = 0;
+ let mut iter = segmenter.segment_str(text).peekable();
+
+ loop {
+ // Special case for links. UAX #14 doesn't handle them well.
+ let (head, tail) = text.split_at(last);
+ if head.ends_with("://") || tail.starts_with("www.") {
+ let (link, _) = link_prefix(tail);
+ linebreak_link(link, |i| f(last + i, Breakpoint::Normal));
+ last += link.len();
+ while iter.peek().is_some_and(|&p| p < last) {
+ iter.next();
+ }
+ }
+
+ // Get the next UAX #14 linebreak opportunity.
+ let Some(point) = iter.next() else { break };
+
+ // Skip breakpoint if there is no char before it. icu4x generates one
+ // at offset 0, but we don't want it.
+ let Some(c) = text[..point].chars().next_back() else { continue };
+
+ // Find out whether the last break was mandatory by checking against
+ // rules LB4 and LB5, special-casing the end of text according to LB3.
+ // See also: https://docs.rs/icu_segmenter/latest/icu_segmenter/struct.LineSegmenter.html
+ let breakpoint = if point == text.len() {
+ Breakpoint::Mandatory
+ } else {
+ match lb.get(c) {
+ // Fix for: https://github.com/unicode-org/icu4x/issues/4146
+ LineBreak::Glue | LineBreak::WordJoiner | LineBreak::ZWJ => continue,
+ LineBreak::MandatoryBreak
+ | LineBreak::CarriageReturn
+ | LineBreak::LineFeed
+ | LineBreak::NextLine => Breakpoint::Mandatory,
+ _ => Breakpoint::Normal,
+ }
+ };
+
+ // Hyphenate between the last and current breakpoint.
+ if hyphenate && last < point {
+ for segment in text[last..point].split_word_bounds() {
+ if !segment.is_empty() && segment.chars().all(char::is_alphabetic) {
+ hyphenations(p, &lb, last, segment, &mut f);
+ }
+ last += segment.len();
+ }
+ }
+
+ // Call `f` for the UAX #14 break opportunity.
+ f(point, breakpoint);
+ last = point;
+ }
+}
+
+/// Generate breakpoints for hyphenations within a word.
+fn hyphenations(
+ p: &Preparation,
+ lb: &CodePointMapDataBorrowed<LineBreak>,
+ mut offset: usize,
+ word: &str,
+ mut f: impl FnMut(usize, Breakpoint),
+) {
+ let Some(lang) = lang_at(p, offset) else { return };
+ let count = word.chars().count();
+ let end = offset + word.len();
+
+ let mut chars = 0;
+ for syllable in hypher::hyphenate(word, lang) {
+ offset += syllable.len();
+ chars += syllable.chars().count();
+
+ // Don't hyphenate after the final syllable.
+ if offset == end {
+ continue;
+ }
+
+ // Filter out hyphenation opportunities where hyphenation was actually
+ // disabled.
+ if !hyphenate_at(p, offset) {
+ continue;
+ }
+
+ // Filter out forbidden hyphenation opportunities.
+ if matches!(
+ syllable.chars().next_back().map(|c| lb.get(c)),
+ Some(LineBreak::Glue | LineBreak::WordJoiner | LineBreak::ZWJ)
+ ) {
+ continue;
+ }
+
+ // Determine the number of codepoints before and after the hyphenation.
+ let l = chars.saturating_as::<u8>();
+ let r = (count - chars).saturating_as::<u8>();
+
+ // Call `f` for the word-internal hyphenation opportunity.
+ f(offset, Breakpoint::Hyphen(l, r));
+ }
+}
+
+/// Produce linebreak opportunities for a link.
+fn linebreak_link(link: &str, mut f: impl FnMut(usize)) {
+ #[derive(PartialEq)]
+ enum Class {
+ Alphabetic,
+ Digit,
+ Open,
+ Other,
+ }
+
+ impl Class {
+ fn of(c: char) -> Self {
+ if c.is_alphabetic() {
+ Class::Alphabetic
+ } else if c.is_numeric() {
+ Class::Digit
+ } else if matches!(c, '(' | '[') {
+ Class::Open
+ } else {
+ Class::Other
+ }
+ }
+ }
+
+ let mut offset = 0;
+ let mut prev = Class::Other;
+
+ for (end, c) in link.char_indices() {
+ let class = Class::of(c);
+
+ // Emit opportunities when going from
+ // - other -> other
+ // - alphabetic -> numeric
+ // - numeric -> alphabetic
+ // Never before/after opening delimiters.
+ if end > 0
+ && prev != Class::Open
+ && if class == Class::Other { prev == Class::Other } else { class != prev }
+ {
+ let piece = &link[offset..end];
+ if piece.len() < 16 {
+ // For bearably long segments, emit them as one.
+ offset = end;
+ f(offset);
+ } else {
+ // If it gets very long (e.g. a hash in the URL), just allow a
+ // break at every char.
+ for c in piece.chars() {
+ offset += c.len_utf8();
+ f(offset);
+ }
+ }
+ }
+
+ prev = class;
+ }
+}
+
+/// Whether hyphenation is enabled at the given offset.
+fn hyphenate_at(p: &Preparation, offset: usize) -> bool {
+ p.hyphenate
+ .or_else(|| {
+ let (_, item) = p.get(offset);
+ let styles = item.text()?.styles;
+ Some(TextElem::hyphenate_in(styles))
+ })
+ .unwrap_or(false)
+}
+
+/// The text language at the given offset.
+fn lang_at(p: &Preparation, offset: usize) -> Option<hypher::Lang> {
+ let lang = p.lang.or_else(|| {
+ let (_, item) = p.get(offset);
+ let styles = item.text()?.styles;
+ Some(TextElem::lang_in(styles))
+ })?;
+
+ let bytes = lang.as_str().as_bytes().try_into().ok()?;
+ hypher::Lang::from_iso(bytes)
+}
+
+/// Resolved metrics relevant for cost computation.
+struct CostMetrics {
+ min_ratio: f64,
+ min_approx_ratio: f64,
+ approx_hyphen_width: Abs,
+ hyph_cost: Cost,
+ runt_cost: Cost,
+}
+
+impl CostMetrics {
+ /// Compute shared metrics for paragraph optimization.
+ fn compute(p: &Preparation) -> Self {
+ Self {
+ // When justifying, we may stretch spaces below their natural width.
+ min_ratio: if p.justify { MIN_RATIO } else { 0.0 },
+ min_approx_ratio: if p.justify { MIN_APPROX_RATIO } else { 0.0 },
+ // Approximate hyphen width for estimates.
+ approx_hyphen_width: Em::new(0.33).at(p.size),
+ // Costs.
+ hyph_cost: DEFAULT_HYPH_COST * p.costs.hyphenation().get(),
+ runt_cost: DEFAULT_RUNT_COST * p.costs.runt().get(),
+ }
+ }
+
+ /// The minimum line ratio we allow for shrinking. For approximate layout,
+ /// we allow less because otherwise we get an invalid layout fairly often,
+ /// which makes our bound useless.
+ fn min_ratio(&self, approx: bool) -> f64 {
+ if approx {
+ self.min_approx_ratio
+ } else {
+ self.min_ratio
+ }
+ }
+}
+
+/// Estimated line metrics.
+///
+/// Allows to get a quick estimate of a metric for a line between two byte
+/// positions.
+struct Estimates {
+ widths: CumulativeVec<Abs>,
+ stretchability: CumulativeVec<Abs>,
+ shrinkability: CumulativeVec<Abs>,
+ justifiables: CumulativeVec<usize>,
+}
+
+impl Estimates {
+ /// Compute estimations for approximate Knuth-Plass layout.
+ fn compute(p: &Preparation) -> Self {
+ let cap = p.text.len();
+
+ let mut widths = CumulativeVec::with_capacity(cap);
+ let mut stretchability = CumulativeVec::with_capacity(cap);
+ let mut shrinkability = CumulativeVec::with_capacity(cap);
+ let mut justifiables = CumulativeVec::with_capacity(cap);
+
+ for (range, item) in p.items.iter() {
+ if let Item::Text(shaped) = item {
+ for g in shaped.glyphs.iter() {
+ let byte_len = g.range.len();
+ let stretch = g.stretchability().0 + g.stretchability().1;
+ let shrink = g.shrinkability().0 + g.shrinkability().1;
+ widths.push(byte_len, g.x_advance.at(shaped.size));
+ stretchability.push(byte_len, stretch.at(shaped.size));
+ shrinkability.push(byte_len, shrink.at(shaped.size));
+ justifiables.push(byte_len, g.is_justifiable() as usize);
+ }
+ } else {
+ widths.push(range.len(), item.natural_width());
+ }
+
+ widths.adjust(range.end);
+ stretchability.adjust(range.end);
+ shrinkability.adjust(range.end);
+ justifiables.adjust(range.end);
+ }
+
+ Self {
+ widths,
+ stretchability,
+ shrinkability,
+ justifiables,
+ }
+ }
+}
+
+/// An accumulative array of a metric.
+struct CumulativeVec<T> {
+ total: T,
+ summed: Vec<T>,
+}
+
+impl<T> CumulativeVec<T>
+where
+ T: Default + Copy + Add<Output = T> + Sub<Output = T>,
+{
+ /// Create a new instance with the given capacity.
+ fn with_capacity(capacity: usize) -> Self {
+ let total = T::default();
+ let mut summed = Vec::with_capacity(capacity);
+ summed.push(total);
+ Self { total, summed }
+ }
+
+ /// Adjust to cover the given byte length.
+ fn adjust(&mut self, len: usize) {
+ self.summed.resize(len, self.total);
+ }
+
+ /// Adds a new segment with the given byte length and metric.
+ fn push(&mut self, byte_len: usize, metric: T) {
+ self.total = self.total + metric;
+ for _ in 0..byte_len {
+ self.summed.push(self.total);
+ }
+ }
+
+ /// Estimates the metrics for the line spanned by the range.
+ fn estimate(&self, range: Range) -> T {
+ self.get(range.end) - self.get(range.start)
+ }
+
+ /// Get the metric at the given byte position.
+ fn get(&self, index: usize) -> T {
+ match index.checked_sub(1) {
+ None => T::default(),
+ Some(i) => self.summed[i],
+ }
+ }
+}
diff --git a/crates/typst-layout/src/inline/mod.rs b/crates/typst-layout/src/inline/mod.rs
new file mode 100644
index 00000000..658e3084
--- /dev/null
+++ b/crates/typst-layout/src/inline/mod.rs
@@ -0,0 +1,105 @@
+#[path = "box.rs"]
+mod box_;
+mod collect;
+mod deco;
+mod finalize;
+mod line;
+mod linebreak;
+mod prepare;
+mod shaping;
+
+pub use self::box_::layout_box;
+
+use comemo::{Track, Tracked, TrackedMut};
+use typst_library::diag::SourceResult;
+use typst_library::engine::{Engine, Route, Sink, Traced};
+use typst_library::foundations::{StyleChain, StyleVec};
+use typst_library::introspection::{Introspector, Locator, LocatorLink};
+use typst_library::layout::{Fragment, Size};
+use typst_library::model::ParElem;
+use typst_library::routines::Routines;
+use typst_library::World;
+
+use self::collect::{collect, Item, Segment, SpanMapper};
+use self::deco::decorate;
+use self::finalize::finalize;
+use self::line::{commit, line, Line};
+use self::linebreak::{linebreak, Breakpoint};
+use self::prepare::{prepare, Preparation};
+use self::shaping::{
+ cjk_punct_style, is_of_cj_script, shape_range, ShapedGlyph, ShapedText,
+ BEGIN_PUNCT_PAT, END_PUNCT_PAT,
+};
+
+/// Range of a substring of text.
+type Range = std::ops::Range<usize>;
+
+/// Layouts content inline.
+pub fn layout_inline(
+ engine: &mut Engine,
+ children: &StyleVec,
+ locator: Locator,
+ styles: StyleChain,
+ consecutive: bool,
+ region: Size,
+ expand: bool,
+) -> SourceResult<Fragment> {
+ layout_inline_impl(
+ children,
+ engine.routines,
+ engine.world,
+ engine.introspector,
+ engine.traced,
+ TrackedMut::reborrow_mut(&mut engine.sink),
+ engine.route.track(),
+ locator.track(),
+ styles,
+ consecutive,
+ region,
+ expand,
+ )
+}
+
+/// The internal, memoized implementation of `layout_inline`.
+#[comemo::memoize]
+#[allow(clippy::too_many_arguments)]
+fn layout_inline_impl(
+ children: &StyleVec,
+ routines: &Routines,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ traced: Tracked<Traced>,
+ sink: TrackedMut<Sink>,
+ route: Tracked<Route>,
+ locator: Tracked<Locator>,
+ styles: StyleChain,
+ consecutive: bool,
+ region: Size,
+ expand: bool,
+) -> SourceResult<Fragment> {
+ let link = LocatorLink::new(locator);
+ let locator = Locator::link(&link);
+ let mut engine = Engine {
+ routines,
+ world,
+ introspector,
+ traced,
+ sink,
+ route: Route::extend(route),
+ };
+
+ let mut locator = locator.split();
+
+ // Collect all text into one string for BiDi analysis.
+ let (text, segments, spans) =
+ collect(children, &mut engine, &mut locator, &styles, region, consecutive)?;
+
+ // Perform BiDi analysis and then prepares paragraph layout.
+ let p = prepare(&mut engine, children, &text, segments, spans, styles)?;
+
+ // Break the paragraph into lines.
+ let lines = linebreak(&engine, &p, region.x - p.hang);
+
+ // Turn the selected lines into frames.
+ finalize(&mut engine, &p, &lines, styles, region, expand, &mut locator)
+}
diff --git a/crates/typst-layout/src/inline/prepare.rs b/crates/typst-layout/src/inline/prepare.rs
new file mode 100644
index 00000000..2dd79aec
--- /dev/null
+++ b/crates/typst-layout/src/inline/prepare.rs
@@ -0,0 +1,196 @@
+use typst_library::foundations::{Resolve, Smart};
+use typst_library::layout::{Abs, AlignElem, Dir, Em, FixedAlignment};
+use typst_library::model::Linebreaks;
+use typst_library::text::{Costs, Lang, TextElem};
+use unicode_bidi::{BidiInfo, Level as BidiLevel};
+
+use super::*;
+
+/// 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.
+pub struct Preparation<'a> {
+ /// The paragraph's full text.
+ pub text: &'a str,
+ /// Bidirectional text embedding levels for the paragraph.
+ ///
+ /// This is `None` if the paragraph is BiDi-uniform (all the base direction).
+ pub bidi: Option<BidiInfo<'a>>,
+ /// Text runs, spacing and layouted elements.
+ pub items: Vec<(Range, Item<'a>)>,
+ /// Maps from byte indices to item indices.
+ pub indices: Vec<usize>,
+ /// The span mapper.
+ pub spans: SpanMapper,
+ /// Whether to hyphenate if it's the same for all children.
+ pub hyphenate: Option<bool>,
+ /// Costs for various layout decisions.
+ pub costs: Costs,
+ /// The dominant direction.
+ pub dir: Dir,
+ /// The text language if it's the same for all children.
+ pub lang: Option<Lang>,
+ /// The paragraph's resolved horizontal alignment.
+ pub align: FixedAlignment,
+ /// Whether to justify the paragraph.
+ pub justify: bool,
+ /// The paragraph's hanging indent.
+ pub hang: Abs,
+ /// Whether to add spacing between CJK and Latin characters.
+ pub cjk_latin_spacing: bool,
+ /// Whether font fallback is enabled for this paragraph.
+ pub fallback: bool,
+ /// How to determine line breaks.
+ pub linebreaks: Smart<Linebreaks>,
+ /// The text size.
+ pub size: Abs,
+}
+
+impl<'a> Preparation<'a> {
+ /// Get the item that contains the given `text_offset`.
+ pub fn get(&self, offset: usize) -> &(Range, Item<'a>) {
+ let idx = self.indices.get(offset).copied().unwrap_or(0);
+ &self.items[idx]
+ }
+
+ /// Iterate over the items that intersect the given `sliced` range.
+ pub fn slice(&self, sliced: Range) -> impl Iterator<Item = &(Range, Item<'a>)> {
+ // Usually, we don't want empty-range items at the start of the line
+ // (because they will be part of the previous line), but for the first
+ // line, we need to keep them.
+ let start = match sliced.start {
+ 0 => 0,
+ n => self.indices.get(n).copied().unwrap_or(0),
+ };
+ self.items[start..].iter().take_while(move |(range, _)| {
+ range.start < sliced.end || range.end <= sliced.end
+ })
+ }
+}
+
+/// Performs BiDi analysis and then prepares paragraph layout by building a
+/// representation on which we can do line breaking without layouting each and
+/// every line from scratch.
+#[typst_macros::time]
+pub fn prepare<'a>(
+ engine: &mut Engine,
+ children: &'a StyleVec,
+ text: &'a str,
+ segments: Vec<Segment<'a>>,
+ spans: SpanMapper,
+ styles: StyleChain<'a>,
+) -> SourceResult<Preparation<'a>> {
+ let dir = TextElem::dir_in(styles);
+ let default_level = match dir {
+ Dir::RTL => BidiLevel::rtl(),
+ _ => BidiLevel::ltr(),
+ };
+
+ let bidi = BidiInfo::new(text, Some(default_level));
+ let is_bidi = bidi
+ .levels
+ .iter()
+ .any(|level| level.is_ltr() != default_level.is_ltr());
+
+ let mut cursor = 0;
+ let mut items = Vec::with_capacity(segments.len());
+
+ // Shape the text to finalize the items.
+ for segment in segments {
+ let len = segment.textual_len();
+ let end = cursor + len;
+ let range = cursor..end;
+
+ match segment {
+ Segment::Text(_, styles) => {
+ shape_range(&mut items, engine, text, &bidi, range, styles);
+ }
+ Segment::Item(item) => items.push((range, item)),
+ }
+
+ cursor = end;
+ }
+
+ // Build the mapping from byte to item indices.
+ let mut indices = Vec::with_capacity(text.len());
+ for (i, (range, _)) in items.iter().enumerate() {
+ indices.extend(range.clone().map(|_| i));
+ }
+
+ let cjk_latin_spacing = TextElem::cjk_latin_spacing_in(styles).is_auto();
+ if cjk_latin_spacing {
+ add_cjk_latin_spacing(&mut items);
+ }
+
+ Ok(Preparation {
+ text,
+ bidi: is_bidi.then_some(bidi),
+ items,
+ indices,
+ spans,
+ hyphenate: children.shared_get(styles, TextElem::hyphenate_in),
+ costs: TextElem::costs_in(styles),
+ dir,
+ lang: children.shared_get(styles, TextElem::lang_in),
+ align: AlignElem::alignment_in(styles).resolve(styles).x,
+ justify: ParElem::justify_in(styles),
+ hang: ParElem::hanging_indent_in(styles),
+ cjk_latin_spacing,
+ fallback: TextElem::fallback_in(styles),
+ linebreaks: ParElem::linebreaks_in(styles),
+ size: TextElem::size_in(styles),
+ })
+}
+
+/// Add some spacing between Han characters and western characters. See
+/// Requirements for Chinese Text Layout, Section 3.2.2 Mixed Text Composition
+/// in Horizontal Written Mode
+fn add_cjk_latin_spacing(items: &mut [(Range, Item)]) {
+ let mut items = items
+ .iter_mut()
+ .filter(|(_, x)| !matches!(x, Item::Tag(_)))
+ .peekable();
+
+ let mut prev: Option<&ShapedGlyph> = None;
+ while let Some((_, item)) = items.next() {
+ let Some(text) = item.text_mut() else {
+ prev = None;
+ continue;
+ };
+
+ // Since we only call this function in [`prepare`], we can assume that
+ // the Cow is owned, and `to_mut` can be called without overhead.
+ debug_assert!(matches!(text.glyphs, std::borrow::Cow::Owned(_)));
+ let mut glyphs = text.glyphs.to_mut().iter_mut().peekable();
+
+ while let Some(glyph) = glyphs.next() {
+ let next = glyphs.peek().map(|n| n as _).or_else(|| {
+ items
+ .peek()
+ .and_then(|(_, i)| i.text())
+ .and_then(|shaped| shaped.glyphs.first())
+ });
+
+ // Case 1: CJ followed by a Latin character
+ if glyph.is_cj_script() && next.is_some_and(|g| g.is_letter_or_number()) {
+ // The spacing is default to 1/4 em, and can be shrunk to 1/8 em.
+ glyph.x_advance += Em::new(0.25);
+ glyph.adjustability.shrinkability.1 += Em::new(0.125);
+ text.width += Em::new(0.25).at(text.size);
+ }
+
+ // Case 2: Latin followed by a CJ character
+ if glyph.is_cj_script() && prev.is_some_and(|g| g.is_letter_or_number()) {
+ glyph.x_advance += Em::new(0.25);
+ glyph.x_offset += Em::new(0.25);
+ glyph.adjustability.shrinkability.0 += Em::new(0.125);
+ text.width += Em::new(0.25).at(text.size);
+ }
+
+ prev = Some(glyph);
+ }
+ }
+}
diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs
new file mode 100644
index 00000000..bd803b52
--- /dev/null
+++ b/crates/typst-layout/src/inline/shaping.rs
@@ -0,0 +1,1175 @@
+use std::borrow::Cow;
+use std::fmt::{self, Debug, Formatter};
+use std::str::FromStr;
+use std::sync::Arc;
+
+use az::SaturatingAs;
+use ecow::EcoString;
+use rustybuzz::{BufferFlags, ShapePlan, UnicodeBuffer};
+use ttf_parser::Tag;
+use typst_library::engine::Engine;
+use typst_library::foundations::{Smart, StyleChain};
+use typst_library::layout::{Abs, Dir, Em, Frame, FrameItem, Point, Size};
+use typst_library::text::{
+ families, features, is_default_ignorable, variant, Font, FontVariant, Glyph, Lang,
+ Region, TextEdgeBounds, TextElem, TextItem,
+};
+use typst_library::World;
+use typst_utils::SliceExt;
+use unicode_bidi::{BidiInfo, Level as BidiLevel};
+use unicode_script::{Script, UnicodeScript};
+
+use super::{decorate, Item, Range, SpanMapper};
+
+/// The result of shaping text.
+///
+/// This type contains owned or borrowed shaped text runs, which can be
+/// measured, used to reshape substrings more quickly and converted into a
+/// frame.
+#[derive(Clone)]
+pub struct ShapedText<'a> {
+ /// The start of the text in the full paragraph.
+ pub base: usize,
+ /// The text that was shaped.
+ pub text: &'a str,
+ /// The text direction.
+ pub dir: Dir,
+ /// The text language.
+ pub lang: Lang,
+ /// The text region.
+ pub region: Option<Region>,
+ /// The text's style properties.
+ pub styles: StyleChain<'a>,
+ /// The font variant.
+ pub variant: FontVariant,
+ /// The font size.
+ pub size: Abs,
+ /// The width of the text's bounding box.
+ pub width: Abs,
+ /// The shaped glyphs.
+ pub glyphs: Cow<'a, [ShapedGlyph]>,
+}
+
+/// A single glyph resulting from shaping.
+#[derive(Debug, Clone)]
+pub struct ShapedGlyph {
+ /// The font the glyph is contained in.
+ pub font: Font,
+ /// The glyph's index in the font.
+ pub glyph_id: u16,
+ /// The advance width of the glyph.
+ pub x_advance: Em,
+ /// The horizontal offset of the glyph.
+ pub x_offset: Em,
+ /// The vertical offset of the glyph.
+ pub y_offset: Em,
+ /// The adjustability of the glyph.
+ pub adjustability: Adjustability,
+ /// The byte range of this glyph's cluster in the full paragraph. A cluster
+ /// is a sequence of one or multiple glyphs that cannot be separated and
+ /// must always be treated as a union.
+ ///
+ /// The range values of the glyphs in a [`ShapedText`] should not overlap
+ /// with each other, and they should be monotonically increasing (for
+ /// left-to-right or top-to-bottom text) or monotonically decreasing (for
+ /// right-to-left or bottom-to-top text).
+ pub range: Range,
+ /// Whether splitting the shaping result before this glyph would yield the
+ /// same results as shaping the parts to both sides of `text_index`
+ /// separately.
+ pub safe_to_break: bool,
+ /// The first char in this glyph's cluster.
+ pub c: char,
+ /// Whether this glyph is justifiable for CJK scripts.
+ pub is_justifiable: bool,
+ /// The script of the glyph.
+ pub script: Script,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct Adjustability {
+ /// The left and right stretchability
+ pub stretchability: (Em, Em),
+ /// The left and right shrinkability
+ pub shrinkability: (Em, Em),
+}
+
+impl ShapedGlyph {
+ /// Whether the glyph is a space.
+ pub fn is_space(&self) -> bool {
+ is_space(self.c)
+ }
+
+ /// Whether the glyph is justifiable.
+ pub fn is_justifiable(&self) -> bool {
+ // GB style is not relevant here.
+ self.is_justifiable
+ }
+
+ /// Whether the glyph is part of Chinese or Japanese script (i.e. CJ, not CJK).
+ pub fn is_cj_script(&self) -> bool {
+ is_cj_script(self.c, self.script)
+ }
+
+ pub fn is_cjk_punctuation(&self) -> bool {
+ self.is_cjk_left_aligned_punctuation(CjkPunctStyle::Gb)
+ || self.is_cjk_right_aligned_punctuation()
+ || self.is_cjk_center_aligned_punctuation(CjkPunctStyle::Gb)
+ }
+
+ /// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment>
+ pub fn is_cjk_left_aligned_punctuation(&self, style: CjkPunctStyle) -> bool {
+ is_cjk_left_aligned_punctuation(
+ self.c,
+ self.x_advance,
+ self.stretchability(),
+ style,
+ )
+ }
+
+ /// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment>
+ pub fn is_cjk_right_aligned_punctuation(&self) -> bool {
+ is_cjk_right_aligned_punctuation(self.c, self.x_advance, self.stretchability())
+ }
+
+ /// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment>
+ pub fn is_cjk_center_aligned_punctuation(&self, style: CjkPunctStyle) -> bool {
+ is_cjk_center_aligned_punctuation(self.c, style)
+ }
+
+ /// Whether the glyph is a western letter or number.
+ pub fn is_letter_or_number(&self) -> bool {
+ matches!(self.c.script(), Script::Latin | Script::Greek | Script::Cyrillic)
+ || matches!(self.c, '#' | '$' | '%' | '&')
+ || self.c.is_ascii_digit()
+ }
+
+ pub fn base_adjustability(&self, style: CjkPunctStyle) -> Adjustability {
+ let width = self.x_advance;
+ if self.is_space() {
+ Adjustability {
+ // The number for spaces is from Knuth-Plass' paper
+ stretchability: (Em::zero(), width / 2.0),
+ shrinkability: (Em::zero(), width / 3.0),
+ }
+ } else if self.is_cjk_left_aligned_punctuation(style) {
+ Adjustability {
+ stretchability: (Em::zero(), Em::zero()),
+ shrinkability: (Em::zero(), width / 2.0),
+ }
+ } else if self.is_cjk_right_aligned_punctuation() {
+ Adjustability {
+ stretchability: (Em::zero(), Em::zero()),
+ shrinkability: (width / 2.0, Em::zero()),
+ }
+ } else if self.is_cjk_center_aligned_punctuation(style) {
+ Adjustability {
+ stretchability: (Em::zero(), Em::zero()),
+ shrinkability: (width / 4.0, width / 4.0),
+ }
+ } else {
+ Adjustability::default()
+ }
+ }
+
+ /// The stretchability of the character.
+ pub fn stretchability(&self) -> (Em, Em) {
+ self.adjustability.stretchability
+ }
+
+ /// The shrinkability of the character.
+ pub fn shrinkability(&self) -> (Em, Em) {
+ self.adjustability.shrinkability
+ }
+
+ /// Shrink the width of glyph on the left side.
+ pub fn shrink_left(&mut self, amount: Em) {
+ self.x_offset -= amount;
+ self.x_advance -= amount;
+ self.adjustability.shrinkability.0 -= amount;
+ }
+
+ /// Shrink the width of glyph on the right side.
+ pub fn shrink_right(&mut self, amount: Em) {
+ self.x_advance -= amount;
+ self.adjustability.shrinkability.1 -= amount;
+ }
+}
+
+/// A side you can go toward.
+enum Side {
+ /// To the left-hand side.
+ Left,
+ /// To the right-hand side.
+ Right,
+}
+
+impl<'a> ShapedText<'a> {
+ /// Build the shaped text's frame.
+ ///
+ /// The `justification` defines how much extra advance width each
+ /// [justifiable glyph](ShapedGlyph::is_justifiable) will get.
+ pub fn build(
+ &self,
+ engine: &Engine,
+ spans: &SpanMapper,
+ justification_ratio: f64,
+ extra_justification: Abs,
+ ) -> Frame {
+ let (top, bottom) = self.measure(engine);
+ let size = Size::new(self.width, top + bottom);
+
+ let mut offset = Abs::zero();
+ let mut frame = Frame::soft(size);
+ frame.set_baseline(top);
+
+ let shift = TextElem::baseline_in(self.styles);
+ let decos = TextElem::deco_in(self.styles);
+ let fill = TextElem::fill_in(self.styles);
+ let stroke = TextElem::stroke_in(self.styles);
+ let span_offset = TextElem::span_offset_in(self.styles);
+
+ for ((font, y_offset), group) in
+ self.glyphs.as_ref().group_by_key(|g| (g.font.clone(), g.y_offset))
+ {
+ let mut range = group[0].range.clone();
+ for glyph in group {
+ range.start = range.start.min(glyph.range.start);
+ range.end = range.end.max(glyph.range.end);
+ }
+
+ let pos = Point::new(offset, top + shift - y_offset.at(self.size));
+ let glyphs: Vec<Glyph> = group
+ .iter()
+ .map(|shaped: &ShapedGlyph| {
+ let adjustability_left = if justification_ratio < 0.0 {
+ shaped.shrinkability().0
+ } else {
+ shaped.stretchability().0
+ };
+ let adjustability_right = if justification_ratio < 0.0 {
+ shaped.shrinkability().1
+ } else {
+ shaped.stretchability().1
+ };
+
+ let justification_left = adjustability_left * justification_ratio;
+ let mut justification_right =
+ adjustability_right * justification_ratio;
+ if shaped.is_justifiable() {
+ justification_right +=
+ Em::from_length(extra_justification, self.size)
+ }
+
+ frame.size_mut().x += justification_left.at(self.size)
+ + justification_right.at(self.size);
+
+ // We may not be able to reach the offset completely if
+ // it exceeds u16, but better to have a roughly correct
+ // span offset than nothing.
+ let mut span = spans.span_at(shaped.range.start);
+ span.1 = span.1.saturating_add(span_offset.saturating_as());
+
+ // |<---- a Glyph ---->|
+ // -->|ShapedGlyph|<--
+ // +---+-----------+---+
+ // | | *********| |
+ // | | * | |
+ // | | * ****| |
+ // | | * *| |
+ // | | *********| |
+ // +---+--+--------+---+
+ // A B C D
+ // Note A, B, D could be positive, zero, or negative.
+ // A: justification_left
+ // B: ShapedGlyph's x_offset
+ // (though a small part of the glyph may go inside B)
+ // B+C: ShapedGlyph's x_advance
+ // D: justification_right
+ // A+B: Glyph's x_offset
+ // A+B+C+D: Glyph's x_advance
+ Glyph {
+ id: shaped.glyph_id,
+ x_advance: shaped.x_advance
+ + justification_left
+ + justification_right,
+ x_offset: shaped.x_offset + justification_left,
+ range: (shaped.range.start - range.start).saturating_as()
+ ..(shaped.range.end - range.start).saturating_as(),
+ span,
+ }
+ })
+ .collect();
+
+ let item = TextItem {
+ font,
+ size: self.size,
+ lang: self.lang,
+ region: self.region,
+ fill: fill.clone(),
+ stroke: stroke.clone().map(|s| s.unwrap_or_default()),
+ text: self.text[range.start - self.base..range.end - self.base].into(),
+ glyphs,
+ };
+
+ let width = item.width();
+ if decos.is_empty() {
+ frame.push(pos, FrameItem::Text(item));
+ } else {
+ // Apply line decorations.
+ frame.push(pos, FrameItem::Text(item.clone()));
+ for deco in &decos {
+ decorate(&mut frame, deco, &item, width, shift, pos);
+ }
+ }
+
+ offset += width;
+ }
+
+ frame
+ }
+
+ /// Measure the top and bottom extent of this text.
+ pub fn measure(&self, engine: &Engine) -> (Abs, Abs) {
+ let mut top = Abs::zero();
+ let mut bottom = Abs::zero();
+
+ let top_edge = TextElem::top_edge_in(self.styles);
+ let bottom_edge = TextElem::bottom_edge_in(self.styles);
+
+ // Expand top and bottom by reading the font's vertical metrics.
+ let mut expand = |font: &Font, bounds: TextEdgeBounds| {
+ let (t, b) = font.edges(top_edge, bottom_edge, self.size, bounds);
+ top.set_max(t);
+ bottom.set_max(b);
+ };
+
+ if self.glyphs.is_empty() {
+ // When there are no glyphs, we just use the vertical metrics of the
+ // first available font.
+ let world = engine.world;
+ for family in families(self.styles) {
+ if let Some(font) = world
+ .book()
+ .select(family, self.variant)
+ .and_then(|id| world.font(id))
+ {
+ expand(&font, TextEdgeBounds::Zero);
+ break;
+ }
+ }
+ } else {
+ for g in self.glyphs.iter() {
+ expand(&g.font, TextEdgeBounds::Glyph(g.glyph_id));
+ }
+ }
+
+ (top, bottom)
+ }
+
+ /// How many glyphs are in the text where we can insert additional
+ /// space when encountering underfull lines.
+ pub fn justifiables(&self) -> usize {
+ self.glyphs.iter().filter(|g| g.is_justifiable()).count()
+ }
+
+ /// Whether the last glyph is a CJK character which should not be justified
+ /// on line end.
+ pub fn cjk_justifiable_at_last(&self) -> bool {
+ self.glyphs
+ .last()
+ .map(|g| g.is_cj_script() || g.is_cjk_punctuation())
+ .unwrap_or(false)
+ }
+
+ /// The stretchability of the text.
+ pub fn stretchability(&self) -> Abs {
+ self.glyphs
+ .iter()
+ .map(|g| g.stretchability().0 + g.stretchability().1)
+ .sum::<Em>()
+ .at(self.size)
+ }
+
+ /// The shrinkability of the text
+ pub fn shrinkability(&self) -> Abs {
+ self.glyphs
+ .iter()
+ .map(|g| g.shrinkability().0 + g.shrinkability().1)
+ .sum::<Em>()
+ .at(self.size)
+ }
+
+ /// Reshape a range of the shaped text, reusing information from this
+ /// shaping process if possible.
+ ///
+ /// The text `range` is relative to the whole paragraph.
+ pub fn reshape(&'a self, engine: &Engine, text_range: Range) -> ShapedText<'a> {
+ let text = &self.text[text_range.start - self.base..text_range.end - self.base];
+ if let Some(glyphs) = self.slice_safe_to_break(text_range.clone()) {
+ #[cfg(debug_assertions)]
+ assert_all_glyphs_in_range(glyphs, text, text_range.clone());
+ Self {
+ base: text_range.start,
+ text,
+ dir: self.dir,
+ lang: self.lang,
+ region: self.region,
+ styles: self.styles,
+ size: self.size,
+ variant: self.variant,
+ width: glyphs.iter().map(|g| g.x_advance).sum::<Em>().at(self.size),
+ glyphs: Cow::Borrowed(glyphs),
+ }
+ } else {
+ shape(
+ engine,
+ text_range.start,
+ text,
+ self.styles,
+ self.dir,
+ self.lang,
+ self.region,
+ )
+ }
+ }
+
+ /// Derive an empty text run with the same properties as this one.
+ pub fn empty(&self) -> Self {
+ Self {
+ text: "",
+ width: Abs::zero(),
+ glyphs: Cow::Borrowed(&[]),
+ ..*self
+ }
+ }
+
+ /// Push a hyphen to end of the text.
+ pub fn push_hyphen(&mut self, engine: &Engine, fallback: bool) {
+ self.insert_hyphen(engine, fallback, Side::Right)
+ }
+
+ /// Prepend a hyphen to start of the text.
+ pub fn prepend_hyphen(&mut self, engine: &Engine, fallback: bool) {
+ self.insert_hyphen(engine, fallback, Side::Left)
+ }
+
+ fn insert_hyphen(&mut self, engine: &Engine, fallback: bool, side: Side) {
+ let world = engine.world;
+ let book = world.book();
+ let fallback_func = if fallback {
+ Some(|| book.select_fallback(None, self.variant, "-"))
+ } else {
+ None
+ };
+ let mut chain = families(self.styles)
+ .map(|family| book.select(family, self.variant))
+ .chain(fallback_func.iter().map(|f| f()))
+ .flatten();
+
+ chain.find_map(|id| {
+ let font = world.font(id)?;
+ let ttf = font.ttf();
+ let glyph_id = ttf.glyph_index('-')?;
+ let x_advance = font.to_em(ttf.glyph_hor_advance(glyph_id)?);
+ let range = match side {
+ Side::Left => self.glyphs.first().map(|g| g.range.start..g.range.start),
+ Side::Right => self.glyphs.last().map(|g| g.range.end..g.range.end),
+ }
+ // In the unlikely chance that we hyphenate after an empty line,
+ // ensure that the glyph range still falls after self.base so
+ // that subtracting either of the endpoints by self.base doesn't
+ // underflow. See <https://github.com/typst/typst/issues/2283>.
+ .unwrap_or_else(|| self.base..self.base);
+ self.width += x_advance.at(self.size);
+ let glyph = ShapedGlyph {
+ font,
+ glyph_id: glyph_id.0,
+ x_advance,
+ x_offset: Em::zero(),
+ y_offset: Em::zero(),
+ adjustability: Adjustability::default(),
+ range,
+ safe_to_break: true,
+ c: '-',
+ is_justifiable: false,
+ script: Script::Common,
+ };
+ match side {
+ Side::Left => self.glyphs.to_mut().insert(0, glyph),
+ Side::Right => self.glyphs.to_mut().push(glyph),
+ }
+ Some(())
+ });
+ }
+
+ /// Find the subslice of glyphs that represent the given text range if both
+ /// sides are safe to break.
+ fn slice_safe_to_break(&self, text_range: Range) -> Option<&[ShapedGlyph]> {
+ let Range { mut start, mut end } = text_range;
+ if !self.dir.is_positive() {
+ std::mem::swap(&mut start, &mut end);
+ }
+
+ let left = self.find_safe_to_break(start)?;
+ let right = self.find_safe_to_break(end)?;
+ Some(&self.glyphs[left..right])
+ }
+
+ /// Find the glyph offset matching the text index that is most towards the
+ /// start of the text and safe-to-break.
+ fn find_safe_to_break(&self, text_index: usize) -> Option<usize> {
+ let ltr = self.dir.is_positive();
+
+ // Handle edge cases.
+ let len = self.glyphs.len();
+ if text_index == self.base {
+ return Some(if ltr { 0 } else { len });
+ } else if text_index == self.base + self.text.len() {
+ return Some(if ltr { len } else { 0 });
+ }
+
+ // Find any glyph with the text index.
+ let found = self.glyphs.binary_search_by(|g: &ShapedGlyph| {
+ let ordering = g.range.start.cmp(&text_index);
+ if ltr {
+ ordering
+ } else {
+ ordering.reverse()
+ }
+ });
+
+ let mut idx = match found {
+ Ok(idx) => idx,
+ Err(idx) => {
+ // Handle the special case where we break before a '\n'
+ //
+ // For example: (assume `a` is a CJK character with three bytes)
+ // text: " a \n b "
+ // index: 0 1 2 3 4 5
+ // text_index: ^
+ // glyphs: 0 . 1
+ //
+ // We will get found = Err(1), because '\n' does not have a
+ // glyph. But it's safe to break here. Thus the following
+ // condition:
+ // - glyphs[0].end == text_index == 3
+ // - text[3] == '\n'
+ return (idx > 0
+ && self.glyphs[idx - 1].range.end == text_index
+ && self.text[text_index - self.base..].starts_with('\n'))
+ .then_some(idx);
+ }
+ };
+
+ // Search for the start-most glyph with the text index. This means
+ // we take empty range glyphs at the start and leave those at the end
+ // for the next line.
+ let dec = if ltr { usize::checked_sub } else { usize::checked_add };
+ while let Some(next) = dec(idx, 1) {
+ if self.glyphs.get(next).map_or(true, |g| g.range.start != text_index) {
+ break;
+ }
+ idx = next;
+ }
+
+ // RTL needs offset one because the left side of the range should be
+ // exclusive and the right side inclusive, contrary to the normal
+ // behaviour of ranges.
+ self.glyphs[idx].safe_to_break.then_some(idx + usize::from(!ltr))
+ }
+}
+
+impl Debug for ShapedText<'_> {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.text.fmt(f)
+ }
+}
+
+/// Group a range of text by BiDi level and script, shape the runs and generate
+/// items for them.
+pub fn shape_range<'a>(
+ items: &mut Vec<(Range, Item<'a>)>,
+ engine: &Engine,
+ text: &'a str,
+ bidi: &BidiInfo<'a>,
+ range: Range,
+ styles: StyleChain<'a>,
+) {
+ let script = TextElem::script_in(styles);
+ 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(engine, range.start, &text[range.clone()], styles, dir, lang, region);
+ items.push((range, 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. If the text's script is explicitly
+ // set (rather than inferred from the glyphs), we keep the script at an
+ // unchanging `Script::Unknown` so that only level changes cause breaks.
+ for i in range.clone() {
+ if !text.is_char_boundary(i) {
+ continue;
+ }
+
+ let level = bidi.levels[i];
+ let curr_script = match script {
+ Smart::Auto => {
+ text[i..].chars().next().map_or(Script::Unknown, |c| c.script())
+ }
+ Smart::Custom(_) => Script::Unknown,
+ };
+
+ if level != prev_level || !is_compatible(curr_script, prev_script) {
+ if cursor < i {
+ process(cursor..i, prev_level);
+ }
+ cursor = i;
+ prev_level = level;
+ prev_script = curr_script;
+ } else if is_generic_script(prev_script) {
+ prev_script = curr_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
+}
+
+/// Shape text into [`ShapedText`].
+#[allow(clippy::too_many_arguments)]
+fn shape<'a>(
+ engine: &Engine,
+ base: usize,
+ text: &'a str,
+ styles: StyleChain<'a>,
+ dir: Dir,
+ lang: Lang,
+ region: Option<Region>,
+) -> ShapedText<'a> {
+ let size = TextElem::size_in(styles);
+ let mut ctx = ShapingContext {
+ engine,
+ size,
+ glyphs: vec![],
+ used: vec![],
+ styles,
+ variant: variant(styles),
+ features: features(styles),
+ fallback: TextElem::fallback_in(styles),
+ dir,
+ };
+
+ if !text.is_empty() {
+ shape_segment(&mut ctx, base, text, families(styles));
+ }
+
+ track_and_space(&mut ctx);
+ calculate_adjustability(&mut ctx, lang, region);
+
+ #[cfg(debug_assertions)]
+ assert_all_glyphs_in_range(&ctx.glyphs, text, base..(base + text.len()));
+ #[cfg(debug_assertions)]
+ assert_glyph_ranges_in_order(&ctx.glyphs, dir);
+
+ ShapedText {
+ base,
+ text,
+ dir,
+ lang,
+ region,
+ styles,
+ variant: ctx.variant,
+ size,
+ width: ctx.glyphs.iter().map(|g| g.x_advance).sum::<Em>().at(size),
+ glyphs: Cow::Owned(ctx.glyphs),
+ }
+}
+
+/// Holds shaping results and metadata common to all shaped segments.
+struct ShapingContext<'a, 'v> {
+ engine: &'a Engine<'v>,
+ glyphs: Vec<ShapedGlyph>,
+ used: Vec<Font>,
+ styles: StyleChain<'a>,
+ size: Abs,
+ variant: FontVariant,
+ features: Vec<rustybuzz::Feature>,
+ fallback: bool,
+ dir: Dir,
+}
+
+/// Shape text with font fallback using the `families` iterator.
+fn shape_segment<'a>(
+ ctx: &mut ShapingContext,
+ base: usize,
+ text: &str,
+ mut families: impl Iterator<Item = &'a str> + Clone,
+) {
+ // Don't try shaping newlines, tabs, or default ignorables.
+ if text
+ .chars()
+ .all(|c| c == '\n' || c == '\t' || is_default_ignorable(c))
+ {
+ return;
+ }
+
+ // Find the next available family.
+ let world = ctx.engine.world;
+ let book = world.book();
+ let mut selection = families.find_map(|family| {
+ book.select(family, ctx.variant)
+ .and_then(|id| world.font(id))
+ .filter(|font| !ctx.used.contains(font))
+ });
+
+ // Do font fallback if the families are exhausted and fallback is enabled.
+ if selection.is_none() && ctx.fallback {
+ let first = ctx.used.first().map(Font::info);
+ selection = book
+ .select_fallback(first, ctx.variant, text)
+ .and_then(|id| world.font(id))
+ .filter(|font| !ctx.used.contains(font));
+ }
+
+ // Extract the font id or shape notdef glyphs if we couldn't find any font.
+ let Some(font) = selection else {
+ if let Some(font) = ctx.used.first().cloned() {
+ shape_tofus(ctx, base, text, font);
+ }
+ return;
+ };
+
+ ctx.used.push(font.clone());
+
+ // Fill the buffer with our text.
+ let mut buffer = UnicodeBuffer::new();
+ buffer.push_str(text);
+ buffer.set_language(language(ctx.styles));
+ if let Some(script) = TextElem::script_in(ctx.styles).custom().and_then(|script| {
+ rustybuzz::Script::from_iso15924_tag(Tag::from_bytes(script.as_bytes()))
+ }) {
+ buffer.set_script(script)
+ }
+ buffer.set_direction(match ctx.dir {
+ Dir::LTR => rustybuzz::Direction::LeftToRight,
+ Dir::RTL => rustybuzz::Direction::RightToLeft,
+ _ => unimplemented!("vertical text layout"),
+ });
+ buffer.guess_segment_properties();
+
+ // By default, Harfbuzz will create zero-width space glyphs for default
+ // ignorables. This is probably useful for GUI apps that want noticable
+ // effects on the cursor for those, but for us it's not useful and hurts
+ // text extraction.
+ buffer.set_flags(BufferFlags::REMOVE_DEFAULT_IGNORABLES);
+
+ // Prepare the shape plan. This plan depends on direction, script, language,
+ // and features, but is independent from the text and can thus be memoized.
+ let plan = create_shape_plan(
+ &font,
+ buffer.direction(),
+ buffer.script(),
+ buffer.language().as_ref(),
+ &ctx.features,
+ );
+
+ // Shape!
+ let buffer = rustybuzz::shape_with_plan(font.rusty(), &plan, buffer);
+ let infos = buffer.glyph_infos();
+ let pos = buffer.glyph_positions();
+ let ltr = ctx.dir.is_positive();
+
+ // Collect the shaped glyphs, doing fallback and shaping parts again with
+ // the next font if necessary.
+ let mut i = 0;
+ while i < infos.len() {
+ let info = &infos[i];
+ let cluster = info.cluster as usize;
+
+ // Add the glyph to the shaped output.
+ if info.glyph_id != 0 {
+ // Determine the text range of the glyph.
+ let start = base + cluster;
+ let end = base
+ + if ltr { i.checked_add(1) } else { i.checked_sub(1) }
+ .and_then(|last| infos.get(last))
+ .map_or(text.len(), |info| info.cluster as usize);
+
+ let c = text[cluster..].chars().next().unwrap();
+ let script = c.script();
+ let x_advance = font.to_em(pos[i].x_advance);
+ ctx.glyphs.push(ShapedGlyph {
+ font: font.clone(),
+ glyph_id: info.glyph_id as u16,
+ // TODO: Don't ignore y_advance.
+ x_advance,
+ x_offset: font.to_em(pos[i].x_offset),
+ y_offset: font.to_em(pos[i].y_offset),
+ adjustability: Adjustability::default(),
+ range: start..end,
+ safe_to_break: !info.unsafe_to_break(),
+ c,
+ is_justifiable: is_justifiable(
+ c,
+ script,
+ x_advance,
+ Adjustability::default().stretchability,
+ ),
+ script,
+ });
+ } else {
+ // First, search for the end of the tofu sequence.
+ let k = i;
+ while infos.get(i + 1).is_some_and(|info| info.glyph_id == 0) {
+ i += 1;
+ }
+
+ // Then, determine the start and end text index for the tofu
+ // sequence.
+ //
+ // Examples:
+ // Everything is shown in visual order. Tofus are written as "_".
+ // We want to find out that the tofus span the text `2..6`.
+ // Note that the clusters are longer than 1 char.
+ //
+ // Left-to-right:
+ // Text: h a l i h a l l o
+ // Glyphs: A _ _ C E
+ // Clusters: 0 2 4 6 8
+ // k=1 i=2
+ //
+ // Right-to-left:
+ // Text: O L L A H I L A H
+ // Glyphs: E C _ _ A
+ // Clusters: 8 6 4 2 0
+ // k=2 i=3
+ let start = infos[if ltr { k } else { i }].cluster as usize;
+ let end = if ltr { i.checked_add(1) } else { k.checked_sub(1) }
+ .and_then(|last| infos.get(last))
+ .map_or(text.len(), |info| info.cluster as usize);
+
+ // Trim half-baked cluster.
+ let remove = base + start..base + end;
+ while ctx.glyphs.last().is_some_and(|g| remove.contains(&g.range.start)) {
+ ctx.glyphs.pop();
+ }
+
+ // Recursively shape the tofu sequence with the next family.
+ shape_segment(ctx, base + start, &text[start..end], families.clone());
+ }
+
+ i += 1;
+ }
+
+ ctx.used.pop();
+}
+
+/// Create a shape plan.
+#[comemo::memoize]
+fn create_shape_plan(
+ font: &Font,
+ direction: rustybuzz::Direction,
+ script: rustybuzz::Script,
+ language: Option<&rustybuzz::Language>,
+ features: &[rustybuzz::Feature],
+) -> Arc<ShapePlan> {
+ Arc::new(rustybuzz::ShapePlan::new(
+ font.rusty(),
+ direction,
+ Some(script),
+ language,
+ features,
+ ))
+}
+
+/// Shape the text with tofus from the given font.
+fn shape_tofus(ctx: &mut ShapingContext, base: usize, text: &str, font: Font) {
+ let x_advance = font.advance(0).unwrap_or_default();
+ let add_glyph = |(cluster, c): (usize, char)| {
+ let start = base + cluster;
+ let end = start + c.len_utf8();
+ let script = c.script();
+ ctx.glyphs.push(ShapedGlyph {
+ font: font.clone(),
+ glyph_id: 0,
+ x_advance,
+ x_offset: Em::zero(),
+ y_offset: Em::zero(),
+ adjustability: Adjustability::default(),
+ range: start..end,
+ safe_to_break: true,
+ c,
+ is_justifiable: is_justifiable(
+ c,
+ script,
+ x_advance,
+ Adjustability::default().stretchability,
+ ),
+ script,
+ });
+ };
+ if ctx.dir.is_positive() {
+ text.char_indices().for_each(add_glyph);
+ } else {
+ text.char_indices().rev().for_each(add_glyph);
+ }
+}
+
+/// Apply tracking and spacing to the shaped glyphs.
+fn track_and_space(ctx: &mut ShapingContext) {
+ let tracking = Em::from_length(TextElem::tracking_in(ctx.styles), ctx.size);
+ let spacing =
+ TextElem::spacing_in(ctx.styles).map(|abs| Em::from_length(abs, ctx.size));
+
+ let mut glyphs = ctx.glyphs.iter_mut().peekable();
+ while let Some(glyph) = glyphs.next() {
+ // Make non-breaking space same width as normal space.
+ if glyph.c == '\u{00A0}' {
+ glyph.x_advance -= nbsp_delta(&glyph.font).unwrap_or_default();
+ }
+
+ if glyph.is_space() {
+ glyph.x_advance = spacing.relative_to(glyph.x_advance);
+ }
+
+ if glyphs
+ .peek()
+ .is_some_and(|next| glyph.range.start != next.range.start)
+ {
+ glyph.x_advance += tracking;
+ }
+ }
+}
+
+/// Calculate stretchability and shrinkability of each glyph,
+/// and CJK punctuation adjustments according to Chinese Layout Requirements.
+fn calculate_adjustability(ctx: &mut ShapingContext, lang: Lang, region: Option<Region>) {
+ let style = cjk_punct_style(lang, region);
+
+ for glyph in &mut ctx.glyphs {
+ glyph.adjustability = glyph.base_adjustability(style);
+ }
+
+ let mut glyphs = ctx.glyphs.iter_mut().peekable();
+ while let Some(glyph) = glyphs.next() {
+ // CNS style needs not further adjustment.
+ if glyph.is_cjk_punctuation() && matches!(style, CjkPunctStyle::Cns) {
+ continue;
+ }
+
+ // Now we apply consecutive punctuation adjustment, specified in Chinese Layout.
+ // Requirements, section 3.1.6.1 Punctuation Adjustment Space, and Japanese Layout
+ // Requirements, section 3.1 Line Composition Rules for Punctuation Marks
+ let Some(next) = glyphs.peek_mut() else { continue };
+ let width = glyph.x_advance;
+ let delta = width / 2.0;
+ if glyph.is_cjk_punctuation()
+ && next.is_cjk_punctuation()
+ && (glyph.shrinkability().1 + next.shrinkability().0) >= delta
+ {
+ let left_delta = glyph.shrinkability().1.min(delta);
+ glyph.shrink_right(left_delta);
+ next.shrink_left(delta - left_delta);
+ }
+ }
+}
+
+/// Difference between non-breaking and normal space.
+fn nbsp_delta(font: &Font) -> Option<Em> {
+ let space = font.ttf().glyph_index(' ')?.0;
+ let nbsp = font.ttf().glyph_index('\u{00A0}')?.0;
+ Some(font.advance(nbsp)? - font.advance(space)?)
+}
+
+/// Process the language and region of a style chain into a
+/// rustybuzz-compatible BCP 47 language.
+fn language(styles: StyleChain) -> rustybuzz::Language {
+ let mut bcp: EcoString = TextElem::lang_in(styles).as_str().into();
+ if let Some(region) = TextElem::region_in(styles) {
+ bcp.push('-');
+ bcp.push_str(region.as_str());
+ }
+ rustybuzz::Language::from_str(&bcp).unwrap()
+}
+
+/// Returns true if all glyphs in `glyphs` have ranges within the range `range`.
+#[cfg(debug_assertions)]
+fn assert_all_glyphs_in_range(glyphs: &[ShapedGlyph], text: &str, range: Range) {
+ if glyphs
+ .iter()
+ .any(|g| g.range.start < range.start || g.range.end > range.end)
+ {
+ panic!("one or more glyphs in {text:?} fell out of range");
+ }
+}
+
+/// Asserts that the ranges of `glyphs` is in the proper order according to
+/// `dir`.
+///
+/// This asserts instead of returning a bool in order to provide a more
+/// informative message when the invariant is violated.
+#[cfg(debug_assertions)]
+fn assert_glyph_ranges_in_order(glyphs: &[ShapedGlyph], dir: Dir) {
+ if glyphs.is_empty() {
+ return;
+ }
+
+ // Iterator::is_sorted and friends are unstable as of Rust 1.70.0
+ for i in 0..(glyphs.len() - 1) {
+ let a = &glyphs[i];
+ let b = &glyphs[i + 1];
+ let ord = a.range.start.cmp(&b.range.start);
+ let ord = if dir.is_positive() { ord } else { ord.reverse() };
+ if ord == std::cmp::Ordering::Greater {
+ panic!(
+ "glyph ranges should be monotonically {}, \
+ but found glyphs out of order:\n\n\
+ first: {a:#?}\nsecond: {b:#?}",
+ if dir.is_positive() { "increasing" } else { "decreasing" },
+ );
+ }
+ }
+}
+
+// The CJK punctuation that can appear at the beginning or end of a line.
+pub const BEGIN_PUNCT_PAT: &[char] =
+ &['“', '‘', '《', '〈', '(', '『', '「', '【', '〖', '〔', '[', '{'];
+pub const END_PUNCT_PAT: &[char] = &[
+ '”', '’', ',', '.', '。', '、', ':', ';', '》', '〉', ')', '』', '」', '】',
+ '〗', '〕', ']', '}', '?', '!',
+];
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CjkPunctStyle {
+ /// Standard GB/T 15834-2011, used mostly in mainland China.
+ Gb,
+ /// Standard by Taiwan Ministry of Education, used in Taiwan and Hong Kong.
+ Cns,
+ /// Standard JIS X 4051, used in Japan.
+ Jis,
+}
+
+pub fn cjk_punct_style(lang: Lang, region: Option<Region>) -> CjkPunctStyle {
+ match (lang, region.as_ref().map(Region::as_str)) {
+ (Lang::CHINESE, Some("TW" | "HK")) => CjkPunctStyle::Cns,
+ (Lang::JAPANESE, _) => CjkPunctStyle::Jis,
+ // zh-CN, zh-SG, zh-MY use GB-style punctuation,
+ _ => CjkPunctStyle::Gb,
+ }
+}
+
+/// Whether the glyph is a space.
+fn is_space(c: char) -> bool {
+ matches!(c, ' ' | '\u{00A0}' | ' ')
+}
+
+/// Whether the glyph is part of Chinese or Japanese script (i.e. CJ, not CJK).
+pub fn is_of_cj_script(c: char) -> bool {
+ is_cj_script(c, c.script())
+}
+
+/// Whether the glyph is part of Chinese or Japanese script (i.e. CJ, not CJK).
+/// The function is dedicated to typesetting Chinese or Japanese, which do not
+/// have spaces between words, so K is not checked here.
+fn is_cj_script(c: char, script: Script) -> bool {
+ use Script::*;
+ // U+30FC: Katakana-Hiragana Prolonged Sound Mark
+ matches!(script, Hiragana | Katakana | Han) || c == '\u{30FC}'
+}
+
+/// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment>
+fn is_cjk_left_aligned_punctuation(
+ c: char,
+ x_advance: Em,
+ stretchability: (Em, Em),
+ style: CjkPunctStyle,
+) -> bool {
+ use CjkPunctStyle::*;
+
+ // CJK quotation marks shares codepoints with latin quotation marks.
+ // But only the CJK ones have full width.
+ if matches!(c, '”' | '’') && x_advance + stretchability.1 == Em::one() {
+ return true;
+ }
+
+ if matches!(style, Gb | Jis) && matches!(c, ',' | '。' | '.' | '、' | ':' | ';')
+ {
+ return true;
+ }
+
+ if matches!(style, Gb) && matches!(c, '?' | '!') {
+ // In GB style, exclamations and question marks are also left aligned
+ // and can be adjusted. Note that they are not adjustable in other
+ // styles.
+ return true;
+ }
+
+ // See appendix A.3 https://www.w3.org/TR/clreq/#tables_of_chinese_punctuation_marks
+ matches!(c, '》' | ')' | '』' | '」' | '】' | '〗' | '〕' | '〉' | ']' | '}')
+}
+
+/// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment>
+fn is_cjk_right_aligned_punctuation(
+ c: char,
+ x_advance: Em,
+ stretchability: (Em, Em),
+) -> bool {
+ // CJK quotation marks shares codepoints with latin quotation marks.
+ // But only the CJK ones have full width.
+ if matches!(c, '“' | '‘') && x_advance + stretchability.0 == Em::one() {
+ return true;
+ }
+ // See appendix A.3 https://www.w3.org/TR/clreq/#tables_of_chinese_punctuation_marks
+ matches!(c, '《' | '(' | '『' | '「' | '【' | '〖' | '〔' | '〈' | '[' | '{')
+}
+
+/// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment>
+fn is_cjk_center_aligned_punctuation(c: char, style: CjkPunctStyle) -> bool {
+ if matches!(style, CjkPunctStyle::Cns)
+ && matches!(c, ',' | '。' | '.' | '、' | ':' | ';')
+ {
+ return true;
+ }
+
+ // U+30FB: Katakana Middle Dot
+ // U+00B7: Middle Dot
+ matches!(c, '\u{30FB}' | '\u{00B7}')
+}
+
+/// Whether the glyph is justifiable.
+///
+/// Quotations in latin script and CJK are unfortunately the same codepoint
+/// (U+2018, U+2019, U+201C, U+201D), but quotations in Chinese must be
+/// fullwidth. This heuristics can therefore fail for monospace latin fonts.
+/// However, since monospace fonts are usually not justified this edge case
+/// should be rare enough.
+fn is_justifiable(
+ c: char,
+ script: Script,
+ x_advance: Em,
+ stretchability: (Em, Em),
+) -> bool {
+ // punctuation style is not relevant here.
+ let style = CjkPunctStyle::Gb;
+ is_space(c)
+ || is_cj_script(c, script)
+ || is_cjk_left_aligned_punctuation(c, x_advance, stretchability, style)
+ || is_cjk_right_aligned_punctuation(c, x_advance, stretchability)
+ || is_cjk_center_aligned_punctuation(c, style)
+}
diff --git a/crates/typst-layout/src/lib.rs b/crates/typst-layout/src/lib.rs
new file mode 100644
index 00000000..7069fc4d
--- /dev/null
+++ b/crates/typst-layout/src/lib.rs
@@ -0,0 +1,30 @@
+//! Typst's layout engine.
+
+mod flow;
+mod grid;
+mod image;
+mod inline;
+mod lists;
+mod math;
+mod pad;
+mod pages;
+mod repeat;
+mod shapes;
+mod stack;
+mod transforms;
+
+pub use self::flow::{layout_columns, layout_fragment, layout_frame};
+pub use self::grid::{layout_grid, layout_table};
+pub use self::image::layout_image;
+pub use self::inline::{layout_box, layout_inline};
+pub use self::lists::{layout_enum, layout_list};
+pub use self::math::{layout_equation_block, layout_equation_inline};
+pub use self::pad::layout_pad;
+pub use self::pages::layout_document;
+pub use self::repeat::layout_repeat;
+pub use self::shapes::{
+ layout_circle, layout_ellipse, layout_line, layout_path, layout_polygon, layout_rect,
+ layout_square,
+};
+pub use self::stack::layout_stack;
+pub use self::transforms::{layout_move, layout_rotate, layout_scale, layout_skew};
diff --git a/crates/typst-layout/src/lists.rs b/crates/typst-layout/src/lists.rs
new file mode 100644
index 00000000..08c2a2f4
--- /dev/null
+++ b/crates/typst-layout/src/lists.rs
@@ -0,0 +1,146 @@
+use comemo::Track;
+use smallvec::smallvec;
+use typst_library::diag::SourceResult;
+use typst_library::engine::Engine;
+use typst_library::foundations::{Content, Context, Depth, Packed, StyleChain};
+use typst_library::introspection::Locator;
+use typst_library::layout::{Axes, Fragment, HAlignment, Regions, Sizing, VAlignment};
+use typst_library::model::{EnumElem, ListElem, Numbering, ParElem};
+use typst_library::text::TextElem;
+
+use crate::grid::{Cell, CellGrid, GridLayouter};
+
+/// Layout the list.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_list(
+ elem: &Packed<ListElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+) -> SourceResult<Fragment> {
+ let indent = elem.indent(styles);
+ let body_indent = elem.body_indent(styles);
+ let gutter = elem.spacing(styles).unwrap_or_else(|| {
+ if elem.tight(styles) {
+ ParElem::leading_in(styles).into()
+ } else {
+ ParElem::spacing_in(styles).into()
+ }
+ });
+
+ let Depth(depth) = ListElem::depth_in(styles);
+ let marker = elem
+ .marker(styles)
+ .resolve(engine, styles, depth)?
+ // avoid '#set align' interference with the list
+ .aligned(HAlignment::Start + VAlignment::Top);
+
+ let mut cells = vec![];
+ let mut locator = locator.split();
+
+ for item in elem.children() {
+ cells.push(Cell::new(Content::empty(), locator.next(&())));
+ cells.push(Cell::new(marker.clone(), locator.next(&marker.span())));
+ cells.push(Cell::new(Content::empty(), locator.next(&())));
+ cells.push(Cell::new(
+ item.body.clone().styled(ListElem::set_depth(Depth(1))),
+ locator.next(&item.body.span()),
+ ));
+ }
+
+ let grid = CellGrid::new(
+ Axes::with_x(&[
+ Sizing::Rel(indent.into()),
+ Sizing::Auto,
+ Sizing::Rel(body_indent.into()),
+ Sizing::Auto,
+ ]),
+ Axes::with_y(&[gutter.into()]),
+ cells,
+ );
+ let layouter = GridLayouter::new(&grid, regions, styles, elem.span());
+
+ layouter.layout(engine)
+}
+
+/// Layout the enumeration.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_enum(
+ elem: &Packed<EnumElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+) -> SourceResult<Fragment> {
+ let numbering = elem.numbering(styles);
+ let indent = elem.indent(styles);
+ let body_indent = elem.body_indent(styles);
+ let gutter = elem.spacing(styles).unwrap_or_else(|| {
+ if elem.tight(styles) {
+ ParElem::leading_in(styles).into()
+ } else {
+ ParElem::spacing_in(styles).into()
+ }
+ });
+
+ let mut cells = vec![];
+ let mut locator = locator.split();
+ let mut number = elem.start(styles);
+ let mut parents = EnumElem::parents_in(styles);
+
+ let full = elem.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 = elem.number_align(styles);
+
+ for item in elem.children() {
+ number = item.number(styles).unwrap_or(number);
+
+ let context = Context::new(None, Some(styles));
+ let resolved = if full {
+ parents.push(number);
+ let content = numbering.apply(engine, context.track(), &parents)?.display();
+ parents.pop();
+ content
+ } else {
+ match numbering {
+ Numbering::Pattern(pattern) => {
+ TextElem::packed(pattern.apply_kth(parents.len(), number))
+ }
+ other => other.apply(engine, context.track(), &[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(Cell::new(Content::empty(), locator.next(&())));
+ cells.push(Cell::new(resolved, locator.next(&())));
+ cells.push(Cell::new(Content::empty(), locator.next(&())));
+ cells.push(Cell::new(
+ item.body.clone().styled(EnumElem::set_parents(smallvec![number])),
+ locator.next(&item.body.span()),
+ ));
+ number = number.saturating_add(1);
+ }
+
+ let grid = CellGrid::new(
+ Axes::with_x(&[
+ Sizing::Rel(indent.into()),
+ Sizing::Auto,
+ Sizing::Rel(body_indent.into()),
+ Sizing::Auto,
+ ]),
+ Axes::with_y(&[gutter.into()]),
+ cells,
+ );
+ let layouter = GridLayouter::new(&grid, regions, styles, elem.span());
+
+ layouter.layout(engine)
+}
diff --git a/crates/typst-layout/src/math/accent.rs b/crates/typst-layout/src/math/accent.rs
new file mode 100644
index 00000000..9fa7a5a0
--- /dev/null
+++ b/crates/typst-layout/src/math/accent.rs
@@ -0,0 +1,75 @@
+use typst_library::diag::SourceResult;
+use typst_library::foundations::{Packed, StyleChain};
+use typst_library::layout::{Em, Frame, Point, Rel, Size};
+use typst_library::math::{Accent, AccentElem};
+
+use super::{
+ scaled_font_size, style_cramped, FrameFragment, GlyphFragment, MathContext,
+ MathFragment,
+};
+
+/// How much the accent can be shorter than the base.
+const ACCENT_SHORT_FALL: Em = Em::new(0.5);
+
+/// Lays out an [`AccentElem`].
+#[typst_macros::time(name = "math.accent", span = elem.span())]
+pub fn layout_accent(
+ elem: &Packed<AccentElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let cramped = style_cramped();
+ let base = ctx.layout_into_fragment(elem.base(), styles.chain(&cramped))?;
+
+ // Preserve class to preserve automatic spacing.
+ let base_class = base.class();
+ let base_attach = base.accent_attach();
+
+ let width = elem
+ .size(styles)
+ .unwrap_or(Rel::one())
+ .at(scaled_font_size(ctx, styles))
+ .relative_to(base.width());
+
+ // Forcing the accent to be at least as large as the base makes it too
+ // wide in many case.
+ let Accent(c) = elem.accent();
+ let glyph = GlyphFragment::new(ctx, styles, *c, elem.span());
+ let short_fall = ACCENT_SHORT_FALL.at(glyph.font_size);
+ let variant = glyph.stretch_horizontal(ctx, width, short_fall);
+ let accent = variant.frame;
+ let accent_attach = variant.accent_attach;
+
+ // Descent is negative because the accent's ink bottom is above the
+ // baseline. Therefore, the default gap is the accent's negated descent
+ // minus the accent base height. Only if the base is very small, we need
+ // a larger gap so that the accent doesn't move too low.
+ let accent_base_height = scaled!(ctx, styles, accent_base_height);
+ let gap = -accent.descent() - base.height().min(accent_base_height);
+ let size = Size::new(base.width(), accent.height() + gap + base.height());
+ let accent_pos = Point::with_x(base_attach - accent_attach);
+ let base_pos = Point::with_y(accent.height() + gap);
+ let baseline = base_pos.y + base.ascent();
+ let base_italics_correction = base.italics_correction();
+ let base_text_like = base.is_text_like();
+
+ let base_ascent = match &base {
+ MathFragment::Frame(frame) => frame.base_ascent,
+ _ => base.ascent(),
+ };
+
+ let mut frame = Frame::soft(size);
+ frame.set_baseline(baseline);
+ frame.push_frame(accent_pos, accent);
+ frame.push_frame(base_pos, base.into_frame());
+ ctx.push(
+ FrameFragment::new(ctx, styles, frame)
+ .with_class(base_class)
+ .with_base_ascent(base_ascent)
+ .with_italics_correction(base_italics_correction)
+ .with_accent_attach(base_attach)
+ .with_text_like(base_text_like),
+ );
+
+ Ok(())
+}
diff --git a/crates/typst-layout/src/math/attach.rs b/crates/typst-layout/src/math/attach.rs
new file mode 100644
index 00000000..0f9090f7
--- /dev/null
+++ b/crates/typst-layout/src/math/attach.rs
@@ -0,0 +1,515 @@
+use typst_library::diag::SourceResult;
+use typst_library::foundations::{Packed, Smart, StyleChain};
+use typst_library::layout::{Abs, Axis, Corner, Frame, Length, Point, Rel, Size};
+use typst_library::math::{
+ AttachElem, EquationElem, LimitsElem, PrimesElem, ScriptsElem, StretchElem,
+};
+use typst_library::text::TextElem;
+use typst_utils::OptionExt;
+
+use super::{
+ stretch_fragment, style_for_subscript, style_for_superscript, FrameFragment, Limits,
+ MathContext, MathFragment,
+};
+
+macro_rules! measure {
+ ($e: ident, $attr: ident) => {
+ $e.as_ref().map(|e| e.$attr()).unwrap_or_default()
+ };
+}
+
+/// Lays out an [`AttachElem`].
+#[typst_macros::time(name = "math.attach", span = elem.span())]
+pub fn layout_attach(
+ elem: &Packed<AttachElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let merged = elem.merge_base();
+ let elem = merged.as_ref().unwrap_or(elem);
+ let stretch = stretch_size(styles, elem);
+
+ let mut base = ctx.layout_into_fragment(elem.base(), styles)?;
+ let sup_style = style_for_superscript(styles);
+ let sup_style_chain = styles.chain(&sup_style);
+ let tl = elem.tl(sup_style_chain);
+ let tr = elem.tr(sup_style_chain);
+ let primed = tr.as_ref().is_some_and(|content| content.is::<PrimesElem>());
+ let t = elem.t(sup_style_chain);
+
+ let sub_style = style_for_subscript(styles);
+ let sub_style_chain = styles.chain(&sub_style);
+ let bl = elem.bl(sub_style_chain);
+ let br = elem.br(sub_style_chain);
+ let b = elem.b(sub_style_chain);
+
+ let limits = base.limits().active(styles);
+ let (t, tr) = match (t, tr) {
+ (Some(t), Some(tr)) if primed && !limits => (None, Some(tr + t)),
+ (Some(t), None) if !limits => (None, Some(t)),
+ (t, tr) => (t, tr),
+ };
+ let (b, br) = if limits || br.is_some() { (b, br) } else { (None, b) };
+
+ macro_rules! layout {
+ ($content:ident, $style_chain:ident) => {
+ $content
+ .map(|elem| ctx.layout_into_fragment(&elem, $style_chain))
+ .transpose()
+ };
+ }
+
+ // Layout the top and bottom attachments early so we can measure their
+ // widths, in order to calculate what the stretch size is relative to.
+ let t = layout!(t, sup_style_chain)?;
+ let b = layout!(b, sub_style_chain)?;
+ if let Some(stretch) = stretch {
+ let relative_to_width = measure!(t, width).max(measure!(b, width));
+ stretch_fragment(
+ ctx,
+ styles,
+ &mut base,
+ Some(Axis::X),
+ Some(relative_to_width),
+ stretch,
+ Abs::zero(),
+ );
+ }
+
+ let fragments = [
+ layout!(tl, sup_style_chain)?,
+ t,
+ layout!(tr, sup_style_chain)?,
+ layout!(bl, sub_style_chain)?,
+ b,
+ layout!(br, sub_style_chain)?,
+ ];
+
+ layout_attachments(ctx, styles, base, fragments)
+}
+
+/// Lays out a [`PrimeElem`].
+#[typst_macros::time(name = "math.primes", span = elem.span())]
+pub fn layout_primes(
+ elem: &Packed<PrimesElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ match *elem.count() {
+ count @ 1..=4 => {
+ let c = match count {
+ 1 => '′',
+ 2 => '″',
+ 3 => '‴',
+ 4 => '⁗',
+ _ => unreachable!(),
+ };
+ let f = ctx.layout_into_fragment(&TextElem::packed(c), styles)?;
+ ctx.push(f);
+ }
+ count => {
+ // Custom amount of primes
+ let prime =
+ ctx.layout_into_fragment(&TextElem::packed('′'), styles)?.into_frame();
+ let width = prime.width() * (count + 1) as f64 / 2.0;
+ let mut frame = Frame::soft(Size::new(width, prime.height()));
+ frame.set_baseline(prime.ascent());
+
+ for i in 0..count {
+ frame.push_frame(
+ Point::new(prime.width() * (i as f64 / 2.0), Abs::zero()),
+ prime.clone(),
+ )
+ }
+ ctx.push(FrameFragment::new(ctx, styles, frame).with_text_like(true));
+ }
+ }
+ Ok(())
+}
+
+/// Lays out a [`ScriptsElem`].
+#[typst_macros::time(name = "math.scripts", span = elem.span())]
+pub fn layout_scripts(
+ elem: &Packed<ScriptsElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let mut fragment = ctx.layout_into_fragment(elem.body(), styles)?;
+ fragment.set_limits(Limits::Never);
+ ctx.push(fragment);
+ Ok(())
+}
+
+/// Lays out a [`LimitsElem`].
+#[typst_macros::time(name = "math.limits", span = elem.span())]
+pub fn layout_limits(
+ elem: &Packed<LimitsElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let limits = if elem.inline(styles) { Limits::Always } else { Limits::Display };
+ let mut fragment = ctx.layout_into_fragment(elem.body(), styles)?;
+ fragment.set_limits(limits);
+ ctx.push(fragment);
+ Ok(())
+}
+
+/// Get the size to stretch the base to, if the attach argument is true.
+fn stretch_size(
+ styles: StyleChain,
+ elem: &Packed<AttachElem>,
+) -> Option<Smart<Rel<Length>>> {
+ // Extract from an EquationElem.
+ let mut base = elem.base();
+ if let Some(equation) = base.to_packed::<EquationElem>() {
+ base = equation.body();
+ }
+
+ base.to_packed::<StretchElem>().map(|stretch| stretch.size(styles))
+}
+
+/// Lay out the attachments.
+fn layout_attachments(
+ ctx: &mut MathContext,
+ styles: StyleChain,
+ base: MathFragment,
+ [tl, t, tr, bl, b, br]: [Option<MathFragment>; 6],
+) -> SourceResult<()> {
+ let base_class = base.class();
+
+ // Calculate the distance from the base's baseline to the superscripts' and
+ // subscripts' baseline.
+ let (tx_shift, bx_shift) = if [&tl, &tr, &bl, &br].iter().all(|e| e.is_none()) {
+ (Abs::zero(), Abs::zero())
+ } else {
+ compute_script_shifts(ctx, styles, &base, [&tl, &tr, &bl, &br])
+ };
+
+ // Calculate the distance from the base's baseline to the top attachment's
+ // and bottom attachment's baseline.
+ let (t_shift, b_shift) =
+ compute_limit_shifts(ctx, styles, &base, [t.as_ref(), b.as_ref()]);
+
+ // Calculate the final frame height.
+ let ascent = base
+ .ascent()
+ .max(tx_shift + measure!(tr, ascent))
+ .max(tx_shift + measure!(tl, ascent))
+ .max(t_shift + measure!(t, ascent));
+ let descent = base
+ .descent()
+ .max(bx_shift + measure!(br, descent))
+ .max(bx_shift + measure!(bl, descent))
+ .max(b_shift + measure!(b, descent));
+ let height = ascent + descent;
+
+ // Calculate the vertical position of each element in the final frame.
+ let base_y = ascent - base.ascent();
+ let tx_y = |tx: &MathFragment| ascent - tx_shift - tx.ascent();
+ let bx_y = |bx: &MathFragment| ascent + bx_shift - bx.ascent();
+ let t_y = |t: &MathFragment| ascent - t_shift - t.ascent();
+ let b_y = |b: &MathFragment| ascent + b_shift - b.ascent();
+
+ // Calculate the distance each limit extends to the left and right of the
+ // base's width.
+ let ((t_pre_width, t_post_width), (b_pre_width, b_post_width)) =
+ compute_limit_widths(&base, [t.as_ref(), b.as_ref()]);
+
+ // `space_after_script` is extra spacing that is at the start before each
+ // pre-script, and at the end after each post-script (see the MathConstants
+ // table in the OpenType MATH spec).
+ let space_after_script = scaled!(ctx, styles, space_after_script);
+
+ // Calculate the distance each pre-script extends to the left of the base's
+ // width.
+ let (tl_pre_width, bl_pre_width) = compute_pre_script_widths(
+ ctx,
+ &base,
+ [tl.as_ref(), bl.as_ref()],
+ (tx_shift, bx_shift),
+ space_after_script,
+ );
+
+ // Calculate the distance each post-script extends to the right of the
+ // base's width. Also calculate each post-script's kerning (we need this for
+ // its position later).
+ let ((tr_post_width, tr_kern), (br_post_width, br_kern)) = compute_post_script_widths(
+ ctx,
+ &base,
+ [tr.as_ref(), br.as_ref()],
+ (tx_shift, bx_shift),
+ space_after_script,
+ );
+
+ // Calculate the final frame width.
+ let pre_width = t_pre_width.max(b_pre_width).max(tl_pre_width).max(bl_pre_width);
+ let base_width = base.width();
+ let post_width = t_post_width.max(b_post_width).max(tr_post_width).max(br_post_width);
+ let width = pre_width + base_width + post_width;
+
+ // Calculate the horizontal position of each element in the final frame.
+ let base_x = pre_width;
+ let tl_x = pre_width - tl_pre_width + space_after_script;
+ let bl_x = pre_width - bl_pre_width + space_after_script;
+ let tr_x = pre_width + base_width + tr_kern;
+ let br_x = pre_width + base_width + br_kern;
+ let t_x = pre_width - t_pre_width;
+ let b_x = pre_width - b_pre_width;
+
+ // Create the final frame.
+ let mut frame = Frame::soft(Size::new(width, height));
+ frame.set_baseline(ascent);
+ frame.push_frame(Point::new(base_x, base_y), base.into_frame());
+
+ macro_rules! layout {
+ ($e: ident, $x: ident, $y: ident) => {
+ if let Some($e) = $e {
+ frame.push_frame(Point::new($x, $y(&$e)), $e.into_frame());
+ }
+ };
+ }
+
+ layout!(tl, tl_x, tx_y); // pre-superscript
+ layout!(bl, bl_x, bx_y); // pre-subscript
+ layout!(tr, tr_x, tx_y); // post-superscript
+ layout!(br, br_x, bx_y); // post-subscript
+ layout!(t, t_x, t_y); // upper-limit
+ layout!(b, b_x, b_y); // lower-limit
+
+ // Done! Note that we retain the class of the base.
+ ctx.push(FrameFragment::new(ctx, styles, frame).with_class(base_class));
+
+ Ok(())
+}
+
+/// Calculate the distance each post-script extends to the right of the base's
+/// width, as well as its kerning value. Requires the distance from the base's
+/// baseline to each post-script's baseline to obtain the correct kerning value.
+/// Returns 2 tuples of two lengths, each first containing the distance the
+/// post-script extends left of the base's width and second containing the
+/// post-script's kerning value. The first tuple is for the post-superscript,
+/// and the second is for the post-subscript.
+fn compute_post_script_widths(
+ ctx: &MathContext,
+ base: &MathFragment,
+ [tr, br]: [Option<&MathFragment>; 2],
+ (tr_shift, br_shift): (Abs, Abs),
+ space_after_post_script: Abs,
+) -> ((Abs, Abs), (Abs, Abs)) {
+ let tr_values = tr.map_or_default(|tr| {
+ let kern = math_kern(ctx, base, tr, tr_shift, Corner::TopRight);
+ (space_after_post_script + tr.width() + kern, kern)
+ });
+
+ // The base's bounding box already accounts for its italic correction, so we
+ // need to shift the post-subscript left by the base's italic correction
+ // (see the kerning algorithm as described in the OpenType MATH spec).
+ let br_values = br.map_or_default(|br| {
+ let kern = math_kern(ctx, base, br, br_shift, Corner::BottomRight)
+ - base.italics_correction();
+ (space_after_post_script + br.width() + kern, kern)
+ });
+
+ (tr_values, br_values)
+}
+
+/// Calculate the distance each pre-script extends to the left of the base's
+/// width. Requires the distance from the base's baseline to each pre-script's
+/// baseline to obtain the correct kerning value.
+/// Returns two lengths, the first being the distance the pre-superscript
+/// extends left of the base's width and the second being the distance the
+/// pre-subscript extends left of the base's width.
+fn compute_pre_script_widths(
+ ctx: &MathContext,
+ base: &MathFragment,
+ [tl, bl]: [Option<&MathFragment>; 2],
+ (tl_shift, bl_shift): (Abs, Abs),
+ space_before_pre_script: Abs,
+) -> (Abs, Abs) {
+ let tl_pre_width = tl.map_or_default(|tl| {
+ let kern = math_kern(ctx, base, tl, tl_shift, Corner::TopLeft);
+ space_before_pre_script + tl.width() + kern
+ });
+
+ let bl_pre_width = bl.map_or_default(|bl| {
+ let kern = math_kern(ctx, base, bl, bl_shift, Corner::BottomLeft);
+ space_before_pre_script + bl.width() + kern
+ });
+
+ (tl_pre_width, bl_pre_width)
+}
+
+/// Calculate the distance each limit extends beyond the base's width, in each
+/// direction. Can be a negative value if the limit does not extend beyond the
+/// base's width, indicating how far into the base's width the limit extends.
+/// Returns 2 tuples of two lengths, each first containing the distance the
+/// limit extends leftward beyond the base's width and second containing the
+/// distance the limit extends rightward beyond the base's width. The first
+/// tuple is for the upper-limit, and the second is for the lower-limit.
+fn compute_limit_widths(
+ base: &MathFragment,
+ [t, b]: [Option<&MathFragment>; 2],
+) -> ((Abs, Abs), (Abs, Abs)) {
+ // The upper- (lower-) limit is shifted to the right (left) of the base's
+ // center by half the base's italic correction.
+ let delta = base.italics_correction() / 2.0;
+
+ let t_widths = t.map_or_default(|t| {
+ let half = (t.width() - base.width()) / 2.0;
+ (half - delta, half + delta)
+ });
+
+ let b_widths = b.map_or_default(|b| {
+ let half = (b.width() - base.width()) / 2.0;
+ (half + delta, half - delta)
+ });
+
+ (t_widths, b_widths)
+}
+
+/// Calculate the distance from the base's baseline to each limit's baseline.
+/// Returns two lengths, the first being the distance to the upper-limit's
+/// baseline and the second being the distance to the lower-limit's baseline.
+fn compute_limit_shifts(
+ ctx: &MathContext,
+ styles: StyleChain,
+ base: &MathFragment,
+ [t, b]: [Option<&MathFragment>; 2],
+) -> (Abs, Abs) {
+ // `upper_gap_min` and `lower_gap_min` give gaps to the descender and
+ // ascender of the limits respectively, whereas `upper_rise_min` and
+ // `lower_drop_min` give gaps to each limit's baseline (see the
+ // MathConstants table in the OpenType MATH spec).
+
+ let t_shift = t.map_or_default(|t| {
+ let upper_gap_min = scaled!(ctx, styles, upper_limit_gap_min);
+ let upper_rise_min = scaled!(ctx, styles, upper_limit_baseline_rise_min);
+ base.ascent() + upper_rise_min.max(upper_gap_min + t.descent())
+ });
+
+ let b_shift = b.map_or_default(|b| {
+ let lower_gap_min = scaled!(ctx, styles, lower_limit_gap_min);
+ let lower_drop_min = scaled!(ctx, styles, lower_limit_baseline_drop_min);
+ base.descent() + lower_drop_min.max(lower_gap_min + b.ascent())
+ });
+
+ (t_shift, b_shift)
+}
+
+/// Calculate the distance from the base's baseline to each script's baseline.
+/// Returns two lengths, the first being the distance to the superscripts'
+/// baseline and the second being the distance to the subscripts' baseline.
+fn compute_script_shifts(
+ ctx: &MathContext,
+ styles: StyleChain,
+ base: &MathFragment,
+ [tl, tr, bl, br]: [&Option<MathFragment>; 4],
+) -> (Abs, Abs) {
+ let sup_shift_up = if EquationElem::cramped_in(styles) {
+ scaled!(ctx, styles, superscript_shift_up_cramped)
+ } else {
+ scaled!(ctx, styles, superscript_shift_up)
+ };
+
+ let sup_bottom_min = scaled!(ctx, styles, superscript_bottom_min);
+ let sup_bottom_max_with_sub =
+ scaled!(ctx, styles, superscript_bottom_max_with_subscript);
+ let sup_drop_max = scaled!(ctx, styles, superscript_baseline_drop_max);
+ let gap_min = scaled!(ctx, styles, sub_superscript_gap_min);
+ let sub_shift_down = scaled!(ctx, styles, subscript_shift_down);
+ let sub_top_max = scaled!(ctx, styles, subscript_top_max);
+ let sub_drop_min = scaled!(ctx, styles, subscript_baseline_drop_min);
+
+ let mut shift_up = Abs::zero();
+ let mut shift_down = Abs::zero();
+ let is_text_like = base.is_text_like();
+
+ if tl.is_some() || tr.is_some() {
+ let ascent = match &base {
+ MathFragment::Frame(frame) => frame.base_ascent,
+ _ => base.ascent(),
+ };
+ shift_up = shift_up
+ .max(sup_shift_up)
+ .max(if is_text_like { Abs::zero() } else { ascent - sup_drop_max })
+ .max(sup_bottom_min + measure!(tl, descent))
+ .max(sup_bottom_min + measure!(tr, descent));
+ }
+
+ if bl.is_some() || br.is_some() {
+ shift_down = shift_down
+ .max(sub_shift_down)
+ .max(if is_text_like { Abs::zero() } else { base.descent() + sub_drop_min })
+ .max(measure!(bl, ascent) - sub_top_max)
+ .max(measure!(br, ascent) - sub_top_max);
+ }
+
+ for (sup, sub) in [(tl, bl), (tr, br)] {
+ if let (Some(sup), Some(sub)) = (&sup, &sub) {
+ let sup_bottom = shift_up - sup.descent();
+ let sub_top = sub.ascent() - shift_down;
+ let gap = sup_bottom - sub_top;
+ if gap >= gap_min {
+ continue;
+ }
+
+ let increase = gap_min - gap;
+ let sup_only =
+ (sup_bottom_max_with_sub - sup_bottom).clamp(Abs::zero(), increase);
+ let rest = (increase - sup_only) / 2.0;
+ shift_up += sup_only + rest;
+ shift_down += rest;
+ }
+ }
+
+ (shift_up, shift_down)
+}
+
+/// Calculate the kerning value for a script with respect to the base. A
+/// positive value means shifting the script further away from the base, whereas
+/// a negative value means shifting the script closer to the base. Requires the
+/// distance from the base's baseline to the script's baseline, as well as the
+/// script's corner (tl, tr, bl, br).
+fn math_kern(
+ ctx: &MathContext,
+ base: &MathFragment,
+ script: &MathFragment,
+ shift: Abs,
+ pos: Corner,
+) -> Abs {
+ // This process is described under the MathKernInfo table in the OpenType
+ // MATH spec.
+
+ let (corr_height_top, corr_height_bot) = match pos {
+ // Calculate two correction heights for superscripts:
+ // - The distance from the superscript's baseline to the top of the
+ // base's bounding box.
+ // - The distance from the base's baseline to the bottom of the
+ // superscript's bounding box.
+ Corner::TopLeft | Corner::TopRight => {
+ (base.ascent() - shift, shift - script.descent())
+ }
+ // Calculate two correction heights for subscripts:
+ // - The distance from the base's baseline to the top of the
+ // subscript's bounding box.
+ // - The distance from the subscript's baseline to the bottom of the
+ // base's bounding box.
+ Corner::BottomLeft | Corner::BottomRight => {
+ (script.ascent() - shift, shift - base.descent())
+ }
+ };
+
+ // Calculate the sum of kerning values for each correction height.
+ let summed_kern = |height| {
+ let base_kern = base.kern_at_height(ctx, pos, height);
+ let attach_kern = script.kern_at_height(ctx, pos.inv(), height);
+ base_kern + attach_kern
+ };
+
+ // Take the smaller kerning amount (and so the larger value). Note that
+ // there is a bug in the spec (as of 2024-08-15): it says to take the
+ // minimum of the two sums, but as the kerning value is usually negative it
+ // really means the smaller kern. The current wording of the spec could
+ // result in glyphs colliding.
+ summed_kern(corr_height_top).max(summed_kern(corr_height_bot))
+}
diff --git a/crates/typst-layout/src/math/cancel.rs b/crates/typst-layout/src/math/cancel.rs
new file mode 100644
index 00000000..994e0e2f
--- /dev/null
+++ b/crates/typst-layout/src/math/cancel.rs
@@ -0,0 +1,144 @@
+use comemo::Track;
+use typst_library::diag::{At, SourceResult};
+use typst_library::foundations::{Context, Packed, Smart, StyleChain};
+use typst_library::layout::{Abs, Angle, Frame, FrameItem, Point, Rel, Size, Transform};
+use typst_library::math::{CancelAngle, CancelElem};
+use typst_library::text::TextElem;
+use typst_library::visualize::{FixedStroke, Geometry};
+use typst_syntax::Span;
+
+use super::{scaled_font_size, FrameFragment, MathContext};
+
+/// Lays out a [`CancelElem`].
+#[typst_macros::time(name = "math.cancel", span = elem.span())]
+pub fn layout_cancel(
+ elem: &Packed<CancelElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let body = ctx.layout_into_fragment(elem.body(), styles)?;
+
+ // Preserve properties of body.
+ let body_class = body.class();
+ let body_italics = body.italics_correction();
+ let body_attach = body.accent_attach();
+ let body_text_like = body.is_text_like();
+
+ let mut body = body.into_frame();
+ let body_size = body.size();
+ let span = elem.span();
+ let length = elem.length(styles).at(scaled_font_size(ctx, styles));
+
+ let stroke = elem.stroke(styles).unwrap_or(FixedStroke {
+ paint: TextElem::fill_in(styles).as_decoration(),
+ ..Default::default()
+ });
+
+ let invert = elem.inverted(styles);
+ let cross = elem.cross(styles);
+ let angle = elem.angle(styles);
+
+ let invert_first_line = !cross && invert;
+ let first_line = draw_cancel_line(
+ ctx,
+ length,
+ stroke.clone(),
+ invert_first_line,
+ &angle,
+ body_size,
+ styles,
+ span,
+ )?;
+
+ // The origin of our line is the very middle of the element.
+ let center = body_size.to_point() / 2.0;
+ body.push_frame(center, first_line);
+
+ if cross {
+ // Draw the second line.
+ let second_line =
+ draw_cancel_line(ctx, length, stroke, true, &angle, body_size, styles, span)?;
+
+ body.push_frame(center, second_line);
+ }
+
+ ctx.push(
+ FrameFragment::new(ctx, styles, body)
+ .with_class(body_class)
+ .with_italics_correction(body_italics)
+ .with_accent_attach(body_attach)
+ .with_text_like(body_text_like),
+ );
+
+ Ok(())
+}
+
+/// Draws a cancel line.
+#[allow(clippy::too_many_arguments)]
+fn draw_cancel_line(
+ ctx: &mut MathContext,
+ length_scale: Rel<Abs>,
+ stroke: FixedStroke,
+ invert: bool,
+ angle: &Smart<CancelAngle>,
+ body_size: Size,
+ styles: StyleChain,
+ span: Span,
+) -> SourceResult<Frame> {
+ let default = default_angle(body_size);
+ let mut angle = match angle {
+ // Non specified angle defaults to the diagonal
+ Smart::Auto => default,
+ Smart::Custom(angle) => match angle {
+ // This specifies the absolute angle w.r.t y-axis clockwise.
+ CancelAngle::Angle(v) => *v,
+ // This specifies a function that takes the default angle as input.
+ CancelAngle::Func(func) => func
+ .call(ctx.engine, Context::new(None, Some(styles)).track(), [default])?
+ .cast()
+ .at(span)?,
+ },
+ };
+
+ // invert means flipping along the y-axis
+ if invert {
+ angle *= -1.0;
+ }
+
+ // same as above, the default length is the diagonal of the body box.
+ let default_length = body_size.to_point().hypot();
+ let length = length_scale.relative_to(default_length);
+
+ // Draw a vertical line of length and rotate it by angle
+ let start = Point::new(Abs::zero(), length / 2.0);
+ let delta = Point::new(Abs::zero(), -length);
+
+ let mut frame = Frame::soft(body_size);
+ frame.push(start, FrameItem::Shape(Geometry::Line(delta).stroked(stroke), span));
+
+ // Having the middle of the line at the origin is convenient here.
+ frame.transform(Transform::rotate(angle));
+ Ok(frame)
+}
+
+/// The default line angle for a body of the given size.
+fn default_angle(body: Size) -> Angle {
+ // The default cancel line is the diagonal.
+ // We infer the default angle from
+ // the diagonal w.r.t to the body box.
+ //
+ // The returned angle is in the range of [0, Pi/2]
+ //
+ // Note that the angle is computed w.r.t to the y-axis
+ //
+ // B
+ // /|
+ // diagonal / | height
+ // / |
+ // / |
+ // O ----
+ // width
+ let (width, height) = (body.x, body.y);
+ let default_angle = (width / height).atan(); // arctangent (in the range [0, Pi/2])
+ Angle::rad(default_angle)
+}
diff --git a/crates/typst-layout/src/math/frac.rs b/crates/typst-layout/src/math/frac.rs
new file mode 100644
index 00000000..50686333
--- /dev/null
+++ b/crates/typst-layout/src/math/frac.rs
@@ -0,0 +1,136 @@
+use typst_library::diag::SourceResult;
+use typst_library::foundations::{Content, Packed, StyleChain};
+use typst_library::layout::{Em, Frame, FrameItem, Point, Size};
+use typst_library::math::{BinomElem, FracElem};
+use typst_library::text::TextElem;
+use typst_library::visualize::{FixedStroke, Geometry};
+use typst_syntax::Span;
+
+use super::{
+ scaled_font_size, style_for_denominator, style_for_numerator, FrameFragment,
+ GlyphFragment, MathContext, DELIM_SHORT_FALL,
+};
+
+const FRAC_AROUND: Em = Em::new(0.1);
+
+/// Lays out a [`FracElem`].
+#[typst_macros::time(name = "math.frac", span = elem.span())]
+pub fn layout_frac(
+ elem: &Packed<FracElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ layout_frac_like(
+ ctx,
+ styles,
+ elem.num(),
+ std::slice::from_ref(elem.denom()),
+ false,
+ elem.span(),
+ )
+}
+
+/// Lays out a [`BinomElem`].
+#[typst_macros::time(name = "math.binom", span = elem.span())]
+pub fn layout_binom(
+ elem: &Packed<BinomElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ layout_frac_like(ctx, styles, elem.upper(), elem.lower(), true, elem.span())
+}
+
+/// Layout a fraction or binomial.
+fn layout_frac_like(
+ ctx: &mut MathContext,
+ styles: StyleChain,
+ num: &Content,
+ denom: &[Content],
+ binom: bool,
+ span: Span,
+) -> SourceResult<()> {
+ let font_size = scaled_font_size(ctx, styles);
+ let short_fall = DELIM_SHORT_FALL.at(font_size);
+ let axis = scaled!(ctx, styles, axis_height);
+ let thickness = scaled!(ctx, styles, fraction_rule_thickness);
+ let shift_up = scaled!(
+ ctx, styles,
+ text: fraction_numerator_shift_up,
+ display: fraction_numerator_display_style_shift_up,
+ );
+ let shift_down = scaled!(
+ ctx, styles,
+ text: fraction_denominator_shift_down,
+ display: fraction_denominator_display_style_shift_down,
+ );
+ let num_min = scaled!(
+ ctx, styles,
+ text: fraction_numerator_gap_min,
+ display: fraction_num_display_style_gap_min,
+ );
+ let denom_min = scaled!(
+ ctx, styles,
+ text: fraction_denominator_gap_min,
+ display: fraction_denom_display_style_gap_min,
+ );
+
+ let num_style = style_for_numerator(styles);
+ let num = ctx.layout_into_frame(num, styles.chain(&num_style))?;
+
+ let denom_style = style_for_denominator(styles);
+ let denom = ctx.layout_into_frame(
+ &Content::sequence(
+ // Add a comma between each element.
+ denom.iter().flat_map(|a| [TextElem::packed(','), a.clone()]).skip(1),
+ ),
+ styles.chain(&denom_style),
+ )?;
+
+ let around = FRAC_AROUND.at(font_size);
+ let num_gap = (shift_up - (axis + thickness / 2.0) - num.descent()).max(num_min);
+ let denom_gap =
+ (shift_down + (axis - thickness / 2.0) - denom.ascent()).max(denom_min);
+
+ let line_width = num.width().max(denom.width());
+ let width = line_width + 2.0 * around;
+ let height = num.height() + num_gap + thickness + denom_gap + denom.height();
+ let size = Size::new(width, height);
+ let num_pos = Point::with_x((width - num.width()) / 2.0);
+ let line_pos =
+ Point::new((width - line_width) / 2.0, num.height() + num_gap + thickness / 2.0);
+ let denom_pos = Point::new((width - denom.width()) / 2.0, height - denom.height());
+ let baseline = line_pos.y + axis;
+
+ let mut frame = Frame::soft(size);
+ frame.set_baseline(baseline);
+ frame.push_frame(num_pos, num);
+ frame.push_frame(denom_pos, denom);
+
+ if binom {
+ let mut left = GlyphFragment::new(ctx, styles, '(', span)
+ .stretch_vertical(ctx, height, short_fall);
+ left.center_on_axis(ctx);
+ ctx.push(left);
+ ctx.push(FrameFragment::new(ctx, styles, frame));
+ let mut right = GlyphFragment::new(ctx, styles, ')', span)
+ .stretch_vertical(ctx, height, short_fall);
+ right.center_on_axis(ctx);
+ ctx.push(right);
+ } else {
+ frame.push(
+ line_pos,
+ FrameItem::Shape(
+ Geometry::Line(Point::with_x(line_width)).stroked(
+ FixedStroke::from_pair(
+ TextElem::fill_in(styles).as_decoration(),
+ thickness,
+ ),
+ ),
+ span,
+ ),
+ );
+ ctx.push(FrameFragment::new(ctx, styles, frame));
+ }
+
+ Ok(())
+}
diff --git a/crates/typst-layout/src/math/fragment.rs b/crates/typst-layout/src/math/fragment.rs
new file mode 100644
index 00000000..19a4494e
--- /dev/null
+++ b/crates/typst-layout/src/math/fragment.rs
@@ -0,0 +1,704 @@
+use std::fmt::{self, Debug, Formatter};
+
+use rustybuzz::Feature;
+use smallvec::SmallVec;
+use ttf_parser::gsub::{
+ AlternateSet, AlternateSubstitution, SingleSubstitution, SubstitutionSubtable,
+};
+use ttf_parser::opentype_layout::LayoutTable;
+use ttf_parser::{GlyphId, Rect};
+use typst_library::foundations::StyleChain;
+use typst_library::introspection::Tag;
+use typst_library::layout::{
+ Abs, Axis, Corner, Em, Frame, FrameItem, HideElem, Point, Size, VAlignment,
+};
+use typst_library::math::{EquationElem, MathSize};
+use typst_library::model::{Destination, LinkElem};
+use typst_library::text::{Font, Glyph, Lang, Region, TextElem, TextItem};
+use typst_library::visualize::Paint;
+use typst_syntax::Span;
+use unicode_math_class::MathClass;
+
+use super::{scaled_font_size, stretch_glyph, MathContext, Scaled};
+
+#[derive(Debug, Clone)]
+pub enum MathFragment {
+ Glyph(GlyphFragment),
+ Variant(VariantFragment),
+ Frame(FrameFragment),
+ Spacing(Abs, bool),
+ Space(Abs),
+ Linebreak,
+ Align,
+ Tag(Tag),
+}
+
+impl MathFragment {
+ pub fn size(&self) -> Size {
+ Size::new(self.width(), self.height())
+ }
+
+ pub fn width(&self) -> Abs {
+ match self {
+ Self::Glyph(glyph) => glyph.width,
+ Self::Variant(variant) => variant.frame.width(),
+ Self::Frame(fragment) => fragment.frame.width(),
+ Self::Spacing(amount, _) => *amount,
+ Self::Space(amount) => *amount,
+ _ => Abs::zero(),
+ }
+ }
+
+ pub fn height(&self) -> Abs {
+ match self {
+ Self::Glyph(glyph) => glyph.height(),
+ Self::Variant(variant) => variant.frame.height(),
+ Self::Frame(fragment) => fragment.frame.height(),
+ _ => Abs::zero(),
+ }
+ }
+
+ pub fn ascent(&self) -> Abs {
+ match self {
+ Self::Glyph(glyph) => glyph.ascent,
+ Self::Variant(variant) => variant.frame.ascent(),
+ Self::Frame(fragment) => fragment.frame.baseline(),
+ _ => Abs::zero(),
+ }
+ }
+
+ pub fn descent(&self) -> Abs {
+ match self {
+ Self::Glyph(glyph) => glyph.descent,
+ Self::Variant(variant) => variant.frame.descent(),
+ Self::Frame(fragment) => fragment.frame.descent(),
+ _ => Abs::zero(),
+ }
+ }
+
+ pub fn is_ignorant(&self) -> bool {
+ match self {
+ Self::Frame(fragment) => fragment.ignorant,
+ Self::Tag(_) => true,
+ _ => false,
+ }
+ }
+
+ pub fn class(&self) -> MathClass {
+ match self {
+ Self::Glyph(glyph) => glyph.class,
+ Self::Variant(variant) => variant.class,
+ Self::Frame(fragment) => fragment.class,
+ Self::Spacing(_, _) => MathClass::Space,
+ Self::Space(_) => MathClass::Space,
+ Self::Linebreak => MathClass::Space,
+ Self::Align => MathClass::Special,
+ Self::Tag(_) => MathClass::Special,
+ }
+ }
+
+ pub fn math_size(&self) -> Option<MathSize> {
+ match self {
+ Self::Glyph(glyph) => Some(glyph.math_size),
+ Self::Variant(variant) => Some(variant.math_size),
+ Self::Frame(fragment) => Some(fragment.math_size),
+ _ => None,
+ }
+ }
+
+ pub fn font_size(&self) -> Option<Abs> {
+ match self {
+ Self::Glyph(glyph) => Some(glyph.font_size),
+ Self::Variant(variant) => Some(variant.font_size),
+ Self::Frame(fragment) => Some(fragment.font_size),
+ _ => None,
+ }
+ }
+
+ pub fn set_class(&mut self, class: MathClass) {
+ match self {
+ Self::Glyph(glyph) => glyph.class = class,
+ Self::Variant(variant) => variant.class = class,
+ Self::Frame(fragment) => fragment.class = class,
+ _ => {}
+ }
+ }
+
+ pub fn set_limits(&mut self, limits: Limits) {
+ match self {
+ Self::Glyph(glyph) => glyph.limits = limits,
+ Self::Variant(variant) => variant.limits = limits,
+ Self::Frame(fragment) => fragment.limits = limits,
+ _ => {}
+ }
+ }
+
+ pub fn is_spaced(&self) -> bool {
+ if self.class() == MathClass::Fence {
+ return true;
+ }
+
+ matches!(
+ self,
+ MathFragment::Frame(FrameFragment {
+ spaced: true,
+ class: MathClass::Normal | MathClass::Alphabetic,
+ ..
+ })
+ )
+ }
+
+ pub fn is_text_like(&self) -> bool {
+ match self {
+ Self::Glyph(_) | Self::Variant(_) => self.class() != MathClass::Large,
+ MathFragment::Frame(frame) => frame.text_like,
+ _ => false,
+ }
+ }
+
+ pub fn italics_correction(&self) -> Abs {
+ match self {
+ Self::Glyph(glyph) => glyph.italics_correction,
+ Self::Variant(variant) => variant.italics_correction,
+ Self::Frame(fragment) => fragment.italics_correction,
+ _ => Abs::zero(),
+ }
+ }
+
+ pub fn accent_attach(&self) -> Abs {
+ match self {
+ Self::Glyph(glyph) => glyph.accent_attach,
+ Self::Variant(variant) => variant.accent_attach,
+ Self::Frame(fragment) => fragment.accent_attach,
+ _ => self.width() / 2.0,
+ }
+ }
+
+ pub fn into_frame(self) -> Frame {
+ match self {
+ Self::Glyph(glyph) => glyph.into_frame(),
+ Self::Variant(variant) => variant.frame,
+ Self::Frame(fragment) => fragment.frame,
+ Self::Tag(tag) => {
+ let mut frame = Frame::soft(Size::zero());
+ frame.push(Point::zero(), FrameItem::Tag(tag));
+ frame
+ }
+ _ => Frame::soft(self.size()),
+ }
+ }
+
+ pub fn limits(&self) -> Limits {
+ match self {
+ MathFragment::Glyph(glyph) => glyph.limits,
+ MathFragment::Variant(variant) => variant.limits,
+ MathFragment::Frame(fragment) => fragment.limits,
+ _ => Limits::Never,
+ }
+ }
+
+ /// If no kern table is provided for a corner, a kerning amount of zero is
+ /// assumed.
+ pub fn kern_at_height(&self, ctx: &MathContext, corner: Corner, height: Abs) -> Abs {
+ match self {
+ Self::Glyph(glyph) => {
+ kern_at_height(ctx, glyph.font_size, glyph.id, corner, height)
+ .unwrap_or_default()
+ }
+ _ => Abs::zero(),
+ }
+ }
+}
+
+impl From<GlyphFragment> for MathFragment {
+ fn from(glyph: GlyphFragment) -> Self {
+ Self::Glyph(glyph)
+ }
+}
+
+impl From<VariantFragment> for MathFragment {
+ fn from(variant: VariantFragment) -> Self {
+ Self::Variant(variant)
+ }
+}
+
+impl From<FrameFragment> for MathFragment {
+ fn from(fragment: FrameFragment) -> Self {
+ Self::Frame(fragment)
+ }
+}
+
+#[derive(Clone)]
+pub struct GlyphFragment {
+ pub id: GlyphId,
+ pub c: char,
+ pub font: Font,
+ pub lang: Lang,
+ pub region: Option<Region>,
+ pub fill: Paint,
+ pub shift: Abs,
+ pub width: Abs,
+ pub ascent: Abs,
+ pub descent: Abs,
+ pub italics_correction: Abs,
+ pub accent_attach: Abs,
+ pub font_size: Abs,
+ pub class: MathClass,
+ pub math_size: MathSize,
+ pub span: Span,
+ pub dests: SmallVec<[Destination; 1]>,
+ pub hidden: bool,
+ pub limits: Limits,
+}
+
+impl GlyphFragment {
+ pub fn new(ctx: &MathContext, styles: StyleChain, c: char, span: Span) -> Self {
+ let id = ctx.ttf.glyph_index(c).unwrap_or_default();
+ let id = Self::adjust_glyph_index(ctx, id);
+ Self::with_id(ctx, styles, c, id, span)
+ }
+
+ pub fn try_new(
+ ctx: &MathContext,
+ styles: StyleChain,
+ c: char,
+ span: Span,
+ ) -> Option<Self> {
+ let id = ctx.ttf.glyph_index(c)?;
+ let id = Self::adjust_glyph_index(ctx, id);
+ Some(Self::with_id(ctx, styles, c, id, span))
+ }
+
+ pub fn with_id(
+ ctx: &MathContext,
+ styles: StyleChain,
+ c: char,
+ id: GlyphId,
+ span: Span,
+ ) -> Self {
+ let class = EquationElem::class_in(styles)
+ .or_else(|| match c {
+ ':' => Some(MathClass::Relation),
+ '.' | '/' | '⋯' | '⋱' | '⋰' | '⋮' => Some(MathClass::Normal),
+ _ => unicode_math_class::class(c),
+ })
+ .unwrap_or(MathClass::Normal);
+
+ let mut fragment = Self {
+ id,
+ c,
+ font: ctx.font.clone(),
+ lang: TextElem::lang_in(styles),
+ region: TextElem::region_in(styles),
+ fill: TextElem::fill_in(styles).as_decoration(),
+ shift: TextElem::baseline_in(styles),
+ font_size: scaled_font_size(ctx, styles),
+ math_size: EquationElem::size_in(styles),
+ width: Abs::zero(),
+ ascent: Abs::zero(),
+ descent: Abs::zero(),
+ limits: Limits::for_char(c),
+ italics_correction: Abs::zero(),
+ accent_attach: Abs::zero(),
+ class,
+ span,
+ dests: LinkElem::dests_in(styles),
+ hidden: HideElem::hidden_in(styles),
+ };
+ fragment.set_id(ctx, id);
+ fragment
+ }
+
+ /// Apply GSUB substitutions.
+ fn adjust_glyph_index(ctx: &MathContext, id: GlyphId) -> GlyphId {
+ if let Some(glyphwise_tables) = &ctx.glyphwise_tables {
+ glyphwise_tables.iter().fold(id, |id, table| table.apply(id))
+ } else {
+ id
+ }
+ }
+
+ /// Sets element id and boxes in appropriate way without changing other
+ /// styles. This is used to replace the glyph with a stretch variant.
+ pub fn set_id(&mut self, ctx: &MathContext, id: GlyphId) {
+ let advance = ctx.ttf.glyph_hor_advance(id).unwrap_or_default();
+ let italics = italics_correction(ctx, id, self.font_size).unwrap_or_default();
+ let bbox = ctx.ttf.glyph_bounding_box(id).unwrap_or(Rect {
+ x_min: 0,
+ y_min: 0,
+ x_max: 0,
+ y_max: 0,
+ });
+
+ let mut width = advance.scaled(ctx, self.font_size);
+ let accent_attach =
+ accent_attach(ctx, id, self.font_size).unwrap_or((width + italics) / 2.0);
+
+ if !is_extended_shape(ctx, id) {
+ width += italics;
+ }
+
+ self.id = id;
+ self.width = width;
+ self.ascent = bbox.y_max.scaled(ctx, self.font_size);
+ self.descent = -bbox.y_min.scaled(ctx, self.font_size);
+ self.italics_correction = italics;
+ self.accent_attach = accent_attach;
+ }
+
+ pub fn height(&self) -> Abs {
+ self.ascent + self.descent
+ }
+
+ pub fn into_variant(self) -> VariantFragment {
+ VariantFragment {
+ c: self.c,
+ font_size: self.font_size,
+ italics_correction: self.italics_correction,
+ accent_attach: self.accent_attach,
+ class: self.class,
+ math_size: self.math_size,
+ span: self.span,
+ limits: self.limits,
+ frame: self.into_frame(),
+ mid_stretched: None,
+ }
+ }
+
+ pub fn into_frame(self) -> Frame {
+ let item = TextItem {
+ font: self.font.clone(),
+ size: self.font_size,
+ fill: self.fill,
+ lang: self.lang,
+ region: self.region,
+ text: self.c.into(),
+ stroke: None,
+ glyphs: vec![Glyph {
+ id: self.id.0,
+ x_advance: Em::from_length(self.width, self.font_size),
+ x_offset: Em::zero(),
+ range: 0..self.c.len_utf8() as u16,
+ span: (self.span, 0),
+ }],
+ };
+ let size = Size::new(self.width, self.ascent + self.descent);
+ let mut frame = Frame::soft(size);
+ frame.set_baseline(self.ascent);
+ frame.push(Point::with_y(self.ascent + self.shift), FrameItem::Text(item));
+ frame.post_process_raw(self.dests, self.hidden);
+ frame
+ }
+
+ pub fn make_scriptsize(&mut self, ctx: &MathContext) {
+ let alt_id =
+ script_alternatives(ctx, self.id).and_then(|alts| alts.alternates.get(0));
+
+ if let Some(alt_id) = alt_id {
+ self.set_id(ctx, alt_id);
+ }
+ }
+
+ pub fn make_scriptscriptsize(&mut self, ctx: &MathContext) {
+ let alts = script_alternatives(ctx, self.id);
+ let alt_id = alts
+ .and_then(|alts| alts.alternates.get(1).or_else(|| alts.alternates.get(0)));
+
+ if let Some(alt_id) = alt_id {
+ self.set_id(ctx, alt_id);
+ }
+ }
+
+ /// Try to stretch a glyph to a desired height.
+ pub fn stretch_vertical(
+ self,
+ ctx: &MathContext,
+ height: Abs,
+ short_fall: Abs,
+ ) -> VariantFragment {
+ stretch_glyph(ctx, self, height, short_fall, Axis::Y)
+ }
+
+ /// Try to stretch a glyph to a desired width.
+ pub fn stretch_horizontal(
+ self,
+ ctx: &MathContext,
+ width: Abs,
+ short_fall: Abs,
+ ) -> VariantFragment {
+ stretch_glyph(ctx, self, width, short_fall, Axis::X)
+ }
+}
+
+impl Debug for GlyphFragment {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ write!(f, "GlyphFragment({:?})", self.c)
+ }
+}
+
+#[derive(Clone)]
+pub struct VariantFragment {
+ pub c: char,
+ pub italics_correction: Abs,
+ pub accent_attach: Abs,
+ pub frame: Frame,
+ pub font_size: Abs,
+ pub class: MathClass,
+ pub math_size: MathSize,
+ pub span: Span,
+ pub limits: Limits,
+ pub mid_stretched: Option<bool>,
+}
+
+impl VariantFragment {
+ /// Vertically adjust the fragment's frame so that it is centered
+ /// on the axis.
+ pub fn center_on_axis(&mut self, ctx: &MathContext) {
+ self.align_on_axis(ctx, VAlignment::Horizon)
+ }
+
+ /// Vertically adjust the fragment's frame so that it is aligned
+ /// to the given alignment on the axis.
+ pub fn align_on_axis(&mut self, ctx: &MathContext, align: VAlignment) {
+ let h = self.frame.height();
+ let axis = ctx.constants.axis_height().scaled(ctx, self.font_size);
+ self.frame.set_baseline(align.inv().position(h + axis * 2.0));
+ }
+}
+
+impl Debug for VariantFragment {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ write!(f, "VariantFragment({:?})", self.c)
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct FrameFragment {
+ pub frame: Frame,
+ pub font_size: Abs,
+ pub class: MathClass,
+ pub math_size: MathSize,
+ pub limits: Limits,
+ pub spaced: bool,
+ pub base_ascent: Abs,
+ pub italics_correction: Abs,
+ pub accent_attach: Abs,
+ pub text_like: bool,
+ pub ignorant: bool,
+}
+
+impl FrameFragment {
+ pub fn new(ctx: &MathContext, styles: StyleChain, frame: Frame) -> Self {
+ let base_ascent = frame.ascent();
+ let accent_attach = frame.width() / 2.0;
+ Self {
+ frame: frame.post_processed(styles),
+ font_size: scaled_font_size(ctx, styles),
+ class: EquationElem::class_in(styles).unwrap_or(MathClass::Normal),
+ math_size: EquationElem::size_in(styles),
+ limits: Limits::Never,
+ spaced: false,
+ base_ascent,
+ italics_correction: Abs::zero(),
+ accent_attach,
+ text_like: false,
+ ignorant: false,
+ }
+ }
+
+ pub fn with_class(self, class: MathClass) -> Self {
+ Self { class, ..self }
+ }
+
+ pub fn with_limits(self, limits: Limits) -> Self {
+ Self { limits, ..self }
+ }
+
+ pub fn with_spaced(self, spaced: bool) -> Self {
+ Self { spaced, ..self }
+ }
+
+ pub fn with_base_ascent(self, base_ascent: Abs) -> Self {
+ Self { base_ascent, ..self }
+ }
+
+ pub fn with_italics_correction(self, italics_correction: Abs) -> Self {
+ Self { italics_correction, ..self }
+ }
+
+ pub fn with_accent_attach(self, accent_attach: Abs) -> Self {
+ Self { accent_attach, ..self }
+ }
+
+ pub fn with_text_like(self, text_like: bool) -> Self {
+ Self { text_like, ..self }
+ }
+
+ pub fn with_ignorant(self, ignorant: bool) -> Self {
+ Self { ignorant, ..self }
+ }
+}
+
+/// Look up the italics correction for a glyph.
+fn italics_correction(ctx: &MathContext, id: GlyphId, font_size: Abs) -> Option<Abs> {
+ Some(
+ ctx.table
+ .glyph_info?
+ .italic_corrections?
+ .get(id)?
+ .scaled(ctx, font_size),
+ )
+}
+
+/// Loop up the top accent attachment position for a glyph.
+fn accent_attach(ctx: &MathContext, id: GlyphId, font_size: Abs) -> Option<Abs> {
+ Some(
+ ctx.table
+ .glyph_info?
+ .top_accent_attachments?
+ .get(id)?
+ .scaled(ctx, font_size),
+ )
+}
+
+/// Look up the script/scriptscript alternates for a glyph
+fn script_alternatives<'a>(
+ ctx: &MathContext<'a, '_, '_>,
+ id: GlyphId,
+) -> Option<AlternateSet<'a>> {
+ ctx.ssty_table.and_then(|ssty| {
+ ssty.coverage.get(id).and_then(|index| ssty.alternate_sets.get(index))
+ })
+}
+
+/// Look up whether a glyph is an extended shape.
+fn is_extended_shape(ctx: &MathContext, id: GlyphId) -> bool {
+ ctx.table
+ .glyph_info
+ .and_then(|info| info.extended_shapes)
+ .and_then(|info| info.get(id))
+ .is_some()
+}
+
+/// Look up a kerning value at a specific corner and height.
+fn kern_at_height(
+ ctx: &MathContext,
+ font_size: Abs,
+ id: GlyphId,
+ corner: Corner,
+ height: Abs,
+) -> Option<Abs> {
+ let kerns = ctx.table.glyph_info?.kern_infos?.get(id)?;
+ let kern = match corner {
+ Corner::TopLeft => kerns.top_left,
+ Corner::TopRight => kerns.top_right,
+ Corner::BottomRight => kerns.bottom_right,
+ Corner::BottomLeft => kerns.bottom_left,
+ }?;
+
+ let mut i = 0;
+ while i < kern.count() && height > kern.height(i)?.scaled(ctx, font_size) {
+ i += 1;
+ }
+
+ Some(kern.kern(i)?.scaled(ctx, font_size))
+}
+
+/// Describes in which situation a frame should use limits for attachments.
+#[derive(Debug, Copy, Clone)]
+pub enum Limits {
+ /// Always scripts.
+ Never,
+ /// Display limits only in `display` math.
+ Display,
+ /// Always limits.
+ Always,
+}
+
+impl Limits {
+ /// The default limit configuration if the given character is the base.
+ pub fn for_char(c: char) -> Self {
+ match unicode_math_class::class(c) {
+ Some(MathClass::Large) => {
+ if is_integral_char(c) {
+ Limits::Never
+ } else {
+ Limits::Display
+ }
+ }
+ Some(MathClass::Relation) => Limits::Always,
+ _ => Limits::Never,
+ }
+ }
+
+ /// The default limit configuration for a math class.
+ pub fn for_class(class: MathClass) -> Self {
+ match class {
+ MathClass::Large => Self::Display,
+ MathClass::Relation => Self::Always,
+ _ => Self::Never,
+ }
+ }
+
+ /// Whether limits should be displayed in this context.
+ pub fn active(&self, styles: StyleChain) -> bool {
+ match self {
+ Self::Always => true,
+ Self::Display => EquationElem::size_in(styles) == MathSize::Display,
+ Self::Never => false,
+ }
+ }
+}
+
+/// Determines if the character is one of a variety of integral signs.
+fn is_integral_char(c: char) -> bool {
+ ('∫'..='∳').contains(&c) || ('⨋'..='⨜').contains(&c)
+}
+
+/// An OpenType substitution table that is applicable to glyph-wise substitutions.
+pub enum GlyphwiseSubsts<'a> {
+ Single(SingleSubstitution<'a>),
+ Alternate(AlternateSubstitution<'a>, u32),
+}
+
+impl<'a> GlyphwiseSubsts<'a> {
+ pub fn new(gsub: LayoutTable<'a>, feature: Feature) -> Option<Self> {
+ let table = gsub
+ .features
+ .find(ttf_parser::Tag(feature.tag.0))
+ .and_then(|feature| feature.lookup_indices.get(0))
+ .and_then(|index| gsub.lookups.get(index))?;
+ let table = table.subtables.get::<SubstitutionSubtable>(0)?;
+ match table {
+ SubstitutionSubtable::Single(single_glyphs) => {
+ Some(Self::Single(single_glyphs))
+ }
+ SubstitutionSubtable::Alternate(alt_glyphs) => {
+ Some(Self::Alternate(alt_glyphs, feature.value))
+ }
+ _ => None,
+ }
+ }
+
+ pub fn try_apply(&self, glyph_id: GlyphId) -> Option<GlyphId> {
+ match self {
+ Self::Single(single) => match single {
+ SingleSubstitution::Format1 { coverage, delta } => coverage
+ .get(glyph_id)
+ .map(|_| GlyphId(glyph_id.0.wrapping_add(*delta as u16))),
+ SingleSubstitution::Format2 { coverage, substitutes } => {
+ coverage.get(glyph_id).and_then(|idx| substitutes.get(idx))
+ }
+ },
+ Self::Alternate(alternate, value) => alternate
+ .coverage
+ .get(glyph_id)
+ .and_then(|idx| alternate.alternate_sets.get(idx))
+ .and_then(|set| set.alternates.get(*value as u16)),
+ }
+ }
+
+ pub fn apply(&self, glyph_id: GlyphId) -> GlyphId {
+ self.try_apply(glyph_id).unwrap_or(glyph_id)
+ }
+}
diff --git a/crates/typst-layout/src/math/lr.rs b/crates/typst-layout/src/math/lr.rs
new file mode 100644
index 00000000..aba9012f
--- /dev/null
+++ b/crates/typst-layout/src/math/lr.rs
@@ -0,0 +1,135 @@
+use typst_library::diag::SourceResult;
+use typst_library::foundations::{Packed, Smart, StyleChain};
+use typst_library::layout::{Abs, Axis, Length, Rel};
+use typst_library::math::{EquationElem, LrElem, MidElem};
+use unicode_math_class::MathClass;
+
+use super::{stretch_fragment, MathContext, MathFragment, DELIM_SHORT_FALL};
+
+/// Lays out an [`LrElem`].
+#[typst_macros::time(name = "math.lr", span = elem.span())]
+pub fn layout_lr(
+ elem: &Packed<LrElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let mut body = elem.body();
+
+ // Extract from an EquationElem.
+ if let Some(equation) = body.to_packed::<EquationElem>() {
+ body = equation.body();
+ }
+
+ // Extract implicit LrElem.
+ if let Some(lr) = body.to_packed::<LrElem>() {
+ if lr.size(styles).is_auto() {
+ body = lr.body();
+ }
+ }
+
+ let mut fragments = ctx.layout_into_fragments(body, styles)?;
+ let axis = scaled!(ctx, styles, axis_height);
+ let max_extent = fragments
+ .iter()
+ .map(|fragment| (fragment.ascent() - axis).max(fragment.descent() + axis))
+ .max()
+ .unwrap_or_default();
+
+ let relative_to = 2.0 * max_extent;
+ let height = elem.size(styles);
+
+ // Scale up fragments at both ends.
+ match fragments.as_mut_slice() {
+ [one] => scale(ctx, styles, one, relative_to, height, None),
+ [first, .., last] => {
+ scale(ctx, styles, first, relative_to, height, Some(MathClass::Opening));
+ scale(ctx, styles, last, relative_to, height, Some(MathClass::Closing));
+ }
+ _ => {}
+ }
+
+ // Handle MathFragment::Variant fragments that should be scaled up.
+ for fragment in &mut fragments {
+ if let MathFragment::Variant(ref mut variant) = fragment {
+ if variant.mid_stretched == Some(false) {
+ variant.mid_stretched = Some(true);
+ scale(ctx, styles, fragment, relative_to, height, Some(MathClass::Large));
+ }
+ }
+ }
+
+ // Remove weak SpacingFragment immediately after the opening or immediately
+ // before the closing.
+ let original_len = fragments.len();
+ let mut index = 0;
+ fragments.retain(|fragment| {
+ index += 1;
+ (index != 2 && index + 1 != original_len)
+ || !matches!(fragment, MathFragment::Spacing(_, true))
+ });
+
+ ctx.extend(fragments);
+
+ Ok(())
+}
+
+/// Lays out a [`MidElem`].
+#[typst_macros::time(name = "math.mid", span = elem.span())]
+pub fn layout_mid(
+ elem: &Packed<MidElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let mut fragments = ctx.layout_into_fragments(elem.body(), styles)?;
+
+ for fragment in &mut fragments {
+ match fragment {
+ MathFragment::Glyph(glyph) => {
+ let mut new = glyph.clone().into_variant();
+ new.mid_stretched = Some(false);
+ new.class = MathClass::Fence;
+ *fragment = MathFragment::Variant(new);
+ }
+ MathFragment::Variant(variant) => {
+ variant.mid_stretched = Some(false);
+ variant.class = MathClass::Fence;
+ }
+ _ => {}
+ }
+ }
+
+ ctx.extend(fragments);
+ Ok(())
+}
+
+/// Scale a math fragment to a height.
+fn scale(
+ ctx: &mut MathContext,
+ styles: StyleChain,
+ fragment: &mut MathFragment,
+ relative_to: Abs,
+ height: Smart<Rel<Length>>,
+ apply: Option<MathClass>,
+) {
+ if matches!(
+ fragment.class(),
+ MathClass::Opening | MathClass::Closing | MathClass::Fence
+ ) {
+ // This unwrap doesn't really matter. If it is None, then the fragment
+ // won't be stretchable anyways.
+ let short_fall = DELIM_SHORT_FALL.at(fragment.font_size().unwrap_or_default());
+ stretch_fragment(
+ ctx,
+ styles,
+ fragment,
+ Some(Axis::Y),
+ Some(relative_to),
+ height,
+ short_fall,
+ );
+
+ if let Some(class) = apply {
+ fragment.set_class(class);
+ }
+ }
+}
diff --git a/crates/typst-layout/src/math/mat.rs b/crates/typst-layout/src/math/mat.rs
new file mode 100644
index 00000000..6c8b0455
--- /dev/null
+++ b/crates/typst-layout/src/math/mat.rs
@@ -0,0 +1,333 @@
+use typst_library::diag::{bail, SourceResult};
+use typst_library::foundations::{Content, Packed, StyleChain};
+use typst_library::layout::{
+ Abs, Axes, Em, FixedAlignment, Frame, FrameItem, Point, Ratio, Rel, Size,
+};
+use typst_library::math::{Augment, AugmentOffsets, CasesElem, MatElem, VecElem};
+use typst_library::text::TextElem;
+use typst_library::visualize::{FillRule, FixedStroke, Geometry, LineCap, Shape};
+use typst_syntax::Span;
+
+use super::{
+ alignments, delimiter_alignment, scaled_font_size, stack, style_for_denominator,
+ AlignmentResult, FrameFragment, GlyphFragment, LeftRightAlternator, MathContext,
+ Scaled, DELIM_SHORT_FALL,
+};
+
+const VERTICAL_PADDING: Ratio = Ratio::new(0.1);
+const DEFAULT_STROKE_THICKNESS: Em = Em::new(0.05);
+
+/// Lays out a [`VecElem`].
+#[typst_macros::time(name = "math.vec", span = elem.span())]
+pub fn layout_vec(
+ elem: &Packed<VecElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let delim = elem.delim(styles);
+ let frame = layout_vec_body(
+ ctx,
+ styles,
+ elem.children(),
+ elem.align(styles),
+ elem.gap(styles).at(scaled_font_size(ctx, styles)),
+ LeftRightAlternator::Right,
+ )?;
+
+ layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), elem.span())
+}
+
+/// Lays out a [`MatElem`].
+#[typst_macros::time(name = "math.mat", span = elem.span())]
+pub fn layout_mat(
+ elem: &Packed<MatElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let augment = elem.augment(styles);
+ let rows = elem.rows();
+
+ if let Some(aug) = &augment {
+ for &offset in &aug.hline.0 {
+ if offset == 0 || offset.unsigned_abs() >= rows.len() {
+ bail!(
+ elem.span(),
+ "cannot draw a horizontal line after row {} of a matrix with {} rows",
+ if offset < 0 { rows.len() as isize + offset } else { offset },
+ rows.len()
+ );
+ }
+ }
+
+ let ncols = elem.rows().first().map_or(0, |row| row.len());
+
+ for &offset in &aug.vline.0 {
+ if offset == 0 || offset.unsigned_abs() >= ncols {
+ bail!(
+ elem.span(),
+ "cannot draw a vertical line after column {} of a matrix with {} columns",
+ if offset < 0 { ncols as isize + offset } else { offset },
+ ncols
+ );
+ }
+ }
+ }
+
+ let font_size = scaled_font_size(ctx, styles);
+ let column_gap = elem.column_gap(styles).at(font_size);
+ let row_gap = elem.row_gap(styles).at(font_size);
+ let delim = elem.delim(styles);
+ let frame = layout_mat_body(
+ ctx,
+ styles,
+ rows,
+ elem.align(styles),
+ augment,
+ Axes::new(column_gap, row_gap),
+ elem.span(),
+ )?;
+
+ layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), elem.span())
+}
+
+/// Lays out a [`CasesElem`].
+#[typst_macros::time(name = "math.cases", span = elem.span())]
+pub fn layout_cases(
+ elem: &Packed<CasesElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let delim = elem.delim(styles);
+ let frame = layout_vec_body(
+ ctx,
+ styles,
+ elem.children(),
+ FixedAlignment::Start,
+ elem.gap(styles).at(scaled_font_size(ctx, styles)),
+ LeftRightAlternator::None,
+ )?;
+
+ let (open, close) =
+ if elem.reverse(styles) { (None, delim.close()) } else { (delim.open(), None) };
+
+ layout_delimiters(ctx, styles, frame, open, close, elem.span())
+}
+
+/// Layout the inner contents of a vector.
+fn layout_vec_body(
+ ctx: &mut MathContext,
+ styles: StyleChain,
+ column: &[Content],
+ align: FixedAlignment,
+ row_gap: Rel<Abs>,
+ alternator: LeftRightAlternator,
+) -> SourceResult<Frame> {
+ let gap = row_gap.relative_to(ctx.region.size.y);
+
+ let denom_style = style_for_denominator(styles);
+ let mut flat = vec![];
+ for child in column {
+ flat.push(ctx.layout_into_run(child, styles.chain(&denom_style))?);
+ }
+ // We pad ascent and descent with the ascent and descent of the paren
+ // to ensure that normal vectors are aligned with others unless they are
+ // way too big.
+ let paren =
+ GlyphFragment::new(ctx, styles.chain(&denom_style), '(', Span::detached());
+ Ok(stack(flat, align, gap, 0, alternator, Some((paren.ascent, paren.descent))))
+}
+
+/// Layout the inner contents of a matrix.
+fn layout_mat_body(
+ ctx: &mut MathContext,
+ styles: StyleChain,
+ rows: &[Vec<Content>],
+ align: FixedAlignment,
+ augment: Option<Augment<Abs>>,
+ gap: Axes<Rel<Abs>>,
+ span: Span,
+) -> SourceResult<Frame> {
+ let ncols = rows.first().map_or(0, |row| row.len());
+ let nrows = rows.len();
+ if ncols == 0 || nrows == 0 {
+ return Ok(Frame::soft(Size::zero()));
+ }
+
+ let gap = gap.zip_map(ctx.region.size, Rel::relative_to);
+ let half_gap = gap * 0.5;
+
+ // We provide a default stroke thickness that scales
+ // with font size to ensure that augmentation lines
+ // look correct by default at all matrix sizes.
+ // The line cap is also set to square because it looks more "correct".
+ let font_size = scaled_font_size(ctx, styles);
+ let default_stroke_thickness = DEFAULT_STROKE_THICKNESS.at(font_size);
+ let default_stroke = FixedStroke {
+ thickness: default_stroke_thickness,
+ paint: TextElem::fill_in(styles).as_decoration(),
+ cap: LineCap::Square,
+ ..Default::default()
+ };
+
+ let (hline, vline, stroke) = match augment {
+ Some(augment) => {
+ // We need to get stroke here for ownership.
+ let stroke = augment.stroke.unwrap_or_default().unwrap_or(default_stroke);
+ (augment.hline, augment.vline, stroke)
+ }
+ _ => (AugmentOffsets::default(), AugmentOffsets::default(), default_stroke),
+ };
+
+ // Before the full matrix body can be laid out, the
+ // individual cells must first be independently laid out
+ // so we can ensure alignment across rows and columns.
+
+ // This variable stores the maximum ascent and descent for each row.
+ let mut heights = vec![(Abs::zero(), Abs::zero()); nrows];
+
+ // We want to transpose our data layout to columns
+ // before final layout. For efficiency, the columns
+ // variable is set up here and newly generated
+ // individual cells are then added to it.
+ let mut cols = vec![vec![]; ncols];
+
+ let denom_style = style_for_denominator(styles);
+ // We pad ascent and descent with the ascent and descent of the paren
+ // to ensure that normal matrices are aligned with others unless they are
+ // way too big.
+ let paren =
+ GlyphFragment::new(ctx, styles.chain(&denom_style), '(', Span::detached());
+
+ for (row, (ascent, descent)) in rows.iter().zip(&mut heights) {
+ for (cell, col) in row.iter().zip(&mut cols) {
+ let cell = ctx.layout_into_run(cell, styles.chain(&denom_style))?;
+
+ ascent.set_max(cell.ascent().max(paren.ascent));
+ descent.set_max(cell.descent().max(paren.descent));
+
+ col.push(cell);
+ }
+ }
+
+ // For each row, combine maximum ascent and descent into a row height.
+ // Sum the row heights, then add the total height of the gaps between rows.
+ let total_height =
+ heights.iter().map(|&(a, b)| a + b).sum::<Abs>() + gap.y * (nrows - 1) as f64;
+
+ // Width starts at zero because it can't be calculated until later
+ let mut frame = Frame::soft(Size::new(Abs::zero(), total_height));
+
+ let mut x = Abs::zero();
+
+ for (index, col) in cols.into_iter().enumerate() {
+ let AlignmentResult { points, width: rcol } = alignments(&col);
+
+ let mut y = Abs::zero();
+
+ for (cell, &(ascent, descent)) in col.into_iter().zip(&heights) {
+ let cell = cell.into_line_frame(&points, LeftRightAlternator::Right);
+ let pos = Point::new(
+ if points.is_empty() {
+ x + align.position(rcol - cell.width())
+ } else {
+ x
+ },
+ y + ascent - cell.ascent(),
+ );
+
+ frame.push_frame(pos, cell);
+
+ y += ascent + descent + gap.y;
+ }
+
+ // Advance to the end of the column
+ x += rcol;
+
+ // If a vertical line should be inserted after this column
+ if vline.0.contains(&(index as isize + 1))
+ || vline.0.contains(&(1 - ((ncols - index) as isize)))
+ {
+ frame.push(
+ Point::with_x(x + half_gap.x),
+ line_item(total_height, true, stroke.clone(), span),
+ );
+ }
+
+ // Advance to the start of the next column
+ x += gap.x;
+ }
+
+ // Once all the columns are laid out, the total width can be calculated
+ let total_width = x - gap.x;
+
+ // This allows the horizontal lines to be laid out
+ for line in hline.0 {
+ let real_line =
+ if line < 0 { nrows - line.unsigned_abs() } else { line as usize };
+ let offset = (heights[0..real_line].iter().map(|&(a, b)| a + b).sum::<Abs>()
+ + gap.y * (real_line - 1) as f64)
+ + half_gap.y;
+
+ frame.push(
+ Point::with_y(offset),
+ line_item(total_width, false, stroke.clone(), span),
+ );
+ }
+
+ frame.size_mut().x = total_width;
+
+ Ok(frame)
+}
+
+fn line_item(length: Abs, vertical: bool, stroke: FixedStroke, span: Span) -> FrameItem {
+ let line_geom = if vertical {
+ Geometry::Line(Point::with_y(length))
+ } else {
+ Geometry::Line(Point::with_x(length))
+ };
+
+ FrameItem::Shape(
+ Shape {
+ geometry: line_geom,
+ fill: None,
+ fill_rule: FillRule::default(),
+ stroke: Some(stroke),
+ },
+ span,
+ )
+}
+
+/// Layout the outer wrapper around the body of a vector or matrix.
+fn layout_delimiters(
+ ctx: &mut MathContext,
+ styles: StyleChain,
+ mut frame: Frame,
+ left: Option<char>,
+ right: Option<char>,
+ span: Span,
+) -> SourceResult<()> {
+ let font_size = scaled_font_size(ctx, styles);
+ let short_fall = DELIM_SHORT_FALL.at(font_size);
+ let axis = ctx.constants.axis_height().scaled(ctx, font_size);
+ let height = frame.height();
+ let target = height + VERTICAL_PADDING.of(height);
+ frame.set_baseline(height / 2.0 + axis);
+
+ if let Some(left) = left {
+ let mut left = GlyphFragment::new(ctx, styles, left, span)
+ .stretch_vertical(ctx, target, short_fall);
+ left.align_on_axis(ctx, delimiter_alignment(left.c));
+ ctx.push(left);
+ }
+
+ ctx.push(FrameFragment::new(ctx, styles, frame));
+
+ if let Some(right) = right {
+ let mut right = GlyphFragment::new(ctx, styles, right, span)
+ .stretch_vertical(ctx, target, short_fall);
+ right.align_on_axis(ctx, delimiter_alignment(right.c));
+ ctx.push(right);
+ }
+
+ Ok(())
+}
diff --git a/crates/typst-layout/src/math/mod.rs b/crates/typst-layout/src/math/mod.rs
new file mode 100644
index 00000000..b3dde977
--- /dev/null
+++ b/crates/typst-layout/src/math/mod.rs
@@ -0,0 +1,703 @@
+#[macro_use]
+mod shared;
+mod accent;
+mod attach;
+mod cancel;
+mod frac;
+mod fragment;
+mod lr;
+mod mat;
+mod root;
+mod run;
+mod stretch;
+mod text;
+mod underover;
+
+use ttf_parser::gsub::SubstitutionSubtable;
+use typst_library::diag::{bail, SourceResult};
+use typst_library::engine::Engine;
+use typst_library::foundations::{Content, NativeElement, Packed, Resolve, StyleChain};
+use typst_library::introspection::{Counter, Locator, SplitLocator, TagElem};
+use typst_library::layout::{
+ Abs, AlignElem, Axes, BlockElem, BoxElem, Em, FixedAlignment, Fragment, Frame, HElem,
+ InlineItem, OuterHAlignment, PlaceElem, Point, Region, Regions, Size, Spacing,
+ SpecificAlignment, VAlignment,
+};
+use typst_library::math::*;
+use typst_library::model::ParElem;
+use typst_library::routines::{Arenas, RealizationKind};
+use typst_library::text::{
+ families, features, variant, Font, LinebreakElem, SpaceElem, TextEdgeBounds,
+ TextElem, TextSize,
+};
+use typst_library::World;
+use typst_syntax::Span;
+use typst_utils::Numeric;
+use unicode_math_class::MathClass;
+
+use self::fragment::{
+ FrameFragment, GlyphFragment, GlyphwiseSubsts, Limits, MathFragment, VariantFragment,
+};
+use self::run::{LeftRightAlternator, MathRun, MathRunFrameBuilder};
+use self::shared::*;
+use self::stretch::{stretch_fragment, stretch_glyph};
+
+/// Layout an inline equation (in a paragraph).
+#[typst_macros::time(span = elem.span())]
+pub fn layout_equation_inline(
+ elem: &Packed<EquationElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Size,
+) -> SourceResult<Vec<InlineItem>> {
+ assert!(!elem.block(styles));
+
+ let font = find_math_font(engine, styles, elem.span())?;
+
+ let mut locator = locator.split();
+ let mut ctx = MathContext::new(engine, &mut locator, styles, region, &font);
+ let run = ctx.layout_into_run(&elem.body, styles)?;
+
+ let mut items = if run.row_count() == 1 {
+ run.into_par_items()
+ } else {
+ vec![InlineItem::Frame(run.into_fragment(&ctx, styles).into_frame())]
+ };
+
+ // An empty equation should have a height, so we still create a frame
+ // (which is then resized in the loop).
+ if items.is_empty() {
+ items.push(InlineItem::Frame(Frame::soft(Size::zero())));
+ }
+
+ for item in &mut items {
+ let InlineItem::Frame(frame) = item else { continue };
+
+ let font_size = scaled_font_size(&ctx, styles);
+ let slack = ParElem::leading_in(styles) * 0.7;
+
+ let (t, b) = font.edges(
+ TextElem::top_edge_in(styles),
+ TextElem::bottom_edge_in(styles),
+ font_size,
+ TextEdgeBounds::Frame(frame),
+ );
+
+ let ascent = t.max(frame.ascent() - slack);
+ let descent = b.max(frame.descent() - slack);
+ frame.translate(Point::with_y(ascent - frame.baseline()));
+ frame.size_mut().y = ascent + descent;
+ }
+
+ Ok(items)
+}
+
+/// Layout a block-level equation (in a flow).
+#[typst_macros::time(span = elem.span())]
+pub fn layout_equation_block(
+ elem: &Packed<EquationElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+) -> SourceResult<Fragment> {
+ assert!(elem.block(styles));
+
+ let span = elem.span();
+ let font = find_math_font(engine, styles, span)?;
+
+ let mut locator = locator.split();
+ let mut ctx = MathContext::new(engine, &mut locator, styles, regions.base(), &font);
+ let full_equation_builder = ctx
+ .layout_into_run(&elem.body, styles)?
+ .multiline_frame_builder(&ctx, styles);
+ let width = full_equation_builder.size.x;
+
+ let equation_builders = if BlockElem::breakable_in(styles) {
+ let mut rows = full_equation_builder.frames.into_iter().peekable();
+ let mut equation_builders = vec![];
+ let mut last_first_pos = Point::zero();
+ let mut regions = regions;
+
+ loop {
+ // Keep track of the position of the first row in this region,
+ // so that the offset can be reverted later.
+ let Some(&(_, first_pos)) = rows.peek() else { break };
+ last_first_pos = first_pos;
+
+ let mut frames = vec![];
+ let mut height = Abs::zero();
+ while let Some((sub, pos)) = rows.peek() {
+ let mut pos = *pos;
+ pos.y -= first_pos.y;
+
+ // Finish this region if the line doesn't fit. Only do it if
+ // we placed at least one line _or_ we still have non-last
+ // regions. Crucially, we don't want to infinitely create
+ // new regions which are too small.
+ if !regions.size.y.fits(sub.height() + pos.y)
+ && (regions.may_progress()
+ || (regions.may_break() && !frames.is_empty()))
+ {
+ break;
+ }
+
+ let (sub, _) = rows.next().unwrap();
+ height = height.max(pos.y + sub.height());
+ frames.push((sub, pos));
+ }
+
+ equation_builders
+ .push(MathRunFrameBuilder { frames, size: Size::new(width, height) });
+ regions.next();
+ }
+
+ // Append remaining rows to the equation builder of the last region.
+ if let Some(equation_builder) = equation_builders.last_mut() {
+ equation_builder.frames.extend(rows.map(|(frame, mut pos)| {
+ pos.y -= last_first_pos.y;
+ (frame, pos)
+ }));
+
+ let height = equation_builder
+ .frames
+ .iter()
+ .map(|(frame, pos)| frame.height() + pos.y)
+ .max()
+ .unwrap_or(equation_builder.size.y);
+
+ equation_builder.size.y = height;
+ }
+
+ // Ensure that there is at least one frame, even for empty equations.
+ if equation_builders.is_empty() {
+ equation_builders
+ .push(MathRunFrameBuilder { frames: vec![], size: Size::zero() });
+ }
+
+ equation_builders
+ } else {
+ vec![full_equation_builder]
+ };
+
+ let Some(numbering) = (**elem).numbering(styles) else {
+ let frames = equation_builders
+ .into_iter()
+ .map(MathRunFrameBuilder::build)
+ .collect();
+ return Ok(Fragment::frames(frames));
+ };
+
+ let pod = Region::new(regions.base(), Axes::splat(false));
+ let counter = Counter::of(EquationElem::elem())
+ .display_at_loc(engine, elem.location().unwrap(), styles, numbering)?
+ .spanned(span);
+ let number =
+ (engine.routines.layout_frame)(engine, &counter, locator.next(&()), styles, pod)?;
+
+ static NUMBER_GUTTER: Em = Em::new(0.5);
+ let full_number_width = number.width() + NUMBER_GUTTER.resolve(styles);
+
+ let number_align = match elem.number_align(styles) {
+ SpecificAlignment::H(h) => SpecificAlignment::Both(h, VAlignment::Horizon),
+ SpecificAlignment::V(v) => SpecificAlignment::Both(OuterHAlignment::End, v),
+ SpecificAlignment::Both(h, v) => SpecificAlignment::Both(h, v),
+ };
+
+ // Add equation numbers to each equation region.
+ let region_count = equation_builders.len();
+ let frames = equation_builders
+ .into_iter()
+ .map(|builder| {
+ if builder.frames.is_empty() && region_count > 1 {
+ // Don't number empty regions, but do number empty equations.
+ return builder.build();
+ }
+ add_equation_number(
+ builder,
+ number.clone(),
+ number_align.resolve(styles),
+ AlignElem::alignment_in(styles).resolve(styles).x,
+ regions.size.x,
+ full_number_width,
+ )
+ })
+ .collect();
+
+ Ok(Fragment::frames(frames))
+}
+
+fn find_math_font(
+ engine: &mut Engine<'_>,
+ styles: StyleChain,
+ span: Span,
+) -> SourceResult<Font> {
+ let variant = variant(styles);
+ let world = engine.world;
+ let Some(font) = families(styles).find_map(|family| {
+ let id = world.book().select(family, variant)?;
+ let font = world.font(id)?;
+ let _ = font.ttf().tables().math?.constants?;
+ Some(font)
+ }) else {
+ bail!(span, "current font does not support math");
+ };
+ Ok(font)
+}
+
+fn add_equation_number(
+ equation_builder: MathRunFrameBuilder,
+ number: Frame,
+ number_align: Axes<FixedAlignment>,
+ equation_align: FixedAlignment,
+ region_size_x: Abs,
+ full_number_width: Abs,
+) -> Frame {
+ let first =
+ equation_builder.frames.first().map_or(
+ (equation_builder.size, Point::zero(), Abs::zero()),
+ |(frame, pos)| (frame.size(), *pos, frame.baseline()),
+ );
+ let last =
+ equation_builder.frames.last().map_or(
+ (equation_builder.size, Point::zero(), Abs::zero()),
+ |(frame, pos)| (frame.size(), *pos, frame.baseline()),
+ );
+ let line_count = equation_builder.frames.len();
+ let mut equation = equation_builder.build();
+
+ let width = if region_size_x.is_finite() {
+ region_size_x
+ } else {
+ equation.width() + 2.0 * full_number_width
+ };
+
+ let is_multiline = line_count >= 2;
+ let resizing_offset = resize_equation(
+ &mut equation,
+ &number,
+ number_align,
+ equation_align,
+ width,
+ is_multiline,
+ [first, last],
+ );
+ equation.translate(Point::with_x(match (equation_align, number_align.x) {
+ (FixedAlignment::Start, FixedAlignment::Start) => full_number_width,
+ (FixedAlignment::End, FixedAlignment::End) => -full_number_width,
+ _ => Abs::zero(),
+ }));
+
+ let x = match number_align.x {
+ FixedAlignment::Start => Abs::zero(),
+ FixedAlignment::End => equation.width() - number.width(),
+ _ => unreachable!(),
+ };
+ let y = {
+ let align_baselines = |(_, pos, baseline): (_, Point, Abs), number: &Frame| {
+ resizing_offset.y + pos.y + baseline - number.baseline()
+ };
+ match number_align.y {
+ FixedAlignment::Start => align_baselines(first, &number),
+ FixedAlignment::Center if !is_multiline => align_baselines(first, &number),
+ // In this case, the center lines (not baselines) of the number frame
+ // and the equation frame shall be aligned.
+ FixedAlignment::Center => (equation.height() - number.height()) / 2.0,
+ FixedAlignment::End => align_baselines(last, &number),
+ }
+ };
+
+ equation.push_frame(Point::new(x, y), number);
+ equation
+}
+
+/// Resize the equation's frame accordingly so that it encompasses the number.
+fn resize_equation(
+ equation: &mut Frame,
+ number: &Frame,
+ number_align: Axes<FixedAlignment>,
+ equation_align: FixedAlignment,
+ width: Abs,
+ is_multiline: bool,
+ [first, last]: [(Axes<Abs>, Point, Abs); 2],
+) -> Point {
+ if matches!(number_align.y, FixedAlignment::Center if is_multiline) {
+ // In this case, the center lines (not baselines) of the number frame
+ // and the equation frame shall be aligned.
+ return equation.resize(
+ Size::new(width, equation.height().max(number.height())),
+ Axes::<FixedAlignment>::new(equation_align, FixedAlignment::Center),
+ );
+ }
+
+ let excess_above = Abs::zero().max({
+ if !is_multiline || matches!(number_align.y, FixedAlignment::Start) {
+ let (.., baseline) = first;
+ number.baseline() - baseline
+ } else {
+ Abs::zero()
+ }
+ });
+ let excess_below = Abs::zero().max({
+ if !is_multiline || matches!(number_align.y, FixedAlignment::End) {
+ let (size, .., baseline) = last;
+ (number.height() - number.baseline()) - (size.y - baseline)
+ } else {
+ Abs::zero()
+ }
+ });
+
+ // The vertical expansion is asymmetric on the top and bottom edges, so we
+ // first align at the top then translate the content downward later.
+ let resizing_offset = equation.resize(
+ Size::new(width, equation.height() + excess_above + excess_below),
+ Axes::<FixedAlignment>::new(equation_align, FixedAlignment::Start),
+ );
+ equation.translate(Point::with_y(excess_above));
+ resizing_offset + Point::with_y(excess_above)
+}
+
+/// The context for math layout.
+struct MathContext<'a, 'v, 'e> {
+ // External.
+ engine: &'v mut Engine<'e>,
+ locator: &'v mut SplitLocator<'a>,
+ region: Region,
+ // Font-related.
+ font: &'a Font,
+ ttf: &'a ttf_parser::Face<'a>,
+ table: ttf_parser::math::Table<'a>,
+ constants: ttf_parser::math::Constants<'a>,
+ ssty_table: Option<ttf_parser::gsub::AlternateSubstitution<'a>>,
+ glyphwise_tables: Option<Vec<GlyphwiseSubsts<'a>>>,
+ space_width: Em,
+ // Mutable.
+ fragments: Vec<MathFragment>,
+}
+
+impl<'a, 'v, 'e> MathContext<'a, 'v, 'e> {
+ /// Create a new math context.
+ fn new(
+ engine: &'v mut Engine<'e>,
+ locator: &'v mut SplitLocator<'a>,
+ styles: StyleChain<'a>,
+ base: Size,
+ font: &'a Font,
+ ) -> Self {
+ let math_table = font.ttf().tables().math.unwrap();
+ let gsub_table = font.ttf().tables().gsub;
+ let constants = math_table.constants.unwrap();
+
+ let ssty_table = gsub_table
+ .and_then(|gsub| {
+ gsub.features
+ .find(ttf_parser::Tag::from_bytes(b"ssty"))
+ .and_then(|feature| feature.lookup_indices.get(0))
+ .and_then(|index| gsub.lookups.get(index))
+ })
+ .and_then(|ssty| ssty.subtables.get::<SubstitutionSubtable>(0))
+ .and_then(|ssty| match ssty {
+ SubstitutionSubtable::Alternate(alt_glyphs) => Some(alt_glyphs),
+ _ => None,
+ });
+
+ let features = features(styles);
+ let glyphwise_tables = gsub_table.map(|gsub| {
+ features
+ .into_iter()
+ .filter_map(|feature| GlyphwiseSubsts::new(gsub, feature))
+ .collect()
+ });
+
+ let ttf = font.ttf();
+ let space_width = ttf
+ .glyph_index(' ')
+ .and_then(|id| ttf.glyph_hor_advance(id))
+ .map(|advance| font.to_em(advance))
+ .unwrap_or(THICK);
+
+ Self {
+ engine,
+ locator,
+ region: Region::new(base, Axes::splat(false)),
+ font,
+ ttf: font.ttf(),
+ table: math_table,
+ constants,
+ ssty_table,
+ glyphwise_tables,
+ space_width,
+ fragments: vec![],
+ }
+ }
+
+ /// Push a fragment.
+ fn push(&mut self, fragment: impl Into<MathFragment>) {
+ self.fragments.push(fragment.into());
+ }
+
+ /// Push multiple fragments.
+ fn extend(&mut self, fragments: impl IntoIterator<Item = MathFragment>) {
+ self.fragments.extend(fragments);
+ }
+
+ /// Layout the given element and return the result as a [`MathRun`].
+ fn layout_into_run(
+ &mut self,
+ elem: &Content,
+ styles: StyleChain,
+ ) -> SourceResult<MathRun> {
+ Ok(MathRun::new(self.layout_into_fragments(elem, styles)?))
+ }
+
+ /// Layout the given element and return the resulting [`MathFragment`]s.
+ fn layout_into_fragments(
+ &mut self,
+ elem: &Content,
+ styles: StyleChain,
+ ) -> SourceResult<Vec<MathFragment>> {
+ // The element's layout_math() changes the fragments held in this
+ // MathContext object, but for convenience this function shouldn't change
+ // them, so we restore the MathContext's fragments after obtaining the
+ // layout result.
+ let prev = std::mem::take(&mut self.fragments);
+ self.layout_into_self(elem, styles)?;
+ Ok(std::mem::replace(&mut self.fragments, prev))
+ }
+
+ /// Layout the given element and return the result as a
+ /// unified [`MathFragment`].
+ fn layout_into_fragment(
+ &mut self,
+ elem: &Content,
+ styles: StyleChain,
+ ) -> SourceResult<MathFragment> {
+ Ok(self.layout_into_run(elem, styles)?.into_fragment(self, styles))
+ }
+
+ /// Layout the given element and return the result as a [`Frame`].
+ fn layout_into_frame(
+ &mut self,
+ elem: &Content,
+ styles: StyleChain,
+ ) -> SourceResult<Frame> {
+ Ok(self.layout_into_fragment(elem, styles)?.into_frame())
+ }
+
+ /// Layout arbitrary content.
+ fn layout_into_self(
+ &mut self,
+ content: &Content,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ let arenas = Arenas::default();
+ let pairs = (self.engine.routines.realize)(
+ RealizationKind::Math,
+ self.engine,
+ self.locator,
+ &arenas,
+ content,
+ styles,
+ )?;
+
+ let outer = styles;
+ for (elem, styles) in pairs {
+ // Hack because the font is fixed in math.
+ if styles != outer && TextElem::font_in(styles) != TextElem::font_in(outer) {
+ let frame = layout_external(elem, self, styles)?;
+ self.push(FrameFragment::new(self, styles, frame).with_spaced(true));
+ continue;
+ }
+
+ layout_realized(elem, self, styles)?;
+ }
+
+ Ok(())
+ }
+}
+
+/// Lays out a leaf element resulting from realization.
+fn layout_realized(
+ elem: &Content,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ if let Some(elem) = elem.to_packed::<TagElem>() {
+ ctx.push(MathFragment::Tag(elem.tag.clone()));
+ } else if elem.is::<SpaceElem>() {
+ let font_size = scaled_font_size(ctx, styles);
+ ctx.push(MathFragment::Space(ctx.space_width.at(font_size)));
+ } else if elem.is::<LinebreakElem>() {
+ ctx.push(MathFragment::Linebreak);
+ } else if let Some(elem) = elem.to_packed::<HElem>() {
+ layout_h(elem, ctx, styles)?;
+ } else if let Some(elem) = elem.to_packed::<TextElem>() {
+ self::text::layout_text(elem, ctx, styles)?;
+ } else if let Some(elem) = elem.to_packed::<BoxElem>() {
+ layout_box(elem, ctx, styles)?;
+ } else if elem.is::<AlignPointElem>() {
+ ctx.push(MathFragment::Align);
+ } else if let Some(elem) = elem.to_packed::<ClassElem>() {
+ layout_class(elem, ctx, styles)?;
+ } else if let Some(elem) = elem.to_packed::<AccentElem>() {
+ self::accent::layout_accent(elem, ctx, styles)?;
+ } else if let Some(elem) = elem.to_packed::<AttachElem>() {
+ self::attach::layout_attach(elem, ctx, styles)?;
+ } else if let Some(elem) = elem.to_packed::<PrimesElem>() {
+ self::attach::layout_primes(elem, ctx, styles)?;
+ } else if let Some(elem) = elem.to_packed::<ScriptsElem>() {
+ self::attach::layout_scripts(elem, ctx, styles)?;
+ } else if let Some(elem) = elem.to_packed::<LimitsElem>() {
+ self::attach::layout_limits(elem, ctx, styles)?;
+ } else if let Some(elem) = elem.to_packed::<CancelElem>() {
+ self::cancel::layout_cancel(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<FracElem>() {
+ self::frac::layout_frac(elem, ctx, styles)?;
+ } else if let Some(elem) = elem.to_packed::<BinomElem>() {
+ self::frac::layout_binom(elem, ctx, styles)?;
+ } else if let Some(elem) = elem.to_packed::<LrElem>() {
+ self::lr::layout_lr(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<MidElem>() {
+ self::lr::layout_mid(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<VecElem>() {
+ self::mat::layout_vec(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<MatElem>() {
+ self::mat::layout_mat(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<CasesElem>() {
+ self::mat::layout_cases(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<OpElem>() {
+ layout_op(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<RootElem>() {
+ self::root::layout_root(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<StretchElem>() {
+ self::stretch::layout_stretch(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<UnderlineElem>() {
+ self::underover::layout_underline(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<OverlineElem>() {
+ self::underover::layout_overline(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<UnderbraceElem>() {
+ self::underover::layout_underbrace(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<OverbraceElem>() {
+ self::underover::layout_overbrace(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<UnderbracketElem>() {
+ self::underover::layout_underbracket(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<OverbracketElem>() {
+ self::underover::layout_overbracket(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<UnderparenElem>() {
+ self::underover::layout_underparen(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<OverparenElem>() {
+ self::underover::layout_overparen(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<UndershellElem>() {
+ self::underover::layout_undershell(elem, ctx, styles)?
+ } else if let Some(elem) = elem.to_packed::<OvershellElem>() {
+ self::underover::layout_overshell(elem, ctx, styles)?
+ } else {
+ let mut frame = layout_external(elem, ctx, styles)?;
+ if !frame.has_baseline() {
+ let axis = scaled!(ctx, styles, axis_height);
+ frame.set_baseline(frame.height() / 2.0 + axis);
+ }
+ ctx.push(
+ FrameFragment::new(ctx, styles, frame)
+ .with_spaced(true)
+ .with_ignorant(elem.is::<PlaceElem>()),
+ );
+ }
+
+ Ok(())
+}
+
+/// Lays out an [`BoxElem`].
+fn layout_box(
+ elem: &Packed<BoxElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let local = TextElem::set_size(TextSize(scaled_font_size(ctx, styles).into())).wrap();
+ let frame = (ctx.engine.routines.layout_box)(
+ elem,
+ ctx.engine,
+ ctx.locator.next(&elem.span()),
+ styles.chain(&local),
+ ctx.region.size,
+ )?;
+ ctx.push(FrameFragment::new(ctx, styles, frame).with_spaced(true));
+ Ok(())
+}
+
+/// Lays out an [`HElem`].
+fn layout_h(
+ elem: &Packed<HElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ if let Spacing::Rel(rel) = elem.amount() {
+ if rel.rel.is_zero() {
+ ctx.push(MathFragment::Spacing(
+ rel.abs.at(scaled_font_size(ctx, styles)),
+ elem.weak(styles),
+ ));
+ }
+ }
+ Ok(())
+}
+
+/// Lays out a [`ClassElem`].
+#[typst_macros::time(name = "math.op", span = elem.span())]
+fn layout_class(
+ elem: &Packed<ClassElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let class = *elem.class();
+ let style = EquationElem::set_class(Some(class)).wrap();
+ let mut fragment = ctx.layout_into_fragment(elem.body(), styles.chain(&style))?;
+ fragment.set_class(class);
+ fragment.set_limits(Limits::for_class(class));
+ ctx.push(fragment);
+ Ok(())
+}
+
+/// Lays out an [`OpElem`].
+#[typst_macros::time(name = "math.op", span = elem.span())]
+fn layout_op(
+ elem: &Packed<OpElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let fragment = ctx.layout_into_fragment(elem.text(), styles)?;
+ let italics = fragment.italics_correction();
+ let accent_attach = fragment.accent_attach();
+ let text_like = fragment.is_text_like();
+
+ ctx.push(
+ FrameFragment::new(ctx, styles, fragment.into_frame())
+ .with_class(MathClass::Large)
+ .with_italics_correction(italics)
+ .with_accent_attach(accent_attach)
+ .with_text_like(text_like)
+ .with_limits(if elem.limits(styles) {
+ Limits::Display
+ } else {
+ Limits::Never
+ }),
+ );
+ Ok(())
+}
+
+/// Layout into a frame with normal layout.
+fn layout_external(
+ content: &Content,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<Frame> {
+ let local = TextElem::set_size(TextSize(scaled_font_size(ctx, styles).into())).wrap();
+ (ctx.engine.routines.layout_frame)(
+ ctx.engine,
+ content,
+ ctx.locator.next(&content.span()),
+ styles.chain(&local),
+ ctx.region,
+ )
+}
diff --git a/crates/typst-layout/src/math/root.rs b/crates/typst-layout/src/math/root.rs
new file mode 100644
index 00000000..0bb2f539
--- /dev/null
+++ b/crates/typst-layout/src/math/root.rs
@@ -0,0 +1,126 @@
+use typst_library::diag::SourceResult;
+use typst_library::foundations::{Packed, StyleChain};
+use typst_library::layout::{Abs, Frame, FrameItem, Point, Size};
+use typst_library::math::{EquationElem, MathSize, RootElem};
+use typst_library::text::TextElem;
+use typst_library::visualize::{FixedStroke, Geometry};
+
+use super::{style_cramped, FrameFragment, GlyphFragment, MathContext};
+
+/// Lays out a [`RootElem`].
+///
+/// TeXbook page 443, page 360
+/// See also: <https://www.w3.org/TR/mathml-core/#radicals-msqrt-mroot>
+#[typst_macros::time(name = "math.root", span = elem.span())]
+pub fn layout_root(
+ elem: &Packed<RootElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let index = elem.index(styles);
+ let radicand = elem.radicand();
+ let span = elem.span();
+
+ let gap = scaled!(
+ ctx, styles,
+ text: radical_vertical_gap,
+ display: radical_display_style_vertical_gap,
+ );
+ let thickness = scaled!(ctx, styles, radical_rule_thickness);
+ let extra_ascender = scaled!(ctx, styles, radical_extra_ascender);
+ let kern_before = scaled!(ctx, styles, radical_kern_before_degree);
+ let kern_after = scaled!(ctx, styles, radical_kern_after_degree);
+ let raise_factor = percent!(ctx, radical_degree_bottom_raise_percent);
+
+ // Layout radicand.
+ let radicand = {
+ let cramped = style_cramped();
+ let styles = styles.chain(&cramped);
+ let run = ctx.layout_into_run(radicand, styles)?;
+ let multiline = run.is_multiline();
+ let mut radicand = run.into_fragment(ctx, styles).into_frame();
+ if multiline {
+ // Align the frame center line with the math axis.
+ radicand.set_baseline(
+ radicand.height() / 2.0 + scaled!(ctx, styles, axis_height),
+ );
+ }
+ radicand
+ };
+
+ // Layout root symbol.
+ let target = radicand.height() + thickness + gap;
+ let sqrt = GlyphFragment::new(ctx, styles, '√', span)
+ .stretch_vertical(ctx, target, Abs::zero())
+ .frame;
+
+ // Layout the index.
+ let sscript = EquationElem::set_size(MathSize::ScriptScript).wrap();
+ let index = index
+ .as_ref()
+ .map(|elem| ctx.layout_into_frame(elem, styles.chain(&sscript)))
+ .transpose()?;
+
+ // TeXbook, page 443, item 11
+ // Keep original gap, and then distribute any remaining free space
+ // equally above and below.
+ let gap = gap.max((sqrt.height() - thickness - radicand.height() + gap) / 2.0);
+
+ let sqrt_ascent = radicand.ascent() + gap + thickness;
+ let descent = sqrt.height() - sqrt_ascent;
+ let inner_ascent = sqrt_ascent + extra_ascender;
+
+ let mut sqrt_offset = Abs::zero();
+ let mut shift_up = Abs::zero();
+ let mut ascent = inner_ascent;
+
+ if let Some(index) = &index {
+ sqrt_offset = kern_before + index.width() + kern_after;
+ // The formula below for how much raise the index by comes from
+ // the TeXbook, page 360, in the definition of `\root`.
+ // However, the `+ index.descent()` part is different from TeX.
+ // Without it, descenders can collide with the surd, a rarity
+ // in practice, but possible. MS Word also adjusts index positions
+ // for descenders.
+ shift_up = raise_factor * (inner_ascent - descent) + index.descent();
+ ascent.set_max(shift_up + index.ascent());
+ }
+
+ let radicand_x = sqrt_offset + sqrt.width();
+ let radicand_y = ascent - radicand.ascent();
+ let width = radicand_x + radicand.width();
+ let size = Size::new(width, ascent + descent);
+
+ // The extra "- thickness" comes from the fact that the sqrt is placed
+ // in `push_frame` with respect to its top, not its baseline.
+ let sqrt_pos = Point::new(sqrt_offset, radicand_y - gap - thickness);
+ let line_pos = Point::new(radicand_x, radicand_y - gap - (thickness / 2.0));
+ let radicand_pos = Point::new(radicand_x, radicand_y);
+
+ let mut frame = Frame::soft(size);
+ frame.set_baseline(ascent);
+
+ if let Some(index) = index {
+ let index_pos = Point::new(kern_before, ascent - index.ascent() - shift_up);
+ frame.push_frame(index_pos, index);
+ }
+
+ frame.push_frame(sqrt_pos, sqrt);
+ frame.push(
+ line_pos,
+ FrameItem::Shape(
+ Geometry::Line(Point::with_x(radicand.width())).stroked(
+ FixedStroke::from_pair(
+ TextElem::fill_in(styles).as_decoration(),
+ thickness,
+ ),
+ ),
+ span,
+ ),
+ );
+
+ frame.push_frame(radicand_pos, radicand);
+ ctx.push(FrameFragment::new(ctx, styles, frame));
+
+ Ok(())
+}
diff --git a/crates/typst-layout/src/math/run.rs b/crates/typst-layout/src/math/run.rs
new file mode 100644
index 00000000..8f12c509
--- /dev/null
+++ b/crates/typst-layout/src/math/run.rs
@@ -0,0 +1,469 @@
+use std::iter::once;
+
+use typst_library::foundations::{Resolve, StyleChain};
+use typst_library::layout::{Abs, AlignElem, Em, Frame, InlineItem, Point, Size};
+use typst_library::math::{EquationElem, MathSize, MEDIUM, THICK, THIN};
+use typst_library::model::ParElem;
+use unicode_math_class::MathClass;
+
+use super::{alignments, scaled_font_size, FrameFragment, MathContext, MathFragment};
+
+const TIGHT_LEADING: Em = Em::new(0.25);
+
+/// A linear collection of [`MathFragment`]s.
+#[derive(Debug, Default, Clone)]
+pub struct MathRun(Vec<MathFragment>);
+
+impl MathRun {
+ /// Takes the given [`MathFragment`]s and do some basic processing.
+ pub fn new(fragments: Vec<MathFragment>) -> Self {
+ let iter = fragments.into_iter().peekable();
+ let mut last: Option<usize> = None;
+ let mut space: Option<MathFragment> = None;
+ let mut resolved: Vec<MathFragment> = vec![];
+
+ for mut fragment in iter {
+ match fragment {
+ // Keep space only if supported by spaced fragments.
+ MathFragment::Space(_) => {
+ if last.is_some() {
+ space = Some(fragment);
+ }
+ continue;
+ }
+
+ // Explicit spacing disables automatic spacing.
+ MathFragment::Spacing(width, weak) => {
+ last = None;
+ space = None;
+
+ if weak {
+ match resolved.last_mut() {
+ None => continue,
+ Some(MathFragment::Spacing(prev, true)) => {
+ *prev = (*prev).max(width);
+ continue;
+ }
+ Some(_) => {}
+ }
+ }
+
+ resolved.push(fragment);
+ continue;
+ }
+
+ // Alignment points are resolved later.
+ MathFragment::Align => {
+ resolved.push(fragment);
+ continue;
+ }
+
+ // New line, new things.
+ MathFragment::Linebreak => {
+ resolved.push(fragment);
+ space = None;
+ last = None;
+ continue;
+ }
+
+ _ => {}
+ }
+
+ // Convert variable operators into binary operators if something
+ // precedes them and they are not preceded by a operator or comparator.
+ if fragment.class() == MathClass::Vary
+ && matches!(
+ last.map(|i| resolved[i].class()),
+ Some(
+ MathClass::Normal
+ | MathClass::Alphabetic
+ | MathClass::Closing
+ | MathClass::Fence
+ )
+ )
+ {
+ fragment.set_class(MathClass::Binary);
+ }
+
+ // Insert spacing between the last and this non-ignorant item.
+ if !fragment.is_ignorant() {
+ if let Some(i) = last {
+ if let Some(s) = spacing(&resolved[i], space.take(), &fragment) {
+ resolved.insert(i + 1, s);
+ }
+ }
+
+ last = Some(resolved.len());
+ }
+
+ resolved.push(fragment);
+ }
+
+ if let Some(MathFragment::Spacing(_, true)) = resolved.last() {
+ resolved.pop();
+ }
+
+ Self(resolved)
+ }
+
+ pub fn iter(&self) -> std::slice::Iter<'_, MathFragment> {
+ self.0.iter()
+ }
+
+ /// Split by linebreaks, and copy [`MathFragment`]s into rows.
+ pub fn rows(&self) -> Vec<Self> {
+ self.0
+ .split(|frag| matches!(frag, MathFragment::Linebreak))
+ .map(|slice| Self(slice.to_vec()))
+ .collect()
+ }
+
+ pub fn row_count(&self) -> usize {
+ let mut count =
+ 1 + self.0.iter().filter(|f| matches!(f, MathFragment::Linebreak)).count();
+
+ // A linebreak at the very end does not introduce an extra row.
+ if let Some(f) = self.0.last() {
+ if matches!(f, MathFragment::Linebreak) {
+ count -= 1
+ }
+ }
+ count
+ }
+
+ pub fn ascent(&self) -> Abs {
+ self.iter()
+ .filter(|e| affects_row_height(e))
+ .map(|e| e.ascent())
+ .max()
+ .unwrap_or_default()
+ }
+
+ pub fn descent(&self) -> Abs {
+ self.iter()
+ .filter(|e| affects_row_height(e))
+ .map(|e| e.descent())
+ .max()
+ .unwrap_or_default()
+ }
+
+ pub fn class(&self) -> MathClass {
+ // Predict the class of the output of 'into_fragment'
+ if self.0.len() == 1 {
+ self.0
+ .first()
+ .map(|fragment| fragment.class())
+ .unwrap_or(MathClass::Normal)
+ } else {
+ // FrameFragment::new() (inside 'into_fragment' in this branch) defaults
+ // to MathClass::Normal for its class.
+ MathClass::Normal
+ }
+ }
+
+ pub fn into_frame(self, ctx: &MathContext, styles: StyleChain) -> Frame {
+ if !self.is_multiline() {
+ self.into_line_frame(&[], LeftRightAlternator::Right)
+ } else {
+ self.multiline_frame_builder(ctx, styles).build()
+ }
+ }
+
+ pub fn into_fragment(self, ctx: &MathContext, styles: StyleChain) -> MathFragment {
+ if self.0.len() == 1 {
+ return self.0.into_iter().next().unwrap();
+ }
+
+ // Fragments without a math_size are ignored: the notion of size do not
+ // apply to them, so their text-likeness is meaningless.
+ let text_like = self
+ .iter()
+ .filter(|e| e.math_size().is_some())
+ .all(|e| e.is_text_like());
+
+ FrameFragment::new(ctx, styles, self.into_frame(ctx, styles))
+ .with_text_like(text_like)
+ .into()
+ }
+
+ /// Returns a builder that lays out the [`MathFragment`]s into a possibly
+ /// multi-row [`Frame`]. The rows are aligned using the same set of alignment
+ /// points computed from them as a whole.
+ pub fn multiline_frame_builder(
+ self,
+ ctx: &MathContext,
+ styles: StyleChain,
+ ) -> MathRunFrameBuilder {
+ let rows: Vec<_> = self.rows();
+ let row_count = rows.len();
+ let alignments = alignments(&rows);
+
+ let leading = if EquationElem::size_in(styles) >= MathSize::Text {
+ ParElem::leading_in(styles)
+ } else {
+ let font_size = scaled_font_size(ctx, styles);
+ TIGHT_LEADING.at(font_size)
+ };
+
+ let align = AlignElem::alignment_in(styles).resolve(styles).x;
+ let mut frames: Vec<(Frame, Point)> = vec![];
+ let mut size = Size::zero();
+ for (i, row) in rows.into_iter().enumerate() {
+ if i == row_count - 1 && row.0.is_empty() {
+ continue;
+ }
+
+ let sub = row.into_line_frame(&alignments.points, LeftRightAlternator::Right);
+ if i > 0 {
+ size.y += leading;
+ }
+
+ let mut pos = Point::with_y(size.y);
+ if alignments.points.is_empty() {
+ pos.x = align.position(alignments.width - sub.width());
+ }
+ size.x.set_max(sub.width());
+ size.y += sub.height();
+ frames.push((sub, pos));
+ }
+
+ MathRunFrameBuilder { size, frames }
+ }
+
+ /// Lay out [`MathFragment`]s into a one-row [`Frame`], using the
+ /// caller-provided alignment points.
+ pub fn into_line_frame(
+ self,
+ points: &[Abs],
+ mut alternator: LeftRightAlternator,
+ ) -> Frame {
+ let ascent = self.ascent();
+ let mut frame = Frame::soft(Size::new(Abs::zero(), ascent + self.descent()));
+ frame.set_baseline(ascent);
+
+ let mut next_x = {
+ let widths: Vec<Abs> = if points.is_empty() {
+ vec![]
+ } else {
+ self.iter()
+ .as_slice()
+ .split(|e| matches!(e, MathFragment::Align))
+ .map(|chunk| chunk.iter().map(|e| e.width()).sum())
+ .collect()
+ };
+
+ let mut prev_points = once(Abs::zero()).chain(points.iter().copied());
+ let mut point_widths = points.iter().copied().zip(widths);
+ move || {
+ point_widths
+ .next()
+ .zip(prev_points.next())
+ .zip(alternator.next())
+ .map(|(((point, width), prev_point), alternator)| match alternator {
+ LeftRightAlternator::Right => point - width,
+ _ => prev_point,
+ })
+ }
+ };
+ let mut x = next_x().unwrap_or_default();
+
+ for fragment in self.0.into_iter() {
+ if matches!(fragment, MathFragment::Align) {
+ x = next_x().unwrap_or(x);
+ continue;
+ }
+
+ let y = ascent - fragment.ascent();
+ let pos = Point::new(x, y);
+ x += fragment.width();
+ frame.push_frame(pos, fragment.into_frame());
+ }
+
+ frame.size_mut().x = x;
+ frame
+ }
+
+ pub fn into_par_items(self) -> Vec<InlineItem> {
+ let mut items = vec![];
+
+ let mut x = Abs::zero();
+ let mut ascent = Abs::zero();
+ let mut descent = Abs::zero();
+ let mut frame = Frame::soft(Size::zero());
+ let mut empty = true;
+
+ let finalize_frame = |frame: &mut Frame, x, ascent, descent| {
+ frame.set_size(Size::new(x, ascent + descent));
+ frame.set_baseline(Abs::zero());
+ frame.translate(Point::with_y(ascent));
+ };
+
+ let mut space_is_visible = false;
+
+ let is_relation = |f: &MathFragment| matches!(f.class(), MathClass::Relation);
+ let is_space = |f: &MathFragment| {
+ matches!(f, MathFragment::Space(_) | MathFragment::Spacing(_, _))
+ };
+
+ let mut iter = self.0.into_iter().peekable();
+ while let Some(fragment) = iter.next() {
+ if space_is_visible {
+ match fragment {
+ MathFragment::Space(width) | MathFragment::Spacing(width, _) => {
+ items.push(InlineItem::Space(width, true));
+ continue;
+ }
+ _ => {}
+ }
+ }
+
+ let class = fragment.class();
+ let y = fragment.ascent();
+
+ ascent.set_max(y);
+ descent.set_max(fragment.descent());
+
+ let pos = Point::new(x, -y);
+ x += fragment.width();
+ frame.push_frame(pos, fragment.into_frame());
+ empty = false;
+
+ if class == MathClass::Binary
+ || (class == MathClass::Relation
+ && !iter.peek().map(is_relation).unwrap_or_default())
+ {
+ let mut frame_prev =
+ std::mem::replace(&mut frame, Frame::soft(Size::zero()));
+
+ finalize_frame(&mut frame_prev, x, ascent, descent);
+ items.push(InlineItem::Frame(frame_prev));
+ empty = true;
+
+ x = Abs::zero();
+ ascent = Abs::zero();
+ descent = Abs::zero();
+
+ space_is_visible = true;
+ if let Some(f_next) = iter.peek() {
+ if !is_space(f_next) {
+ items.push(InlineItem::Space(Abs::zero(), true));
+ }
+ }
+ } else {
+ space_is_visible = false;
+ }
+ }
+
+ // Don't use `frame.is_empty()` because even an empty frame can
+ // contribute width (if it had hidden content).
+ if !empty {
+ finalize_frame(&mut frame, x, ascent, descent);
+ items.push(InlineItem::Frame(frame));
+ }
+
+ items
+ }
+
+ pub fn is_multiline(&self) -> bool {
+ self.iter().any(|frag| matches!(frag, MathFragment::Linebreak))
+ }
+}
+
+impl<T: Into<MathFragment>> From<T> for MathRun {
+ fn from(fragment: T) -> Self {
+ Self(vec![fragment.into()])
+ }
+}
+
+/// An iterator that alternates between the `Left` and `Right` values, if the
+/// initial value is not `None`.
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+pub enum LeftRightAlternator {
+ None,
+ Left,
+ Right,
+}
+
+impl Iterator for LeftRightAlternator {
+ type Item = LeftRightAlternator;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let r = Some(*self);
+ match self {
+ Self::None => {}
+ Self::Left => *self = Self::Right,
+ Self::Right => *self = Self::Left,
+ }
+ r
+ }
+}
+
+/// How the rows from the [`MathRun`] should be aligned and merged into a [`Frame`].
+pub struct MathRunFrameBuilder {
+ /// The size of the resulting frame.
+ pub size: Size,
+ /// Each row's frame, and the position where the frame should
+ /// be pushed into the resulting frame.
+ pub frames: Vec<(Frame, Point)>,
+}
+
+impl MathRunFrameBuilder {
+ /// Consumes the builder and returns a [`Frame`].
+ pub fn build(self) -> Frame {
+ let mut frame = Frame::soft(self.size);
+ for (sub, pos) in self.frames.into_iter() {
+ frame.push_frame(pos, sub);
+ }
+ frame
+ }
+}
+
+fn affects_row_height(fragment: &MathFragment) -> bool {
+ !matches!(fragment, MathFragment::Align | MathFragment::Linebreak)
+}
+
+/// Create the spacing between two fragments in a given style.
+fn spacing(
+ l: &MathFragment,
+ space: Option<MathFragment>,
+ r: &MathFragment,
+) -> Option<MathFragment> {
+ use MathClass::*;
+
+ let resolve = |v: Em, size_ref: &MathFragment| -> Option<MathFragment> {
+ let width = size_ref.font_size().map_or(Abs::zero(), |size| v.at(size));
+ Some(MathFragment::Spacing(width, false))
+ };
+ let script = |f: &MathFragment| f.math_size().is_some_and(|s| s <= MathSize::Script);
+
+ match (l.class(), r.class()) {
+ // No spacing before punctuation; thin spacing after punctuation, unless
+ // in script size.
+ (_, Punctuation) => None,
+ (Punctuation, _) if !script(l) => resolve(THIN, l),
+
+ // No spacing after opening delimiters and before closing delimiters.
+ (Opening, _) | (_, Closing) => None,
+
+ // Thick spacing around relations, unless followed by a another relation
+ // or in script size.
+ (Relation, Relation) => None,
+ (Relation, _) if !script(l) => resolve(THICK, l),
+ (_, Relation) if !script(r) => resolve(THICK, r),
+
+ // Medium spacing around binary operators, unless in script size.
+ (Binary, _) if !script(l) => resolve(MEDIUM, l),
+ (_, Binary) if !script(r) => resolve(MEDIUM, r),
+
+ // Thin spacing around large operators, unless to the left of
+ // an opening delimiter. TeXBook, p170
+ (Large, Opening | Fence) => None,
+ (Large, _) => resolve(THIN, l),
+ (_, Large) => resolve(THIN, r),
+
+ // Spacing around spaced frames.
+ _ if (l.is_spaced() || r.is_spaced()) => space,
+
+ _ => None,
+ }
+}
diff --git a/crates/typst-layout/src/math/shared.rs b/crates/typst-layout/src/math/shared.rs
new file mode 100644
index 00000000..13477c10
--- /dev/null
+++ b/crates/typst-layout/src/math/shared.rs
@@ -0,0 +1,207 @@
+use ttf_parser::math::MathValue;
+use typst_library::foundations::{Style, StyleChain};
+use typst_library::layout::{Abs, Em, FixedAlignment, Frame, Point, Size, VAlignment};
+use typst_library::math::{EquationElem, MathSize};
+use typst_library::text::TextElem;
+use typst_utils::LazyHash;
+
+use super::{LeftRightAlternator, MathContext, MathFragment, MathRun};
+
+macro_rules! scaled {
+ ($ctx:expr, $styles:expr, text: $text:ident, display: $display:ident $(,)?) => {
+ match typst_library::math::EquationElem::size_in($styles) {
+ typst_library::math::MathSize::Display => scaled!($ctx, $styles, $display),
+ _ => scaled!($ctx, $styles, $text),
+ }
+ };
+ ($ctx:expr, $styles:expr, $name:ident) => {
+ $crate::math::Scaled::scaled(
+ $ctx.constants.$name(),
+ $ctx,
+ $crate::math::scaled_font_size($ctx, $styles),
+ )
+ };
+}
+
+macro_rules! percent {
+ ($ctx:expr, $name:ident) => {
+ $ctx.constants.$name() as f64 / 100.0
+ };
+}
+
+/// How much less high scaled delimiters can be than what they wrap.
+pub const DELIM_SHORT_FALL: Em = Em::new(0.1);
+
+/// Converts some unit to an absolute length with the current font & font size.
+pub trait Scaled {
+ fn scaled(self, ctx: &MathContext, font_size: Abs) -> Abs;
+}
+
+impl Scaled for i16 {
+ fn scaled(self, ctx: &MathContext, font_size: Abs) -> Abs {
+ ctx.font.to_em(self).at(font_size)
+ }
+}
+
+impl Scaled for u16 {
+ fn scaled(self, ctx: &MathContext, font_size: Abs) -> Abs {
+ ctx.font.to_em(self).at(font_size)
+ }
+}
+
+impl Scaled for MathValue<'_> {
+ fn scaled(self, ctx: &MathContext, font_size: Abs) -> Abs {
+ self.value.scaled(ctx, font_size)
+ }
+}
+
+/// Get the font size scaled with the `MathSize`.
+pub fn scaled_font_size(ctx: &MathContext, styles: StyleChain) -> Abs {
+ let factor = match EquationElem::size_in(styles) {
+ MathSize::Display | MathSize::Text => 1.0,
+ MathSize::Script => percent!(ctx, script_percent_scale_down),
+ MathSize::ScriptScript => percent!(ctx, script_script_percent_scale_down),
+ };
+ factor * TextElem::size_in(styles)
+}
+
+/// Styles something as cramped.
+pub fn style_cramped() -> LazyHash<Style> {
+ EquationElem::set_cramped(true).wrap()
+}
+
+/// The style for subscripts in the current style.
+pub fn style_for_subscript(styles: StyleChain) -> [LazyHash<Style>; 2] {
+ [style_for_superscript(styles), EquationElem::set_cramped(true).wrap()]
+}
+
+/// The style for superscripts in the current style.
+pub fn style_for_superscript(styles: StyleChain) -> LazyHash<Style> {
+ EquationElem::set_size(match EquationElem::size_in(styles) {
+ MathSize::Display | MathSize::Text => MathSize::Script,
+ MathSize::Script | MathSize::ScriptScript => MathSize::ScriptScript,
+ })
+ .wrap()
+}
+
+/// The style for numerators in the current style.
+pub fn style_for_numerator(styles: StyleChain) -> LazyHash<Style> {
+ EquationElem::set_size(match EquationElem::size_in(styles) {
+ MathSize::Display => MathSize::Text,
+ MathSize::Text => MathSize::Script,
+ MathSize::Script | MathSize::ScriptScript => MathSize::ScriptScript,
+ })
+ .wrap()
+}
+
+/// The style for denominators in the current style.
+pub fn style_for_denominator(styles: StyleChain) -> [LazyHash<Style>; 2] {
+ [style_for_numerator(styles), EquationElem::set_cramped(true).wrap()]
+}
+
+/// How a delimieter should be aligned when scaling.
+pub fn delimiter_alignment(delimiter: char) -> VAlignment {
+ match delimiter {
+ '⌜' | '⌝' => VAlignment::Top,
+ '⌞' | '⌟' => VAlignment::Bottom,
+ _ => VAlignment::Horizon,
+ }
+}
+
+/// Stack rows on top of each other.
+///
+/// Add a `gap` between each row and uses the baseline of the `baseline`-th
+/// row for the whole frame. `alternator` controls the left/right alternating
+/// alignment behavior of `AlignPointElem` in the rows.
+pub fn stack(
+ rows: Vec<MathRun>,
+ align: FixedAlignment,
+ gap: Abs,
+ baseline: usize,
+ alternator: LeftRightAlternator,
+ minimum_ascent_descent: Option<(Abs, Abs)>,
+) -> Frame {
+ let rows: Vec<_> = rows.into_iter().flat_map(|r| r.rows()).collect();
+ let AlignmentResult { points, width } = alignments(&rows);
+ let rows: Vec<_> = rows
+ .into_iter()
+ .map(|row| row.into_line_frame(&points, alternator))
+ .collect();
+
+ let padded_height = |height: Abs| {
+ height.max(minimum_ascent_descent.map_or(Abs::zero(), |(a, d)| a + d))
+ };
+
+ let mut frame = Frame::soft(Size::new(
+ width,
+ rows.iter().map(|row| padded_height(row.height())).sum::<Abs>()
+ + rows.len().saturating_sub(1) as f64 * gap,
+ ));
+
+ let mut y = Abs::zero();
+ for (i, row) in rows.into_iter().enumerate() {
+ let x = if points.is_empty() {
+ align.position(width - row.width())
+ } else {
+ Abs::zero()
+ };
+ let ascent_padded_part = minimum_ascent_descent
+ .map_or(Abs::zero(), |(a, _)| (a - row.ascent()))
+ .max(Abs::zero());
+ let pos = Point::new(x, y + ascent_padded_part);
+ if i == baseline {
+ frame.set_baseline(y + row.baseline() + ascent_padded_part);
+ }
+ y += padded_height(row.height()) + gap;
+ frame.push_frame(pos, row);
+ }
+
+ frame
+}
+
+/// Determine the positions of the alignment points, according to the input rows combined.
+pub fn alignments(rows: &[MathRun]) -> AlignmentResult {
+ let mut widths = Vec::<Abs>::new();
+
+ let mut pending_width = Abs::zero();
+ for row in rows {
+ let mut width = Abs::zero();
+ let mut alignment_index = 0;
+
+ for fragment in row.iter() {
+ if matches!(fragment, MathFragment::Align) {
+ if alignment_index < widths.len() {
+ widths[alignment_index].set_max(width);
+ } else {
+ widths.push(width.max(pending_width));
+ }
+ width = Abs::zero();
+ alignment_index += 1;
+ } else {
+ width += fragment.width();
+ }
+ }
+ if widths.is_empty() {
+ pending_width.set_max(width);
+ } else if alignment_index < widths.len() {
+ widths[alignment_index].set_max(width);
+ } else {
+ widths.push(width.max(pending_width));
+ }
+ }
+
+ let mut points = widths;
+ for i in 1..points.len() {
+ let prev = points[i - 1];
+ points[i] += prev;
+ }
+ AlignmentResult {
+ width: points.last().copied().unwrap_or(pending_width),
+ points,
+ }
+}
+
+pub struct AlignmentResult {
+ pub points: Vec<Abs>,
+ pub width: Abs,
+}
diff --git a/crates/typst-layout/src/math/stretch.rs b/crates/typst-layout/src/math/stretch.rs
new file mode 100644
index 00000000..6dc82014
--- /dev/null
+++ b/crates/typst-layout/src/math/stretch.rs
@@ -0,0 +1,291 @@
+use ttf_parser::math::{GlyphAssembly, GlyphConstruction, GlyphPart};
+use ttf_parser::LazyArray16;
+use typst_library::diag::SourceResult;
+use typst_library::foundations::{Packed, Smart, StyleChain};
+use typst_library::layout::{Abs, Axis, Frame, Length, Point, Rel, Size};
+use typst_library::math::StretchElem;
+use typst_utils::Get;
+
+use super::{
+ delimiter_alignment, scaled_font_size, GlyphFragment, MathContext, MathFragment,
+ Scaled, VariantFragment,
+};
+
+/// Maximum number of times extenders can be repeated.
+const MAX_REPEATS: usize = 1024;
+
+/// Lays out a [`StretchElem`].
+#[typst_macros::time(name = "math.stretch", span = elem.span())]
+pub fn layout_stretch(
+ elem: &Packed<StretchElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let mut fragment = ctx.layout_into_fragment(elem.body(), styles)?;
+ stretch_fragment(
+ ctx,
+ styles,
+ &mut fragment,
+ None,
+ None,
+ elem.size(styles),
+ Abs::zero(),
+ );
+ ctx.push(fragment);
+ Ok(())
+}
+
+/// Attempts to stretch the given fragment by/to the amount given in stretch.
+pub fn stretch_fragment(
+ ctx: &mut MathContext,
+ styles: StyleChain,
+ fragment: &mut MathFragment,
+ axis: Option<Axis>,
+ relative_to: Option<Abs>,
+ stretch: Smart<Rel<Length>>,
+ short_fall: Abs,
+) {
+ let glyph = match fragment {
+ MathFragment::Glyph(glyph) => glyph.clone(),
+ MathFragment::Variant(variant) => {
+ GlyphFragment::new(ctx, styles, variant.c, variant.span)
+ }
+ _ => return,
+ };
+
+ // Return if we attempt to stretch along an axis which isn't stretchable,
+ // so that the original fragment isn't modified.
+ let Some(stretch_axis) = stretch_axis(ctx, &glyph) else { return };
+ let axis = axis.unwrap_or(stretch_axis);
+ if axis != stretch_axis {
+ return;
+ }
+
+ let relative_to_size = relative_to.unwrap_or_else(|| fragment.size().get(axis));
+
+ let mut variant = stretch_glyph(
+ ctx,
+ glyph,
+ stretch
+ .unwrap_or(Rel::one())
+ .at(scaled_font_size(ctx, styles))
+ .relative_to(relative_to_size),
+ short_fall,
+ axis,
+ );
+
+ if axis == Axis::Y {
+ variant.align_on_axis(ctx, delimiter_alignment(variant.c));
+ }
+
+ *fragment = MathFragment::Variant(variant);
+}
+
+/// Try to stretch a glyph to a desired width or height.
+///
+/// The resulting frame may not have the exact desired width.
+pub fn stretch_glyph(
+ ctx: &MathContext,
+ mut base: GlyphFragment,
+ target: Abs,
+ short_fall: Abs,
+ axis: Axis,
+) -> VariantFragment {
+ // If the base glyph is good enough, use it.
+ let advance = match axis {
+ Axis::X => base.width,
+ Axis::Y => base.height(),
+ };
+ let short_target = target - short_fall;
+ if short_target <= advance {
+ return base.into_variant();
+ }
+
+ let mut min_overlap = Abs::zero();
+ let construction = ctx
+ .table
+ .variants
+ .and_then(|variants| {
+ min_overlap = variants.min_connector_overlap.scaled(ctx, base.font_size);
+ match axis {
+ Axis::X => variants.horizontal_constructions,
+ Axis::Y => variants.vertical_constructions,
+ }
+ .get(base.id)
+ })
+ .unwrap_or(GlyphConstruction { assembly: None, variants: LazyArray16::new(&[]) });
+
+ // Search for a pre-made variant with a good advance.
+ let mut best_id = base.id;
+ let mut best_advance = base.width;
+ for variant in construction.variants {
+ best_id = variant.variant_glyph;
+ best_advance = base.font.to_em(variant.advance_measurement).at(base.font_size);
+ if short_target <= best_advance {
+ break;
+ }
+ }
+
+ // This is either good or the best we've got.
+ if short_target <= best_advance || construction.assembly.is_none() {
+ base.set_id(ctx, best_id);
+ return base.into_variant();
+ }
+
+ // Assemble from parts.
+ let assembly = construction.assembly.unwrap();
+ assemble(ctx, base, assembly, min_overlap, target, axis)
+}
+
+/// Return whether the glyph is stretchable and if it is, along which axis it
+/// can be stretched.
+fn stretch_axis(ctx: &MathContext, base: &GlyphFragment) -> Option<Axis> {
+ let base_id = base.id;
+ let vertical = ctx
+ .table
+ .variants
+ .and_then(|variants| variants.vertical_constructions.get(base_id))
+ .map(|_| Axis::Y);
+ let horizontal = ctx
+ .table
+ .variants
+ .and_then(|variants| variants.horizontal_constructions.get(base_id))
+ .map(|_| Axis::X);
+
+ match (vertical, horizontal) {
+ (vertical, None) => vertical,
+ (None, horizontal) => horizontal,
+ _ => {
+ // As far as we know, there aren't any glyphs that have both
+ // vertical and horizontal constructions. So for the time being, we
+ // will assume that a glyph cannot have both.
+ panic!("glyph {:?} has both vertical and horizontal constructions", base.c);
+ }
+ }
+}
+
+/// Assemble a glyph from parts.
+fn assemble(
+ ctx: &MathContext,
+ base: GlyphFragment,
+ assembly: GlyphAssembly,
+ min_overlap: Abs,
+ target: Abs,
+ axis: Axis,
+) -> VariantFragment {
+ // Determine the number of times the extenders need to be repeated as well
+ // as a ratio specifying how much to spread the parts apart
+ // (0 = maximal overlap, 1 = minimal overlap).
+ let mut full;
+ let mut ratio;
+ let mut repeat = 0;
+ loop {
+ full = Abs::zero();
+ ratio = 0.0;
+
+ let mut parts = parts(assembly, repeat).peekable();
+ let mut growable = Abs::zero();
+
+ while let Some(part) = parts.next() {
+ let mut advance = part.full_advance.scaled(ctx, base.font_size);
+ if let Some(next) = parts.peek() {
+ let max_overlap = part
+ .end_connector_length
+ .min(next.start_connector_length)
+ .scaled(ctx, base.font_size);
+
+ advance -= max_overlap;
+ growable += max_overlap - min_overlap;
+ }
+
+ full += advance;
+ }
+
+ if full < target {
+ let delta = target - full;
+ ratio = (delta / growable).min(1.0);
+ full += ratio * growable;
+ }
+
+ if target <= full || repeat >= MAX_REPEATS {
+ break;
+ }
+
+ repeat += 1;
+ }
+
+ let mut selected = vec![];
+ let mut parts = parts(assembly, repeat).peekable();
+ while let Some(part) = parts.next() {
+ let mut advance = part.full_advance.scaled(ctx, base.font_size);
+ if let Some(next) = parts.peek() {
+ let max_overlap = part
+ .end_connector_length
+ .min(next.start_connector_length)
+ .scaled(ctx, base.font_size);
+ advance -= max_overlap;
+ advance += ratio * (max_overlap - min_overlap);
+ }
+
+ let mut fragment = base.clone();
+ fragment.set_id(ctx, part.glyph_id);
+ selected.push((fragment, advance));
+ }
+
+ let size;
+ let baseline;
+ match axis {
+ Axis::X => {
+ let height = base.ascent + base.descent;
+ size = Size::new(full, height);
+ baseline = base.ascent;
+ }
+ Axis::Y => {
+ let axis = ctx.constants.axis_height().scaled(ctx, base.font_size);
+ let width = selected.iter().map(|(f, _)| f.width).max().unwrap_or_default();
+ size = Size::new(width, full);
+ baseline = full / 2.0 + axis;
+ }
+ }
+
+ let mut frame = Frame::soft(size);
+ let mut offset = Abs::zero();
+ frame.set_baseline(baseline);
+ frame.post_process_raw(base.dests, base.hidden);
+
+ for (fragment, advance) in selected {
+ let pos = match axis {
+ Axis::X => Point::new(offset, frame.baseline() - fragment.ascent),
+ Axis::Y => Point::with_y(full - offset - fragment.height()),
+ };
+ frame.push_frame(pos, fragment.into_frame());
+ offset += advance;
+ }
+
+ let accent_attach = match axis {
+ Axis::X => frame.width() / 2.0,
+ Axis::Y => base.accent_attach,
+ };
+
+ VariantFragment {
+ c: base.c,
+ frame,
+ font_size: base.font_size,
+ italics_correction: Abs::zero(),
+ accent_attach,
+ class: base.class,
+ math_size: base.math_size,
+ span: base.span,
+ limits: base.limits,
+ mid_stretched: None,
+ }
+}
+
+/// Return an iterator over the assembly's parts with extenders repeated the
+/// specified number of times.
+fn parts(assembly: GlyphAssembly, repeat: usize) -> impl Iterator<Item = GlyphPart> + '_ {
+ assembly.parts.into_iter().flat_map(move |part| {
+ let count = if part.part_flags.extender() { repeat } else { 1 };
+ std::iter::repeat(part).take(count)
+ })
+}
diff --git a/crates/typst-layout/src/math/text.rs b/crates/typst-layout/src/math/text.rs
new file mode 100644
index 00000000..df80b45a
--- /dev/null
+++ b/crates/typst-layout/src/math/text.rs
@@ -0,0 +1,344 @@
+use std::f64::consts::SQRT_2;
+
+use ecow::{eco_vec, EcoString};
+use typst_library::diag::SourceResult;
+use typst_library::foundations::{Packed, StyleChain, StyleVec};
+use typst_library::layout::{Abs, Size};
+use typst_library::math::{EquationElem, MathSize, MathVariant};
+use typst_library::text::{
+ BottomEdge, BottomEdgeMetric, TextElem, TextSize, TopEdge, TopEdgeMetric,
+};
+use typst_syntax::{is_newline, Span};
+use unicode_math_class::MathClass;
+use unicode_segmentation::UnicodeSegmentation;
+
+use super::{
+ scaled_font_size, FrameFragment, GlyphFragment, MathContext, MathFragment, MathRun,
+};
+
+/// Lays out a [`TextElem`].
+pub fn layout_text(
+ elem: &Packed<TextElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ let text = elem.text();
+ let span = elem.span();
+ let mut chars = text.chars();
+ let math_size = EquationElem::size_in(styles);
+
+ let fragment: MathFragment = if let Some(mut glyph) = chars
+ .next()
+ .filter(|_| chars.next().is_none())
+ .map(|c| styled_char(styles, c, true))
+ .and_then(|c| GlyphFragment::try_new(ctx, styles, c, span))
+ {
+ // A single letter that is available in the math font.
+ match math_size {
+ MathSize::Script => {
+ glyph.make_scriptsize(ctx);
+ }
+ MathSize::ScriptScript => {
+ glyph.make_scriptscriptsize(ctx);
+ }
+ _ => (),
+ }
+
+ if glyph.class == MathClass::Large {
+ let mut variant = if math_size == MathSize::Display {
+ let height = scaled!(ctx, styles, display_operator_min_height)
+ .max(SQRT_2 * glyph.height());
+ glyph.stretch_vertical(ctx, height, Abs::zero())
+ } else {
+ glyph.into_variant()
+ };
+ // TeXbook p 155. Large operators are always vertically centered on the axis.
+ variant.center_on_axis(ctx);
+ variant.into()
+ } else {
+ glyph.into()
+ }
+ } else if text.chars().all(|c| c.is_ascii_digit() || c == '.') {
+ // Numbers aren't that difficult.
+ let mut fragments = vec![];
+ for c in text.chars() {
+ let c = styled_char(styles, c, false);
+ fragments.push(GlyphFragment::new(ctx, styles, c, span).into());
+ }
+ let frame = MathRun::new(fragments).into_frame(ctx, styles);
+ FrameFragment::new(ctx, styles, frame).with_text_like(true).into()
+ } else {
+ let local = [
+ TextElem::set_top_edge(TopEdge::Metric(TopEdgeMetric::Bounds)),
+ TextElem::set_bottom_edge(BottomEdge::Metric(BottomEdgeMetric::Bounds)),
+ TextElem::set_size(TextSize(scaled_font_size(ctx, styles).into())),
+ ]
+ .map(|p| p.wrap());
+
+ // Anything else is handled by Typst's standard text layout.
+ let styles = styles.chain(&local);
+ let text: EcoString =
+ text.chars().map(|c| styled_char(styles, c, false)).collect();
+ if text.contains(is_newline) {
+ let mut fragments = vec![];
+ for (i, piece) in text.split(is_newline).enumerate() {
+ if i != 0 {
+ fragments.push(MathFragment::Linebreak);
+ }
+ if !piece.is_empty() {
+ fragments.push(layout_complex_text(piece, ctx, span, styles)?.into());
+ }
+ }
+ let mut frame = MathRun::new(fragments).into_frame(ctx, styles);
+ let axis = scaled!(ctx, styles, axis_height);
+ frame.set_baseline(frame.height() / 2.0 + axis);
+ FrameFragment::new(ctx, styles, frame).into()
+ } else {
+ layout_complex_text(&text, ctx, span, styles)?.into()
+ }
+ };
+
+ ctx.push(fragment);
+ Ok(())
+}
+
+/// Layout the given text string into a [`FrameFragment`].
+fn layout_complex_text(
+ text: &str,
+ ctx: &mut MathContext,
+ span: Span,
+ styles: StyleChain,
+) -> SourceResult<FrameFragment> {
+ // There isn't a natural width for a paragraph in a math environment;
+ // because it will be placed somewhere probably not at the left margin
+ // it will overflow. So emulate an `hbox` instead and allow the paragraph
+ // to extend as far as needed.
+ let spaced = text.graphemes(true).nth(1).is_some();
+ let elem = TextElem::packed(text).spanned(span);
+ let frame = (ctx.engine.routines.layout_inline)(
+ ctx.engine,
+ &StyleVec::wrap(eco_vec![elem]),
+ ctx.locator.next(&span),
+ styles,
+ false,
+ Size::splat(Abs::inf()),
+ false,
+ )?
+ .into_frame();
+
+ Ok(FrameFragment::new(ctx, styles, frame)
+ .with_class(MathClass::Alphabetic)
+ .with_text_like(true)
+ .with_spaced(spaced))
+}
+
+/// Select the correct styled math letter.
+///
+/// <https://www.w3.org/TR/mathml-core/#new-text-transform-mappings>
+/// <https://en.wikipedia.org/wiki/Mathematical_Alphanumeric_Symbols>
+fn styled_char(styles: StyleChain, c: char, auto_italic: bool) -> char {
+ use MathVariant::*;
+
+ let variant = EquationElem::variant_in(styles);
+ let bold = EquationElem::bold_in(styles);
+ let italic = EquationElem::italic_in(styles).unwrap_or(
+ auto_italic
+ && matches!(
+ c,
+ 'a'..='z' | 'ı' | 'ȷ' | 'A'..='Z' | 'α'..='ω' |
+ '∂' | 'ϵ' | 'ϑ' | 'ϰ' | 'ϕ' | 'ϱ' | 'ϖ'
+ )
+ && matches!(variant, Sans | Serif),
+ );
+
+ if let Some(c) = basic_exception(c) {
+ return c;
+ }
+
+ if let Some(c) = latin_exception(c, variant, bold, italic) {
+ return c;
+ }
+
+ if let Some(c) = greek_exception(c, variant, bold, italic) {
+ return c;
+ }
+
+ let base = match c {
+ 'A'..='Z' => 'A',
+ 'a'..='z' => 'a',
+ 'Α'..='Ω' => 'Α',
+ 'α'..='ω' => 'α',
+ '0'..='9' => '0',
+ // Hebrew Alef -> Dalet.
+ '\u{05D0}'..='\u{05D3}' => '\u{05D0}',
+ _ => return c,
+ };
+
+ let tuple = (variant, bold, italic);
+ let start = match c {
+ // Latin upper.
+ 'A'..='Z' => match tuple {
+ (Serif, false, false) => 0x0041,
+ (Serif, true, false) => 0x1D400,
+ (Serif, false, true) => 0x1D434,
+ (Serif, true, true) => 0x1D468,
+ (Sans, false, false) => 0x1D5A0,
+ (Sans, true, false) => 0x1D5D4,
+ (Sans, false, true) => 0x1D608,
+ (Sans, true, true) => 0x1D63C,
+ (Cal, false, _) => 0x1D49C,
+ (Cal, true, _) => 0x1D4D0,
+ (Frak, false, _) => 0x1D504,
+ (Frak, true, _) => 0x1D56C,
+ (Mono, _, _) => 0x1D670,
+ (Bb, _, _) => 0x1D538,
+ },
+
+ // Latin lower.
+ 'a'..='z' => match tuple {
+ (Serif, false, false) => 0x0061,
+ (Serif, true, false) => 0x1D41A,
+ (Serif, false, true) => 0x1D44E,
+ (Serif, true, true) => 0x1D482,
+ (Sans, false, false) => 0x1D5BA,
+ (Sans, true, false) => 0x1D5EE,
+ (Sans, false, true) => 0x1D622,
+ (Sans, true, true) => 0x1D656,
+ (Cal, false, _) => 0x1D4B6,
+ (Cal, true, _) => 0x1D4EA,
+ (Frak, false, _) => 0x1D51E,
+ (Frak, true, _) => 0x1D586,
+ (Mono, _, _) => 0x1D68A,
+ (Bb, _, _) => 0x1D552,
+ },
+
+ // Greek upper.
+ 'Α'..='Ω' => match tuple {
+ (Serif, false, false) => 0x0391,
+ (Serif, true, false) => 0x1D6A8,
+ (Serif, false, true) => 0x1D6E2,
+ (Serif, true, true) => 0x1D71C,
+ (Sans, _, false) => 0x1D756,
+ (Sans, _, true) => 0x1D790,
+ (Cal | Frak | Mono | Bb, _, _) => return c,
+ },
+
+ // Greek lower.
+ 'α'..='ω' => match tuple {
+ (Serif, false, false) => 0x03B1,
+ (Serif, true, false) => 0x1D6C2,
+ (Serif, false, true) => 0x1D6FC,
+ (Serif, true, true) => 0x1D736,
+ (Sans, _, false) => 0x1D770,
+ (Sans, _, true) => 0x1D7AA,
+ (Cal | Frak | Mono | Bb, _, _) => return c,
+ },
+
+ // Hebrew Alef -> Dalet.
+ '\u{05D0}'..='\u{05D3}' => 0x2135,
+
+ // Numbers.
+ '0'..='9' => match tuple {
+ (Serif, false, _) => 0x0030,
+ (Serif, true, _) => 0x1D7CE,
+ (Bb, _, _) => 0x1D7D8,
+ (Sans, false, _) => 0x1D7E2,
+ (Sans, true, _) => 0x1D7EC,
+ (Mono, _, _) => 0x1D7F6,
+ (Cal | Frak, _, _) => return c,
+ },
+
+ _ => unreachable!(),
+ };
+
+ std::char::from_u32(start + (c as u32 - base as u32)).unwrap()
+}
+
+fn basic_exception(c: char) -> Option<char> {
+ Some(match c {
+ '〈' => '⟨',
+ '〉' => '⟩',
+ '《' => '⟪',
+ '》' => '⟫',
+ _ => return None,
+ })
+}
+
+fn latin_exception(
+ c: char,
+ variant: MathVariant,
+ bold: bool,
+ italic: bool,
+) -> Option<char> {
+ use MathVariant::*;
+ Some(match (c, variant, bold, italic) {
+ ('B', Cal, false, _) => 'ℬ',
+ ('E', Cal, false, _) => 'ℰ',
+ ('F', Cal, false, _) => 'ℱ',
+ ('H', Cal, false, _) => 'ℋ',
+ ('I', Cal, false, _) => 'ℐ',
+ ('L', Cal, false, _) => 'ℒ',
+ ('M', Cal, false, _) => 'ℳ',
+ ('R', Cal, false, _) => 'ℛ',
+ ('C', Frak, false, _) => 'ℭ',
+ ('H', Frak, false, _) => 'ℌ',
+ ('I', Frak, false, _) => 'ℑ',
+ ('R', Frak, false, _) => 'ℜ',
+ ('Z', Frak, false, _) => 'ℨ',
+ ('C', Bb, ..) => 'ℂ',
+ ('H', Bb, ..) => 'ℍ',
+ ('N', Bb, ..) => 'ℕ',
+ ('P', Bb, ..) => 'ℙ',
+ ('Q', Bb, ..) => 'ℚ',
+ ('R', Bb, ..) => 'ℝ',
+ ('Z', Bb, ..) => 'ℤ',
+ ('D', Bb, _, true) => 'ⅅ',
+ ('d', Bb, _, true) => 'ⅆ',
+ ('e', Bb, _, true) => 'ⅇ',
+ ('i', Bb, _, true) => 'ⅈ',
+ ('j', Bb, _, true) => 'ⅉ',
+ ('h', Serif, false, true) => 'ℎ',
+ ('e', Cal, false, _) => 'ℯ',
+ ('g', Cal, false, _) => 'ℊ',
+ ('o', Cal, false, _) => 'ℴ',
+ ('ı', Serif, .., true) => '𝚤',
+ ('ȷ', Serif, .., true) => '𝚥',
+ _ => return None,
+ })
+}
+
+fn greek_exception(
+ c: char,
+ variant: MathVariant,
+ bold: bool,
+ italic: bool,
+) -> Option<char> {
+ use MathVariant::*;
+ let list = match c {
+ 'ϴ' => ['𝚹', '𝛳', '𝜭', '𝝧', '𝞡', 'ϴ'],
+ '∇' => ['𝛁', '𝛻', '𝜵', '𝝯', '𝞩', '∇'],
+ '∂' => ['𝛛', '𝜕', '𝝏', '𝞉', '𝟃', '∂'],
+ 'ϵ' => ['𝛜', '𝜖', '𝝐', '𝞊', '𝟄', 'ϵ'],
+ 'ϑ' => ['𝛝', '𝜗', '𝝑', '𝞋', '𝟅', 'ϑ'],
+ 'ϰ' => ['𝛞', '𝜘', '𝝒', '𝞌', '𝟆', 'ϰ'],
+ 'ϕ' => ['𝛟', '𝜙', '𝝓', '𝞍', '𝟇', 'ϕ'],
+ 'ϱ' => ['𝛠', '𝜚', '𝝔', '𝞎', '𝟈', 'ϱ'],
+ 'ϖ' => ['𝛡', '𝜛', '𝝕', '𝞏', '𝟉', 'ϖ'],
+ 'Γ' => ['𝚪', '𝛤', '𝜞', '𝝘', '𝞒', 'ℾ'],
+ 'γ' => ['𝛄', '𝛾', '𝜸', '𝝲', '𝞬', 'ℽ'],
+ 'Π' => ['𝚷', '𝛱', '𝜫', '𝝥', '𝞟', 'ℿ'],
+ 'π' => ['𝛑', '𝜋', '𝝅', '𝝿', '𝞹', 'ℼ'],
+ '∑' => ['∑', '∑', '∑', '∑', '∑', '⅀'],
+ _ => return None,
+ };
+
+ Some(match (variant, bold, italic) {
+ (Serif, true, false) => list[0],
+ (Serif, false, true) => list[1],
+ (Serif, true, true) => list[2],
+ (Sans, _, false) => list[3],
+ (Sans, _, true) => list[4],
+ (Bb, ..) => list[5],
+ _ => return None,
+ })
+}
diff --git a/crates/typst-layout/src/math/underover.rs b/crates/typst-layout/src/math/underover.rs
new file mode 100644
index 00000000..b1d4825b
--- /dev/null
+++ b/crates/typst-layout/src/math/underover.rs
@@ -0,0 +1,327 @@
+use typst_library::diag::SourceResult;
+use typst_library::foundations::{Content, Packed, StyleChain};
+use typst_library::layout::{Abs, Em, FixedAlignment, Frame, FrameItem, Point, Size};
+use typst_library::math::{
+ OverbraceElem, OverbracketElem, OverlineElem, OverparenElem, OvershellElem,
+ UnderbraceElem, UnderbracketElem, UnderlineElem, UnderparenElem, UndershellElem,
+};
+use typst_library::text::TextElem;
+use typst_library::visualize::{FixedStroke, Geometry};
+use typst_syntax::Span;
+
+use super::{
+ scaled_font_size, stack, style_cramped, style_for_subscript, style_for_superscript,
+ FrameFragment, GlyphFragment, LeftRightAlternator, MathContext, MathRun,
+};
+
+const BRACE_GAP: Em = Em::new(0.25);
+const BRACKET_GAP: Em = Em::new(0.25);
+const PAREN_GAP: Em = Em::new(0.25);
+const SHELL_GAP: Em = Em::new(0.25);
+
+/// A marker to distinguish under- and overlines.
+enum Position {
+ Under,
+ Over,
+}
+
+/// Lays out an [`UnderlineElem`].
+#[typst_macros::time(name = "math.underline", span = elem.span())]
+pub fn layout_underline(
+ elem: &Packed<UnderlineElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ layout_underoverline(ctx, styles, elem.body(), elem.span(), Position::Under)
+}
+
+/// Lays out an [`OverlineElem`].
+#[typst_macros::time(name = "math.overline", span = elem.span())]
+pub fn layout_overline(
+ elem: &Packed<OverlineElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ layout_underoverline(ctx, styles, elem.body(), elem.span(), Position::Over)
+}
+
+/// Lays out an [`UnderbraceElem`].
+#[typst_macros::time(name = "math.underbrace", span = elem.span())]
+pub fn layout_underbrace(
+ elem: &Packed<UnderbraceElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ layout_underoverspreader(
+ ctx,
+ styles,
+ elem.body(),
+ &elem.annotation(styles),
+ '⏟',
+ BRACE_GAP,
+ Position::Under,
+ elem.span(),
+ )
+}
+
+/// Lays out an [`OverbraceElem`].
+#[typst_macros::time(name = "math.overbrace", span = elem.span())]
+pub fn layout_overbrace(
+ elem: &Packed<OverbraceElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ layout_underoverspreader(
+ ctx,
+ styles,
+ elem.body(),
+ &elem.annotation(styles),
+ '⏞',
+ BRACE_GAP,
+ Position::Over,
+ elem.span(),
+ )
+}
+
+/// Lays out an [`UnderbracketElem`].
+#[typst_macros::time(name = "math.underbracket", span = elem.span())]
+pub fn layout_underbracket(
+ elem: &Packed<UnderbracketElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ layout_underoverspreader(
+ ctx,
+ styles,
+ elem.body(),
+ &elem.annotation(styles),
+ '⎵',
+ BRACKET_GAP,
+ Position::Under,
+ elem.span(),
+ )
+}
+
+/// Lays out an [`OverbracketElem`].
+#[typst_macros::time(name = "math.overbracket", span = elem.span())]
+pub fn layout_overbracket(
+ elem: &Packed<OverbracketElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ layout_underoverspreader(
+ ctx,
+ styles,
+ elem.body(),
+ &elem.annotation(styles),
+ '⎴',
+ BRACKET_GAP,
+ Position::Over,
+ elem.span(),
+ )
+}
+
+/// Lays out an [`UnderparenElem`].
+#[typst_macros::time(name = "math.underparen", span = elem.span())]
+pub fn layout_underparen(
+ elem: &Packed<UnderparenElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ layout_underoverspreader(
+ ctx,
+ styles,
+ elem.body(),
+ &elem.annotation(styles),
+ '⏝',
+ PAREN_GAP,
+ Position::Under,
+ elem.span(),
+ )
+}
+
+/// Lays out an [`OverparenElem`].
+#[typst_macros::time(name = "math.overparen", span = elem.span())]
+pub fn layout_overparen(
+ elem: &Packed<OverparenElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ layout_underoverspreader(
+ ctx,
+ styles,
+ elem.body(),
+ &elem.annotation(styles),
+ '⏜',
+ PAREN_GAP,
+ Position::Over,
+ elem.span(),
+ )
+}
+
+/// Lays out an [`UndershellElem`].
+#[typst_macros::time(name = "math.undershell", span = elem.span())]
+pub fn layout_undershell(
+ elem: &Packed<UndershellElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ layout_underoverspreader(
+ ctx,
+ styles,
+ elem.body(),
+ &elem.annotation(styles),
+ '⏡',
+ SHELL_GAP,
+ Position::Under,
+ elem.span(),
+ )
+}
+
+/// Lays out an [`OvershellElem`].
+#[typst_macros::time(name = "math.overshell", span = elem.span())]
+pub fn layout_overshell(
+ elem: &Packed<OvershellElem>,
+ ctx: &mut MathContext,
+ styles: StyleChain,
+) -> SourceResult<()> {
+ layout_underoverspreader(
+ ctx,
+ styles,
+ elem.body(),
+ &elem.annotation(styles),
+ '⏠',
+ SHELL_GAP,
+ Position::Over,
+ elem.span(),
+ )
+}
+
+/// layout under- or overlined content.
+fn layout_underoverline(
+ ctx: &mut MathContext,
+ styles: StyleChain,
+ body: &Content,
+ span: Span,
+ position: Position,
+) -> SourceResult<()> {
+ let (extra_height, content, line_pos, content_pos, baseline, bar_height, line_adjust);
+ match position {
+ Position::Under => {
+ let sep = scaled!(ctx, styles, underbar_extra_descender);
+ bar_height = scaled!(ctx, styles, underbar_rule_thickness);
+ let gap = scaled!(ctx, styles, underbar_vertical_gap);
+ extra_height = sep + bar_height + gap;
+
+ content = ctx.layout_into_fragment(body, styles)?;
+
+ line_pos = Point::with_y(content.height() + gap + bar_height / 2.0);
+ content_pos = Point::zero();
+ baseline = content.ascent();
+ line_adjust = -content.italics_correction();
+ }
+ Position::Over => {
+ let sep = scaled!(ctx, styles, overbar_extra_ascender);
+ bar_height = scaled!(ctx, styles, overbar_rule_thickness);
+ let gap = scaled!(ctx, styles, overbar_vertical_gap);
+ extra_height = sep + bar_height + gap;
+
+ let cramped = style_cramped();
+ content = ctx.layout_into_fragment(body, styles.chain(&cramped))?;
+
+ line_pos = Point::with_y(sep + bar_height / 2.0);
+ content_pos = Point::with_y(extra_height);
+ baseline = content.ascent() + extra_height;
+ line_adjust = Abs::zero();
+ }
+ }
+
+ let width = content.width();
+ let height = content.height() + extra_height;
+ let size = Size::new(width, height);
+ let line_width = width + line_adjust;
+
+ let content_class = content.class();
+ let content_is_text_like = content.is_text_like();
+ let content_italics_correction = content.italics_correction();
+ let mut frame = Frame::soft(size);
+ frame.set_baseline(baseline);
+ frame.push_frame(content_pos, content.into_frame());
+ frame.push(
+ line_pos,
+ FrameItem::Shape(
+ Geometry::Line(Point::with_x(line_width)).stroked(FixedStroke {
+ paint: TextElem::fill_in(styles).as_decoration(),
+ thickness: bar_height,
+ ..FixedStroke::default()
+ }),
+ span,
+ ),
+ );
+
+ ctx.push(
+ FrameFragment::new(ctx, styles, frame)
+ .with_class(content_class)
+ .with_text_like(content_is_text_like)
+ .with_italics_correction(content_italics_correction),
+ );
+
+ Ok(())
+}
+
+/// Layout an over- or underbrace-like object.
+#[allow(clippy::too_many_arguments)]
+fn layout_underoverspreader(
+ ctx: &mut MathContext,
+ styles: StyleChain,
+ body: &Content,
+ annotation: &Option<Content>,
+ c: char,
+ gap: Em,
+ position: Position,
+ span: Span,
+) -> SourceResult<()> {
+ let font_size = scaled_font_size(ctx, styles);
+ let gap = gap.at(font_size);
+ let body = ctx.layout_into_run(body, styles)?;
+ let body_class = body.class();
+ let body = body.into_fragment(ctx, styles);
+ let glyph = GlyphFragment::new(ctx, styles, c, span);
+ let stretched = glyph.stretch_horizontal(ctx, body.width(), Abs::zero());
+
+ let mut rows = vec![];
+ let baseline = match position {
+ Position::Under => {
+ rows.push(MathRun::new(vec![body]));
+ rows.push(stretched.into());
+ if let Some(annotation) = annotation {
+ let under_style = style_for_subscript(styles);
+ let annotation_styles = styles.chain(&under_style);
+ rows.push(ctx.layout_into_run(annotation, annotation_styles)?);
+ }
+ 0
+ }
+ Position::Over => {
+ if let Some(annotation) = annotation {
+ let over_style = style_for_superscript(styles);
+ let annotation_styles = styles.chain(&over_style);
+ rows.push(ctx.layout_into_run(annotation, annotation_styles)?);
+ }
+ rows.push(stretched.into());
+ rows.push(MathRun::new(vec![body]));
+ rows.len() - 1
+ }
+ };
+
+ let frame = stack(
+ rows,
+ FixedAlignment::Center,
+ gap,
+ baseline,
+ LeftRightAlternator::Right,
+ None,
+ );
+ ctx.push(FrameFragment::new(ctx, styles, frame).with_class(body_class));
+
+ Ok(())
+}
diff --git a/crates/typst-layout/src/pad.rs b/crates/typst-layout/src/pad.rs
new file mode 100644
index 00000000..00badcdb
--- /dev/null
+++ b/crates/typst-layout/src/pad.rs
@@ -0,0 +1,93 @@
+use typst_library::diag::SourceResult;
+use typst_library::engine::Engine;
+use typst_library::foundations::{Packed, Resolve, StyleChain};
+use typst_library::introspection::Locator;
+use typst_library::layout::{
+ Abs, Fragment, Frame, PadElem, Point, Regions, Rel, Sides, Size,
+};
+
+/// Layout the padded content.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_pad(
+ elem: &Packed<PadElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+) -> SourceResult<Fragment> {
+ let padding = Sides::new(
+ elem.left(styles).resolve(styles),
+ elem.top(styles).resolve(styles),
+ elem.right(styles).resolve(styles),
+ elem.bottom(styles).resolve(styles),
+ );
+
+ let mut backlog = vec![];
+ let pod = regions.map(&mut backlog, |size| shrink(size, &padding));
+
+ // Layout child into padded regions.
+ let mut fragment = crate::layout_fragment(engine, &elem.body, locator, styles, pod)?;
+
+ for frame in &mut fragment {
+ grow(frame, &padding);
+ }
+
+ Ok(fragment)
+}
+
+/// Shrink a region size by an inset relative to the size itself.
+pub fn shrink(size: Size, inset: &Sides<Rel<Abs>>) -> Size {
+ size - inset.sum_by_axis().relative_to(size)
+}
+
+/// Shrink the components of possibly multiple `Regions` by an inset relative to
+/// the regions themselves.
+pub fn shrink_multiple(
+ size: &mut Size,
+ full: &mut Abs,
+ backlog: &mut [Abs],
+ last: &mut Option<Abs>,
+ inset: &Sides<Rel<Abs>>,
+) {
+ let summed = inset.sum_by_axis();
+ *size -= summed.relative_to(*size);
+ *full -= summed.y.relative_to(*full);
+ for item in backlog {
+ *item -= summed.y.relative_to(*item);
+ }
+ *last = last.map(|v| v - summed.y.relative_to(v));
+}
+
+/// Grow a frame's size by an inset 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 inset,
+/// r be the right inset,
+/// 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)
+pub fn grow(frame: &mut Frame, inset: &Sides<Rel<Abs>>) {
+ // Apply the padding inversely such that the grown size padded
+ // yields the frame's size.
+ let padded = frame
+ .size()
+ .zip_map(inset.sum_by_axis(), |s, p| (s + p.abs) / (1.0 - p.rel.get()));
+
+ let inset = inset.relative_to(padded);
+ let offset = Point::new(inset.left, inset.top);
+
+ // Grow the frame and translate everything in the frame inwards.
+ frame.set_size(padded);
+ frame.translate(offset);
+}
diff --git a/crates/typst-layout/src/pages/collect.rs b/crates/typst-layout/src/pages/collect.rs
new file mode 100644
index 00000000..1903d6ac
--- /dev/null
+++ b/crates/typst-layout/src/pages/collect.rs
@@ -0,0 +1,165 @@
+use std::collections::HashSet;
+
+use typst_library::foundations::StyleChain;
+use typst_library::introspection::{Locator, SplitLocator, Tag, TagElem};
+use typst_library::layout::{PagebreakElem, Parity};
+use typst_library::routines::Pair;
+
+/// An item in page layout.
+pub enum Item<'a> {
+ /// A page run containing content. All runs will be layouted in parallel.
+ Run(&'a [Pair<'a>], StyleChain<'a>, Locator<'a>),
+ /// Tags in between pages. These will be prepended to the first start of
+ /// the next page, or appended at the very end of the final page if there is
+ /// no next page.
+ Tags(&'a [Pair<'a>]),
+ /// An instruction to possibly add a page to bring the page number parity to
+ /// the desired state. Can only be done at the end, sequentially, because it
+ /// requires knowledge of the concrete page number.
+ Parity(Parity, StyleChain<'a>, Locator<'a>),
+}
+
+/// Slices up the children into logical parts, processing styles and handling
+/// things like tags and weak pagebreaks.
+pub fn collect<'a>(
+ mut children: &'a mut [Pair<'a>],
+ mut locator: SplitLocator<'a>,
+ mut initial: StyleChain<'a>,
+) -> Vec<Item<'a>> {
+ // The collected page-level items.
+ let mut items: Vec<Item<'a>> = vec![];
+ // When this is true, an empty page should be added to `pages` at the end.
+ let mut staged_empty_page = true;
+
+ // The `children` are a flat list of flow-level items and pagebreaks. This
+ // loops splits it up into pagebreaks and consecutive slices of
+ // non-pagebreaks. From these pieces, we build page items that we can then
+ // layout in parallel.
+ while let Some(&(elem, styles)) = children.first() {
+ if let Some(pagebreak) = elem.to_packed::<PagebreakElem>() {
+ // Add a blank page if we encounter a strong pagebreak and there was
+ // a staged empty page.
+ let strong = !pagebreak.weak(styles);
+ if strong && staged_empty_page {
+ let locator = locator.next(&elem.span());
+ items.push(Item::Run(&[], initial, locator));
+ }
+
+ // Add an instruction to adjust the page parity if requested.
+ if let Some(parity) = pagebreak.to(styles) {
+ let locator = locator.next(&elem.span());
+ items.push(Item::Parity(parity, styles, locator));
+ }
+
+ // The initial styles for the next page are ours unless this is a
+ // "boundary" pagebreak. Such a pagebreak is generated at the end of
+ // the scope of a page set rule to ensure a page boundary. It's
+ // styles correspond to the styles _before_ the page set rule, so we
+ // don't want to apply it to a potential empty page.
+ if !pagebreak.boundary(styles) {
+ initial = styles;
+ }
+
+ // Stage an empty page after a strong pagebreak.
+ staged_empty_page |= strong;
+
+ // Advance to the next child.
+ children = &mut children[1..];
+ } else {
+ // Find the end of the consecutive non-pagebreak run.
+ let end =
+ children.iter().take_while(|(c, _)| !c.is::<PagebreakElem>()).count();
+
+ // Migrate start tags without accompanying end tags from before a
+ // pagebreak to after it.
+ let end = migrate_unterminated_tags(children, end);
+ if end == 0 {
+ continue;
+ }
+
+ // Advance to the rest of the children.
+ let (group, rest) = children.split_at_mut(end);
+ children = rest;
+
+ // If all that is left now are tags, then we don't want to add a
+ // page just for them (since no group would have been detected in a
+ // tagless layout and tags should never affect the layout). For this
+ // reason, we remember them in a `PageItem::Tags` and later insert
+ // them at the _very start_ of the next page, even before the
+ // header.
+ //
+ // We don't do this if all that's left is end boundary pagebreaks
+ // and if an empty page is still staged, since then we can just
+ // conceptually replace that final page with us.
+ if group.iter().all(|(c, _)| c.is::<TagElem>())
+ && !(staged_empty_page
+ && children.iter().all(|&(c, s)| {
+ c.to_packed::<PagebreakElem>().is_some_and(|c| c.boundary(s))
+ }))
+ {
+ items.push(Item::Tags(group));
+ continue;
+ }
+
+ // Record a page run and then disregard a staged empty page because
+ // we have real content now.
+ let locator = locator.next(&elem.span());
+ items.push(Item::Run(group, initial, locator));
+ staged_empty_page = false;
+ }
+ }
+
+ // Flush a staged empty page.
+ if staged_empty_page {
+ items.push(Item::Run(&[], initial, locator.next(&())));
+ }
+
+ items
+}
+
+/// Migrates trailing start tags without accompanying end tags from before
+/// a pagebreak to after it. Returns the position right after the last
+/// non-migrated tag.
+///
+/// This is important because we want the positions of introspectable elements
+/// that technically started before a pagebreak, but have no visible content
+/// yet, to be after the pagebreak. A typical case where this happens is `show
+/// heading: it => pagebreak() + it`.
+fn migrate_unterminated_tags(children: &mut [Pair], mid: usize) -> usize {
+ // Compute the range from before the first trailing tag to after the last
+ // following pagebreak.
+ let (before, after) = children.split_at(mid);
+ let start = mid - before.iter().rev().take_while(|&(c, _)| c.is::<TagElem>()).count();
+ let end = mid + after.iter().take_while(|&(c, _)| c.is::<PagebreakElem>()).count();
+
+ // Determine the set of tag locations which we won't migrate (because they
+ // are terminated).
+ let excluded: HashSet<_> = children[start..mid]
+ .iter()
+ .filter_map(|(c, _)| match c.to_packed::<TagElem>()?.tag {
+ Tag::Start(_) => None,
+ Tag::End(loc, _) => Some(loc),
+ })
+ .collect();
+
+ // A key function that partitions the area of interest into three groups:
+ // Excluded tags (-1) | Pagebreaks (0) | Migrated tags (1).
+ let key = |(c, _): &Pair| match c.to_packed::<TagElem>() {
+ Some(elem) => {
+ if excluded.contains(&elem.tag.location()) {
+ -1
+ } else {
+ 1
+ }
+ }
+ None => 0,
+ };
+
+ // Partition the children using a *stable* sort. While it would be possible
+ // to write a more efficient direct algorithm for this, the sort version is
+ // less likely to have bugs and this is absolutely not on a hot path.
+ children[start..end].sort_by_key(key);
+
+ // Compute the new end index, right before the pagebreaks.
+ start + children[start..end].iter().take_while(|pair| key(pair) == -1).count()
+}
diff --git a/crates/typst-layout/src/pages/finalize.rs b/crates/typst-layout/src/pages/finalize.rs
new file mode 100644
index 00000000..8df6cb16
--- /dev/null
+++ b/crates/typst-layout/src/pages/finalize.rs
@@ -0,0 +1,73 @@
+use typst_library::diag::SourceResult;
+use typst_library::engine::Engine;
+use typst_library::introspection::{ManualPageCounter, Tag};
+use typst_library::layout::{Frame, FrameItem, Page, Point};
+
+use super::LayoutedPage;
+
+/// Piece together the inner page frame and the marginals. We can only do this
+/// at the very end because inside/outside margins require knowledge of the
+/// physical page number, which is unknown during parallel layout.
+pub fn finalize(
+ engine: &mut Engine,
+ counter: &mut ManualPageCounter,
+ tags: &mut Vec<Tag>,
+ LayoutedPage {
+ inner,
+ mut margin,
+ binding,
+ two_sided,
+ header,
+ footer,
+ background,
+ foreground,
+ fill,
+ numbering,
+ }: LayoutedPage,
+) -> SourceResult<Page> {
+ // 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.
+ if two_sided && binding.swap(counter.physical()) {
+ std::mem::swap(&mut margin.left, &mut margin.right);
+ }
+
+ // Create a frame for the full page.
+ let mut frame = Frame::hard(inner.size() + margin.sum_by_axis());
+
+ // Add tags.
+ for tag in tags.drain(..) {
+ frame.push(Point::zero(), FrameItem::Tag(tag));
+ }
+
+ // Add the "before" marginals. The order in which we push things here is
+ // important as it affects the relative ordering of introspectable elements
+ // and thus how counters resolve.
+ if let Some(background) = background {
+ frame.push_frame(Point::zero(), background);
+ }
+ if let Some(header) = header {
+ frame.push_frame(Point::with_x(margin.left), header);
+ }
+
+ // Add the inner contents.
+ frame.push_frame(Point::new(margin.left, margin.top), inner);
+
+ // Add the "after" marginals.
+ if let Some(footer) = footer {
+ let y = frame.height() - footer.height();
+ frame.push_frame(Point::new(margin.left, y), footer);
+ }
+ if let Some(foreground) = foreground {
+ frame.push_frame(Point::zero(), foreground);
+ }
+
+ // Apply counter updates from within the page to the manual page counter.
+ counter.visit(engine, &frame)?;
+
+ // Get this page's number and then bump the counter for the next page.
+ let number = counter.logical();
+ counter.step();
+
+ Ok(Page { frame, fill, numbering, number })
+}
diff --git a/crates/typst-layout/src/pages/mod.rs b/crates/typst-layout/src/pages/mod.rs
new file mode 100644
index 00000000..b969749a
--- /dev/null
+++ b/crates/typst-layout/src/pages/mod.rs
@@ -0,0 +1,159 @@
+//! Layout of content into a [`Document`].
+
+mod collect;
+mod finalize;
+mod run;
+
+use comemo::{Tracked, TrackedMut};
+use typst_library::diag::SourceResult;
+use typst_library::engine::{Engine, Route, Sink, Traced};
+use typst_library::foundations::{Content, StyleChain};
+use typst_library::introspection::{
+ Introspector, Locator, ManualPageCounter, SplitLocator, TagElem,
+};
+use typst_library::layout::{FrameItem, Page, Point};
+use typst_library::model::{Document, DocumentInfo};
+use typst_library::routines::{Arenas, Pair, RealizationKind, Routines};
+use typst_library::World;
+
+use self::collect::{collect, Item};
+use self::finalize::finalize;
+use self::run::{layout_blank_page, layout_page_run, LayoutedPage};
+
+/// Layout content into a document.
+///
+/// This first performs root-level realization and then lays out the resulting
+/// elements. In contrast to [`layout_fragment`](crate::layout_fragment),
+/// this does not take regions since the regions are defined by the page
+/// configuration in the content and style chain.
+#[typst_macros::time(name = "document")]
+pub fn layout_document(
+ engine: &mut Engine,
+ content: &Content,
+ styles: StyleChain,
+) -> SourceResult<Document> {
+ layout_document_impl(
+ engine.routines,
+ engine.world,
+ engine.introspector,
+ engine.traced,
+ TrackedMut::reborrow_mut(&mut engine.sink),
+ engine.route.track(),
+ content,
+ styles,
+ )
+}
+
+/// The internal implementation of `layout_document`.
+#[comemo::memoize]
+#[allow(clippy::too_many_arguments)]
+fn layout_document_impl(
+ routines: &Routines,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ traced: Tracked<Traced>,
+ sink: TrackedMut<Sink>,
+ route: Tracked<Route>,
+ content: &Content,
+ styles: StyleChain,
+) -> SourceResult<Document> {
+ let mut locator = Locator::root().split();
+ let mut engine = Engine {
+ routines,
+ world,
+ introspector,
+ traced,
+ sink,
+ route: Route::extend(route).unnested(),
+ };
+
+ // Mark the external styles as "outside" so that they are valid at the page
+ // level.
+ let styles = styles.to_map().outside();
+ let styles = StyleChain::new(&styles);
+
+ let arenas = Arenas::default();
+ let mut info = DocumentInfo::default();
+ let mut children = (engine.routines.realize)(
+ RealizationKind::Root(&mut info),
+ &mut engine,
+ &mut locator,
+ &arenas,
+ content,
+ styles,
+ )?;
+
+ let pages = layout_pages(&mut engine, &mut children, locator, styles)?;
+ let introspector = Introspector::new(&pages);
+
+ Ok(Document { pages, info, introspector })
+}
+
+/// Layouts the document's pages.
+fn layout_pages<'a>(
+ engine: &mut Engine,
+ children: &'a mut [Pair<'a>],
+ locator: SplitLocator<'a>,
+ styles: StyleChain<'a>,
+) -> SourceResult<Vec<Page>> {
+ // Slice up the children into logical parts.
+ let items = collect(children, locator, styles);
+
+ // Layout the page runs in parallel.
+ let mut runs = engine.parallelize(
+ items.iter().filter_map(|item| match item {
+ Item::Run(children, initial, locator) => {
+ Some((children, initial, locator.relayout()))
+ }
+ _ => None,
+ }),
+ |engine, (children, initial, locator)| {
+ layout_page_run(engine, children, locator, *initial)
+ },
+ );
+
+ let mut pages = vec![];
+ let mut tags = vec![];
+ let mut counter = ManualPageCounter::new();
+
+ // Collect and finalize the runs, handling things like page parity and tags
+ // between pages.
+ for item in &items {
+ match item {
+ Item::Run(..) => {
+ let layouted = runs.next().unwrap()?;
+ for layouted in layouted {
+ let page = finalize(engine, &mut counter, &mut tags, layouted)?;
+ pages.push(page);
+ }
+ }
+ Item::Parity(parity, initial, locator) => {
+ if !parity.matches(pages.len()) {
+ continue;
+ }
+
+ let layouted = layout_blank_page(engine, locator.relayout(), *initial)?;
+ let page = finalize(engine, &mut counter, &mut tags, layouted)?;
+ pages.push(page);
+ }
+ Item::Tags(items) => {
+ tags.extend(
+ items
+ .iter()
+ .filter_map(|(c, _)| c.to_packed::<TagElem>())
+ .map(|elem| elem.tag.clone()),
+ );
+ }
+ }
+ }
+
+ // Add the remaining tags to the very end of the last page.
+ if !tags.is_empty() {
+ let last = pages.last_mut().unwrap();
+ let pos = Point::with_y(last.frame.height());
+ last.frame
+ .push_multiple(tags.into_iter().map(|tag| (pos, FrameItem::Tag(tag))));
+ }
+
+ Ok(pages)
+}
diff --git a/crates/typst-layout/src/pages/run.rs b/crates/typst-layout/src/pages/run.rs
new file mode 100644
index 00000000..15735faa
--- /dev/null
+++ b/crates/typst-layout/src/pages/run.rs
@@ -0,0 +1,267 @@
+use comemo::{Track, Tracked, TrackedMut};
+use typst_library::diag::SourceResult;
+use typst_library::engine::{Engine, Route, Sink, Traced};
+use typst_library::foundations::{
+ Content, NativeElement, Resolve, Smart, StyleChain, Styles,
+};
+use typst_library::introspection::{
+ Counter, CounterDisplayElem, CounterKey, Introspector, Locator, LocatorLink, TagElem,
+};
+use typst_library::layout::{
+ Abs, AlignElem, Alignment, Axes, Binding, ColumnsElem, Dir, Frame, HAlignment,
+ Length, OuterVAlignment, PageElem, Paper, Region, Regions, Rel, Sides, Size,
+ VAlignment,
+};
+use typst_library::model::Numbering;
+use typst_library::routines::{Pair, Routines};
+use typst_library::text::TextElem;
+use typst_library::visualize::Paint;
+use typst_library::World;
+use typst_utils::Numeric;
+
+use crate::flow::layout_flow;
+
+/// A mostly finished layout for one page. Needs only knowledge of its exact
+/// page number to be finalized into a `Page`. (Because the margins can depend
+/// on the page number.)
+#[derive(Clone)]
+pub struct LayoutedPage {
+ pub inner: Frame,
+ pub margin: Sides<Abs>,
+ pub binding: Binding,
+ pub two_sided: bool,
+ pub header: Option<Frame>,
+ pub footer: Option<Frame>,
+ pub background: Option<Frame>,
+ pub foreground: Option<Frame>,
+ pub fill: Smart<Option<Paint>>,
+ pub numbering: Option<Numbering>,
+}
+
+/// Layout a single page suitable for parity adjustment.
+pub fn layout_blank_page(
+ engine: &mut Engine,
+ locator: Locator,
+ initial: StyleChain,
+) -> SourceResult<LayoutedPage> {
+ let layouted = layout_page_run(engine, &[], locator, initial)?;
+ Ok(layouted.into_iter().next().unwrap())
+}
+
+/// Layout a page run with uniform properties.
+#[typst_macros::time(name = "page run")]
+pub fn layout_page_run(
+ engine: &mut Engine,
+ children: &[Pair],
+ locator: Locator,
+ initial: StyleChain,
+) -> SourceResult<Vec<LayoutedPage>> {
+ layout_page_run_impl(
+ engine.routines,
+ engine.world,
+ engine.introspector,
+ engine.traced,
+ TrackedMut::reborrow_mut(&mut engine.sink),
+ engine.route.track(),
+ children,
+ locator.track(),
+ initial,
+ )
+}
+
+/// The internal implementation of `layout_page_run`.
+#[comemo::memoize]
+#[allow(clippy::too_many_arguments)]
+fn layout_page_run_impl(
+ routines: &Routines,
+ world: Tracked<dyn World + '_>,
+ introspector: Tracked<Introspector>,
+ traced: Tracked<Traced>,
+ sink: TrackedMut<Sink>,
+ route: Tracked<Route>,
+ children: &[Pair],
+ locator: Tracked<Locator>,
+ initial: StyleChain,
+) -> SourceResult<Vec<LayoutedPage>> {
+ let link = LocatorLink::new(locator);
+ let mut locator = Locator::link(&link).split();
+ let mut engine = Engine {
+ routines,
+ world,
+ introspector,
+ traced,
+ sink,
+ route: Route::extend(route),
+ };
+
+ // Determine the page-wide styles.
+ let styles = determine_page_styles(children, initial);
+ let styles = StyleChain::new(&styles);
+
+ // When one of the lengths is infinite the page fits its content along
+ // that axis.
+ let width = PageElem::width_in(styles).unwrap_or(Abs::inf());
+ let height = PageElem::height_in(styles).unwrap_or(Abs::inf());
+ let mut size = Size::new(width, height);
+ if PageElem::flipped_in(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((2.5 / 21.0) * min);
+ let margin = PageElem::margin_in(styles);
+ let two_sided = margin.two_sided.unwrap_or(false);
+ let margin = margin
+ .sides
+ .map(|side| side.and_then(Smart::custom).unwrap_or(default))
+ .resolve(styles)
+ .relative_to(size);
+
+ let fill = PageElem::fill_in(styles);
+ let foreground = PageElem::foreground_in(styles);
+ let background = PageElem::background_in(styles);
+ let header_ascent = PageElem::header_ascent_in(styles).relative_to(margin.top);
+ let footer_descent = PageElem::footer_descent_in(styles).relative_to(margin.bottom);
+ let numbering = PageElem::numbering_in(styles);
+ let number_align = PageElem::number_align_in(styles);
+ let binding =
+ PageElem::binding_in(styles).unwrap_or_else(|| match TextElem::dir_in(styles) {
+ Dir::LTR => Binding::Left,
+ _ => Binding::Right,
+ });
+
+ // Construct the numbering (for header or footer).
+ let numbering_marginal = numbering.as_ref().map(|numbering| {
+ let both = match numbering {
+ Numbering::Pattern(pattern) => pattern.pieces() >= 2,
+ Numbering::Func(_) => true,
+ };
+
+ let mut counter = CounterDisplayElem::new(
+ Counter::new(CounterKey::Page),
+ Smart::Custom(numbering.clone()),
+ both,
+ )
+ .pack();
+
+ // We interpret the Y alignment as selecting header or footer
+ // and then ignore it for aligning the actual number.
+ if let Some(x) = number_align.x() {
+ counter = counter.aligned(x.into());
+ }
+
+ counter
+ });
+
+ let header = PageElem::header_in(styles);
+ let footer = PageElem::footer_in(styles);
+ let (header, footer) = if matches!(number_align.y(), Some(OuterVAlignment::Top)) {
+ (header.as_ref().unwrap_or(&numbering_marginal), footer.as_ref().unwrap_or(&None))
+ } else {
+ (header.as_ref().unwrap_or(&None), footer.as_ref().unwrap_or(&numbering_marginal))
+ };
+
+ // Layout the children.
+ let area = size - margin.sum_by_axis();
+ let fragment = layout_flow(
+ &mut engine,
+ children,
+ &mut locator,
+ styles,
+ Regions::repeat(area, area.map(Abs::is_finite)),
+ PageElem::columns_in(styles),
+ ColumnsElem::gutter_in(styles),
+ true,
+ )?;
+
+ // Layouts a single marginal.
+ let mut layout_marginal = |content: &Option<Content>, area, align| {
+ let Some(content) = content else { return Ok(None) };
+ let aligned = content.clone().styled(AlignElem::set_alignment(align));
+ crate::layout_frame(
+ &mut engine,
+ &aligned,
+ locator.next(&content.span()),
+ styles,
+ Region::new(area, Axes::splat(true)),
+ )
+ .map(Some)
+ };
+
+ // Layout marginals.
+ let mut layouted = Vec::with_capacity(fragment.len());
+ for inner in fragment {
+ let header_size = Size::new(inner.width(), margin.top - header_ascent);
+ let footer_size = Size::new(inner.width(), margin.bottom - footer_descent);
+ let full_size = inner.size() + margin.sum_by_axis();
+ let mid = HAlignment::Center + VAlignment::Horizon;
+ layouted.push(LayoutedPage {
+ inner,
+ fill: fill.clone(),
+ numbering: numbering.clone(),
+ header: layout_marginal(header, header_size, Alignment::BOTTOM)?,
+ footer: layout_marginal(footer, footer_size, Alignment::TOP)?,
+ background: layout_marginal(background, full_size, mid)?,
+ foreground: layout_marginal(foreground, full_size, mid)?,
+ margin,
+ binding,
+ two_sided,
+ });
+ }
+
+ Ok(layouted)
+}
+
+/// Determines the styles used for a page run itself and page-level content like
+/// marginals and footnotes.
+///
+/// As a base, we collect the styles that are shared by all elements on the page
+/// run. As a fallback if there are no elements, we use the styles active at the
+/// pagebreak that introduced the page (at the very start, we use the default
+/// styles). Then, to produce our page styles, we filter this list of styles
+/// according to a few rules:
+///
+/// - Other styles are only kept if they are `outside && (initial || liftable)`.
+/// - "Outside" means they were not produced within a show rule or that the
+/// show rule "broke free" to the page level by emitting page styles.
+/// - "Initial" means they were active at the pagebreak that introduced the
+/// page. Since these are intuitively already active, they should be kept even
+/// if not liftable. (E.g. `text(red, page(..)`) makes the footer red.)
+/// - "Liftable" means they can be lifted to the page-level even though they
+/// weren't yet active at the very beginning. Set rule styles are liftable as
+/// opposed to direct constructor calls:
+/// - For `set page(..); set text(red)` the red text is kept even though it
+/// comes after the weak pagebreak from set page.
+/// - For `set page(..); text(red)[..]` the red isn't kept because the
+/// constructor styles are not liftable.
+fn determine_page_styles(children: &[Pair], initial: StyleChain) -> Styles {
+ // Determine the shared styles (excluding tags).
+ let tagless = children.iter().filter(|(c, _)| !c.is::<TagElem>()).map(|&(_, s)| s);
+ let base = StyleChain::trunk(tagless).unwrap_or(initial).to_map();
+
+ // Determine the initial styles that are also shared by everything. We can't
+ // use `StyleChain::trunk` because it currently doesn't deal with partially
+ // shared links (where a subslice matches).
+ let trunk_len = initial
+ .to_map()
+ .as_slice()
+ .iter()
+ .zip(base.as_slice())
+ .take_while(|&(a, b)| a == b)
+ .count();
+
+ // Filter the base styles according to our rules.
+ base.into_iter()
+ .enumerate()
+ .filter(|(i, style)| {
+ let initial = *i < trunk_len;
+ style.outside() && (initial || style.liftable())
+ })
+ .map(|(_, style)| style)
+ .collect()
+}
diff --git a/crates/typst-layout/src/repeat.rs b/crates/typst-layout/src/repeat.rs
new file mode 100644
index 00000000..b761438c
--- /dev/null
+++ b/crates/typst-layout/src/repeat.rs
@@ -0,0 +1,60 @@
+use typst_library::diag::{bail, SourceResult};
+use typst_library::engine::Engine;
+use typst_library::foundations::{Packed, Resolve, StyleChain};
+use typst_library::introspection::Locator;
+use typst_library::layout::{
+ Abs, AlignElem, Axes, Frame, Point, Region, RepeatElem, Size,
+};
+use typst_utils::Numeric;
+
+/// Layout the repeated content.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_repeat(
+ elem: &Packed<RepeatElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ let pod = Region::new(region.size, Axes::new(false, false));
+ let piece = crate::layout_frame(engine, &elem.body, locator, styles, pod)?;
+ let size = Size::new(region.size.x, piece.height());
+
+ if !size.is_finite() {
+ bail!(elem.span(), "repeat with no size restrictions");
+ }
+
+ let mut frame = Frame::soft(size);
+ if piece.has_baseline() {
+ frame.set_baseline(piece.baseline());
+ }
+
+ let mut gap = elem.gap(styles).resolve(styles);
+ let fill = region.size.x;
+ let width = piece.width();
+
+ // count * width + (count - 1) * gap = fill, but count is an integer so
+ // we need to round down and get the remainder.
+ let count = ((fill + gap) / (width + gap)).floor();
+ let remaining = (fill + gap) % (width + gap);
+
+ let justify = elem.justify(styles);
+ if justify {
+ gap += remaining / (count - 1.0);
+ }
+
+ let align = AlignElem::alignment_in(styles).resolve(styles);
+ let mut offset = Abs::zero();
+ if count == 1.0 || !justify {
+ offset += align.x.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() + gap;
+ }
+ }
+
+ Ok(frame)
+}
diff --git a/crates/typst-layout/src/shapes.rs b/crates/typst-layout/src/shapes.rs
new file mode 100644
index 00000000..31f8c42b
--- /dev/null
+++ b/crates/typst-layout/src/shapes.rs
@@ -0,0 +1,1028 @@
+use std::f64::consts::SQRT_2;
+
+use kurbo::ParamCurveExtrema;
+use typst_library::diag::{bail, SourceResult};
+use typst_library::engine::Engine;
+use typst_library::foundations::{Content, Packed, Resolve, Smart, StyleChain};
+use typst_library::introspection::Locator;
+use typst_library::layout::{
+ Abs, Axes, Corner, Corners, Frame, FrameItem, Length, Point, Ratio, Region, Rel,
+ Sides, Size,
+};
+use typst_library::visualize::{
+ CircleElem, EllipseElem, FillRule, FixedStroke, Geometry, LineElem, Paint, Path,
+ PathElem, PathVertex, PolygonElem, RectElem, Shape, SquareElem, Stroke,
+};
+use typst_syntax::Span;
+use typst_utils::{Get, Numeric};
+
+/// Layout the line.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_line(
+ elem: &Packed<LineElem>,
+ _: &mut Engine,
+ _: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ let resolve = |axes: Axes<Rel<Abs>>| axes.zip_map(region.size, Rel::relative_to);
+ let start = resolve(elem.start(styles));
+ let delta = elem.end(styles).map(|end| resolve(end) - start).unwrap_or_else(|| {
+ let length = elem.length(styles);
+ let angle = elem.angle(styles);
+ let x = angle.cos() * length;
+ let y = angle.sin() * length;
+ resolve(Axes::new(x, y))
+ });
+
+ let stroke = elem.stroke(styles).unwrap_or_default();
+ let size = start.max(start + delta).max(Size::zero());
+
+ if !size.is_finite() {
+ bail!(elem.span(), "cannot create line with infinite length");
+ }
+
+ let mut frame = Frame::soft(size);
+ let shape = Geometry::Line(delta.to_point()).stroked(stroke);
+ frame.push(start.to_point(), FrameItem::Shape(shape, elem.span()));
+ Ok(frame)
+}
+
+/// Layout the path.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_path(
+ elem: &Packed<PathElem>,
+ _: &mut Engine,
+ _: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ let resolve = |axes: Axes<Rel<Length>>| {
+ axes.resolve(styles).zip_map(region.size, Rel::relative_to).to_point()
+ };
+
+ let vertices = elem.vertices();
+ let points: Vec<Point> = vertices.iter().map(|c| resolve(c.vertex())).collect();
+
+ let mut size = Size::zero();
+ if points.is_empty() {
+ return Ok(Frame::soft(size));
+ }
+
+ // Only create a path if there are more than zero points.
+ // Construct a closed path given all points.
+ let mut path = Path::new();
+ path.move_to(points[0]);
+
+ let mut add_cubic = |from_point: Point,
+ to_point: Point,
+ from: PathVertex,
+ to: PathVertex| {
+ let from_control_point = resolve(from.control_point_from()) + from_point;
+ let to_control_point = resolve(to.control_point_to()) + to_point;
+ path.cubic_to(from_control_point, to_control_point, to_point);
+
+ let p0 = kurbo::Point::new(from_point.x.to_raw(), from_point.y.to_raw());
+ let p1 = kurbo::Point::new(
+ from_control_point.x.to_raw(),
+ from_control_point.y.to_raw(),
+ );
+ let p2 =
+ kurbo::Point::new(to_control_point.x.to_raw(), to_control_point.y.to_raw());
+ let p3 = kurbo::Point::new(to_point.x.to_raw(), to_point.y.to_raw());
+ let extrema = kurbo::CubicBez::new(p0, p1, p2, p3).bounding_box();
+ size.x.set_max(Abs::raw(extrema.x1));
+ size.y.set_max(Abs::raw(extrema.y1));
+ };
+
+ for (vertex_window, point_window) in vertices.windows(2).zip(points.windows(2)) {
+ let from = vertex_window[0];
+ let to = vertex_window[1];
+ let from_point = point_window[0];
+ let to_point = point_window[1];
+
+ add_cubic(from_point, to_point, from, to);
+ }
+
+ if elem.closed(styles) {
+ let from = *vertices.last().unwrap(); // We checked that we have at least one element.
+ let to = vertices[0];
+ let from_point = *points.last().unwrap();
+ let to_point = points[0];
+
+ add_cubic(from_point, to_point, from, to);
+ path.close_path();
+ }
+
+ // Prepare fill and stroke.
+ let fill = elem.fill(styles);
+ let fill_rule = elem.fill_rule(styles);
+ let stroke = match elem.stroke(styles) {
+ Smart::Auto if fill.is_none() => Some(FixedStroke::default()),
+ Smart::Auto => None,
+ Smart::Custom(stroke) => stroke.map(Stroke::unwrap_or_default),
+ };
+
+ let mut frame = Frame::soft(size);
+ let shape = Shape {
+ geometry: Geometry::Path(path),
+ stroke,
+ fill,
+ fill_rule,
+ };
+ frame.push(Point::zero(), FrameItem::Shape(shape, elem.span()));
+ Ok(frame)
+}
+
+/// Layout the polygon.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_polygon(
+ elem: &Packed<PolygonElem>,
+ _: &mut Engine,
+ _: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ let points: Vec<Point> = elem
+ .vertices()
+ .iter()
+ .map(|c| c.resolve(styles).zip_map(region.size, Rel::relative_to).to_point())
+ .collect();
+
+ let size = points.iter().fold(Point::zero(), |max, c| c.max(max)).to_size();
+ if !size.is_finite() {
+ bail!(elem.span(), "cannot create polygon with infinite size");
+ }
+
+ let mut frame = Frame::hard(size);
+
+ // Only create a path if there are more than zero points.
+ if points.is_empty() {
+ return Ok(frame);
+ }
+
+ // Prepare fill and stroke.
+ let fill = elem.fill(styles);
+ let fill_rule = elem.fill_rule(styles);
+ let stroke = match elem.stroke(styles) {
+ Smart::Auto if fill.is_none() => Some(FixedStroke::default()),
+ Smart::Auto => None,
+ Smart::Custom(stroke) => stroke.map(Stroke::unwrap_or_default),
+ };
+
+ // Construct a closed path given all points.
+ let mut path = Path::new();
+ path.move_to(points[0]);
+ for &point in &points[1..] {
+ path.line_to(point);
+ }
+ path.close_path();
+
+ let shape = Shape {
+ geometry: Geometry::Path(path),
+ stroke,
+ fill,
+ fill_rule,
+ };
+ frame.push(Point::zero(), FrameItem::Shape(shape, elem.span()));
+ Ok(frame)
+}
+
+/// Lay out the rectangle.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_rect(
+ elem: &Packed<RectElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ layout_shape(
+ engine,
+ locator,
+ styles,
+ region,
+ ShapeKind::Rect,
+ elem.body(styles),
+ elem.fill(styles),
+ elem.stroke(styles),
+ elem.inset(styles),
+ elem.outset(styles),
+ elem.radius(styles),
+ elem.span(),
+ )
+}
+
+/// Lay out the square.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_square(
+ elem: &Packed<SquareElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ layout_shape(
+ engine,
+ locator,
+ styles,
+ region,
+ ShapeKind::Square,
+ elem.body(styles),
+ elem.fill(styles),
+ elem.stroke(styles),
+ elem.inset(styles),
+ elem.outset(styles),
+ elem.radius(styles),
+ elem.span(),
+ )
+}
+
+/// Lay out the ellipse.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_ellipse(
+ elem: &Packed<EllipseElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ layout_shape(
+ engine,
+ locator,
+ styles,
+ region,
+ ShapeKind::Ellipse,
+ elem.body(styles),
+ elem.fill(styles),
+ elem.stroke(styles).map(|s| Sides::splat(Some(s))),
+ elem.inset(styles),
+ elem.outset(styles),
+ Corners::splat(None),
+ elem.span(),
+ )
+}
+
+/// Lay out the circle.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_circle(
+ elem: &Packed<CircleElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ layout_shape(
+ engine,
+ locator,
+ styles,
+ region,
+ ShapeKind::Circle,
+ elem.body(styles),
+ elem.fill(styles),
+ elem.stroke(styles).map(|s| Sides::splat(Some(s))),
+ elem.inset(styles),
+ elem.outset(styles),
+ Corners::splat(None),
+ elem.span(),
+ )
+}
+
+/// A category of shape.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+enum ShapeKind {
+ /// A rectangle with equal side lengths.
+ Square,
+ /// A quadrilateral with four right angles.
+ Rect,
+ /// An ellipse with coinciding foci.
+ Circle,
+ /// A curve around two focal points.
+ Ellipse,
+}
+
+impl ShapeKind {
+ /// Whether this shape kind is curvy.
+ fn is_round(self) -> bool {
+ matches!(self, Self::Circle | Self::Ellipse)
+ }
+
+ /// Whether this shape kind has equal side length.
+ fn is_quadratic(self) -> bool {
+ matches!(self, Self::Square | Self::Circle)
+ }
+}
+
+/// Layout a shape.
+#[allow(clippy::too_many_arguments)]
+fn layout_shape(
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+ kind: ShapeKind,
+ body: &Option<Content>,
+ fill: Option<Paint>,
+ stroke: Smart<Sides<Option<Option<Stroke<Abs>>>>>,
+ inset: Sides<Option<Rel<Abs>>>,
+ outset: Sides<Option<Rel<Abs>>>,
+ radius: Corners<Option<Rel<Abs>>>,
+ span: Span,
+) -> SourceResult<Frame> {
+ let mut frame;
+ if let Some(child) = body {
+ let mut inset = inset.unwrap_or_default();
+ if kind.is_round() {
+ // Apply extra inset to round shapes.
+ inset = inset.map(|v| v + Ratio::new(0.5 - SQRT_2 / 4.0));
+ }
+ let has_inset = !inset.is_zero();
+
+ // Take the inset, if any, into account.
+ let mut pod = region;
+ if has_inset {
+ pod.size = crate::pad::shrink(region.size, &inset);
+ }
+
+ // Layout the child.
+ frame = crate::layout_frame(engine, child, locator.relayout(), styles, pod)?;
+
+ // If the child is a square or circle, relayout with full expansion into
+ // square region to make sure the result is really quadratic.
+ if kind.is_quadratic() {
+ let length = frame.size().max_by_side().min(pod.size.min_by_side());
+ let quad_pod = Region::new(Size::splat(length), Axes::splat(true));
+ frame = crate::layout_frame(engine, child, locator, styles, quad_pod)?;
+ }
+
+ // Apply the inset.
+ if has_inset {
+ crate::pad::grow(&mut frame, &inset);
+ }
+ } else {
+ // The default size that a shape takes on if it has no child and
+ // enough space.
+ let default = Size::new(Abs::pt(45.0), Abs::pt(30.0));
+ let mut size = region.expand.select(region.size, default.min(region.size));
+ if kind.is_quadratic() {
+ size = Size::splat(size.min_by_side());
+ }
+ frame = Frame::soft(size);
+ }
+
+ // Prepare stroke.
+ let stroke = match stroke {
+ Smart::Auto if fill.is_none() => Sides::splat(Some(FixedStroke::default())),
+ Smart::Auto => Sides::splat(None),
+ Smart::Custom(strokes) => {
+ strokes.unwrap_or_default().map(|s| s.map(Stroke::unwrap_or_default))
+ }
+ };
+
+ // Add fill and/or stroke.
+ if fill.is_some() || stroke.iter().any(Option::is_some) {
+ if kind.is_round() {
+ let outset = outset.unwrap_or_default().relative_to(frame.size());
+ let size = frame.size() + outset.sum_by_axis();
+ let pos = Point::new(-outset.left, -outset.top);
+ let shape = Shape {
+ geometry: Geometry::Path(Path::ellipse(size)),
+ fill,
+ stroke: stroke.left,
+ fill_rule: FillRule::default(),
+ };
+ frame.prepend(pos, FrameItem::Shape(shape, span));
+ } else {
+ fill_and_stroke(
+ &mut frame,
+ fill,
+ &stroke,
+ &outset.unwrap_or_default(),
+ &radius.unwrap_or_default(),
+ span,
+ );
+ }
+ }
+
+ Ok(frame)
+}
+
+/// Creates a new rectangle as a path.
+pub fn clip_rect(
+ size: Size,
+ radius: &Corners<Rel<Abs>>,
+ stroke: &Sides<Option<FixedStroke>>,
+) -> Path {
+ let stroke_widths = stroke
+ .as_ref()
+ .map(|s| s.as_ref().map_or(Abs::zero(), |s| s.thickness / 2.0));
+
+ let max_radius = (size.x.min(size.y)) / 2.0
+ + stroke_widths.iter().cloned().min().unwrap_or(Abs::zero());
+
+ let radius = radius.map(|side| side.relative_to(max_radius * 2.0).min(max_radius));
+ let corners = corners_control_points(size, &radius, stroke, &stroke_widths);
+
+ let mut path = Path::new();
+ if corners.top_left.arc_inner() {
+ path.arc_move(
+ corners.top_left.start_inner(),
+ corners.top_left.center_inner(),
+ corners.top_left.end_inner(),
+ );
+ } else {
+ path.move_to(corners.top_left.center_inner());
+ }
+ for corner in [&corners.top_right, &corners.bottom_right, &corners.bottom_left] {
+ if corner.arc_inner() {
+ path.arc_line(corner.start_inner(), corner.center_inner(), corner.end_inner())
+ } else {
+ path.line_to(corner.center_inner());
+ }
+ }
+ path.close_path();
+ path
+}
+
+/// Add a fill and stroke with optional radius and outset to the frame.
+pub fn fill_and_stroke(
+ frame: &mut Frame,
+ fill: Option<Paint>,
+ stroke: &Sides<Option<FixedStroke>>,
+ outset: &Sides<Rel<Abs>>,
+ radius: &Corners<Rel<Abs>>,
+ span: Span,
+) {
+ let outset = outset.relative_to(frame.size());
+ let size = frame.size() + outset.sum_by_axis();
+ let pos = Point::new(-outset.left, -outset.top);
+ frame.prepend_multiple(
+ styled_rect(size, radius, fill, stroke)
+ .into_iter()
+ .map(|x| (pos, FrameItem::Shape(x, span))),
+ );
+}
+
+/// Create a styled rectangle with shapes.
+/// - use rect primitive for simple rectangles
+/// - stroke sides if possible
+/// - use fill for sides for best looks
+pub fn styled_rect(
+ size: Size,
+ radius: &Corners<Rel<Abs>>,
+ fill: Option<Paint>,
+ stroke: &Sides<Option<FixedStroke>>,
+) -> Vec<Shape> {
+ if stroke.is_uniform() && radius.iter().cloned().all(Rel::is_zero) {
+ simple_rect(size, fill, stroke.top.clone())
+ } else {
+ segmented_rect(size, radius, fill, stroke)
+ }
+}
+
+/// Use rect primitive for the rectangle
+fn simple_rect(
+ size: Size,
+ fill: Option<Paint>,
+ stroke: Option<FixedStroke>,
+) -> Vec<Shape> {
+ vec![Shape {
+ geometry: Geometry::Rect(size),
+ fill,
+ stroke,
+ fill_rule: FillRule::default(),
+ }]
+}
+
+fn corners_control_points(
+ size: Size,
+ radius: &Corners<Abs>,
+ strokes: &Sides<Option<FixedStroke>>,
+ stroke_widths: &Sides<Abs>,
+) -> Corners<ControlPoints> {
+ Corners {
+ top_left: Corner::TopLeft,
+ top_right: Corner::TopRight,
+ bottom_right: Corner::BottomRight,
+ bottom_left: Corner::BottomLeft,
+ }
+ .map(|corner| ControlPoints {
+ radius: radius.get(corner),
+ stroke_before: stroke_widths.get(corner.side_ccw()),
+ stroke_after: stroke_widths.get(corner.side_cw()),
+ corner,
+ size,
+ same: match (
+ strokes.get_ref(corner.side_ccw()),
+ strokes.get_ref(corner.side_cw()),
+ ) {
+ (Some(a), Some(b)) => a.paint == b.paint && a.dash == b.dash,
+ (None, None) => true,
+ _ => false,
+ },
+ })
+}
+
+/// Use stroke and fill for the rectangle
+fn segmented_rect(
+ size: Size,
+ radius: &Corners<Rel<Abs>>,
+ fill: Option<Paint>,
+ strokes: &Sides<Option<FixedStroke>>,
+) -> Vec<Shape> {
+ let mut res = vec![];
+ let stroke_widths = strokes
+ .as_ref()
+ .map(|s| s.as_ref().map_or(Abs::zero(), |s| s.thickness / 2.0));
+
+ let max_radius = (size.x.min(size.y)) / 2.0
+ + stroke_widths.iter().cloned().min().unwrap_or(Abs::zero());
+
+ let radius = radius.map(|side| side.relative_to(max_radius * 2.0).min(max_radius));
+ let corners = corners_control_points(size, &radius, strokes, &stroke_widths);
+
+ // insert stroked sides below filled sides
+ let mut stroke_insert = 0;
+
+ // fill shape with inner curve
+ if let Some(fill) = fill {
+ let mut path = Path::new();
+ let c = corners.get_ref(Corner::TopLeft);
+ if c.arc() {
+ path.arc_move(c.start(), c.center(), c.end());
+ } else {
+ path.move_to(c.center());
+ };
+
+ for corner in [Corner::TopRight, Corner::BottomRight, Corner::BottomLeft] {
+ let c = corners.get_ref(corner);
+ if c.arc() {
+ path.arc_line(c.start(), c.center(), c.end());
+ } else {
+ path.line_to(c.center());
+ }
+ }
+ path.close_path();
+ res.push(Shape {
+ geometry: Geometry::Path(path),
+ fill: Some(fill),
+ fill_rule: FillRule::default(),
+ stroke: None,
+ });
+ stroke_insert += 1;
+ }
+
+ let current = corners.iter().find(|c| !c.same).map(|c| c.corner);
+ if let Some(mut current) = current {
+ // multiple segments
+ // start at a corner with a change between sides and iterate clockwise all other corners
+ let mut last = current;
+ for _ in 0..4 {
+ current = current.next_cw();
+ if corners.get_ref(current).same {
+ continue;
+ }
+ // create segment
+ let start = last;
+ let end = current;
+ last = current;
+ let Some(stroke) = strokes.get_ref(start.side_cw()) else { continue };
+ let (shape, ontop) = segment(start, end, &corners, stroke);
+ if ontop {
+ res.push(shape);
+ } else {
+ res.insert(stroke_insert, shape);
+ stroke_insert += 1;
+ }
+ }
+ } else if let Some(stroke) = &strokes.top {
+ // single segment
+ let (shape, _) = segment(Corner::TopLeft, Corner::TopLeft, &corners, stroke);
+ res.push(shape);
+ }
+ res
+}
+
+fn path_segment(
+ start: Corner,
+ end: Corner,
+ corners: &Corners<ControlPoints>,
+ path: &mut Path,
+) {
+ // create start corner
+ let c = corners.get_ref(start);
+ if start == end || !c.arc() {
+ path.move_to(c.end());
+ } else {
+ path.arc_move(c.mid(), c.center(), c.end());
+ }
+
+ // create corners between start and end
+ let mut current = start.next_cw();
+ while current != end {
+ let c = corners.get_ref(current);
+ if c.arc() {
+ path.arc_line(c.start(), c.center(), c.end());
+ } else {
+ path.line_to(c.end());
+ }
+ current = current.next_cw();
+ }
+
+ // create end corner
+ let c = corners.get_ref(end);
+ if !c.arc() {
+ path.line_to(c.start());
+ } else if start == end {
+ path.arc_line(c.start(), c.center(), c.end());
+ } else {
+ path.arc_line(c.start(), c.center(), c.mid());
+ }
+}
+
+/// Returns the shape for the segment and whether the shape should be drawn on top.
+fn segment(
+ start: Corner,
+ end: Corner,
+ corners: &Corners<ControlPoints>,
+ stroke: &FixedStroke,
+) -> (Shape, bool) {
+ fn fill_corner(corner: &ControlPoints) -> bool {
+ corner.stroke_before != corner.stroke_after
+ || corner.radius() < corner.stroke_before
+ }
+
+ fn fill_corners(
+ start: Corner,
+ end: Corner,
+ corners: &Corners<ControlPoints>,
+ ) -> bool {
+ if fill_corner(corners.get_ref(start)) {
+ return true;
+ }
+ if fill_corner(corners.get_ref(end)) {
+ return true;
+ }
+ let mut current = start.next_cw();
+ while current != end {
+ if fill_corner(corners.get_ref(current)) {
+ return true;
+ }
+ current = current.next_cw();
+ }
+ false
+ }
+
+ let solid = stroke
+ .dash
+ .as_ref()
+ .map(|pattern| pattern.array.is_empty())
+ .unwrap_or(true);
+
+ let use_fill = solid && fill_corners(start, end, corners);
+ let shape = if use_fill {
+ fill_segment(start, end, corners, stroke)
+ } else {
+ stroke_segment(start, end, corners, stroke.clone())
+ };
+
+ (shape, use_fill)
+}
+
+/// Stroke the sides from `start` to `end` clockwise.
+fn stroke_segment(
+ start: Corner,
+ end: Corner,
+ corners: &Corners<ControlPoints>,
+ stroke: FixedStroke,
+) -> Shape {
+ // Create start corner.
+ let mut path = Path::new();
+ path_segment(start, end, corners, &mut path);
+
+ Shape {
+ geometry: Geometry::Path(path),
+ stroke: Some(stroke),
+ fill: None,
+ fill_rule: FillRule::default(),
+ }
+}
+
+/// Fill the sides from `start` to `end` clockwise.
+fn fill_segment(
+ start: Corner,
+ end: Corner,
+ corners: &Corners<ControlPoints>,
+ stroke: &FixedStroke,
+) -> Shape {
+ let mut path = Path::new();
+
+ // create the start corner
+ // begin on the inside and finish on the outside
+ // no corner if start and end are equal
+ // half corner if different
+ if start == end {
+ let c = corners.get_ref(start);
+ path.move_to(c.end_inner());
+ path.line_to(c.end_outer());
+ } else {
+ let c = corners.get_ref(start);
+
+ if c.arc_inner() {
+ path.arc_move(c.end_inner(), c.center_inner(), c.mid_inner());
+ } else {
+ path.move_to(c.end_inner());
+ }
+
+ if c.arc_outer() {
+ path.arc_line(c.mid_outer(), c.center_outer(), c.end_outer());
+ } else {
+ path.line_to(c.outer());
+ path.line_to(c.end_outer());
+ }
+ }
+
+ // create the clockwise outside path for the corners between start and end
+ let mut current = start.next_cw();
+ while current != end {
+ let c = corners.get_ref(current);
+ if c.arc_outer() {
+ path.arc_line(c.start_outer(), c.center_outer(), c.end_outer());
+ } else {
+ path.line_to(c.outer());
+ }
+ current = current.next_cw();
+ }
+
+ // create the end corner
+ // begin on the outside and finish on the inside
+ // full corner if start and end are equal
+ // half corner if different
+ if start == end {
+ let c = corners.get_ref(end);
+ if c.arc_outer() {
+ path.arc_line(c.start_outer(), c.center_outer(), c.end_outer());
+ } else {
+ path.line_to(c.outer());
+ path.line_to(c.end_outer());
+ }
+ if c.arc_inner() {
+ path.arc_line(c.end_inner(), c.center_inner(), c.start_inner());
+ } else {
+ path.line_to(c.center_inner());
+ }
+ } else {
+ let c = corners.get_ref(end);
+ if c.arc_outer() {
+ path.arc_line(c.start_outer(), c.center_outer(), c.mid_outer());
+ } else {
+ path.line_to(c.outer());
+ }
+ if c.arc_inner() {
+ path.arc_line(c.mid_inner(), c.center_inner(), c.start_inner());
+ } else {
+ path.line_to(c.center_inner());
+ }
+ }
+
+ // create the counterclockwise inside path for the corners between start and end
+ let mut current = end.next_ccw();
+ while current != start {
+ let c = corners.get_ref(current);
+ if c.arc_inner() {
+ path.arc_line(c.end_inner(), c.center_inner(), c.start_inner());
+ } else {
+ path.line_to(c.center_inner());
+ }
+ current = current.next_ccw();
+ }
+
+ path.close_path();
+
+ Shape {
+ geometry: Geometry::Path(path),
+ stroke: None,
+ fill: Some(stroke.paint.clone()),
+ fill_rule: FillRule::default(),
+ }
+}
+
+/// Helper to calculate different control points for the corners.
+/// Clockwise orientation from start to end.
+/// ```text
+/// O-------------------EO --- - Z: Zero/Origin ({x: 0, y: 0} for top left corner)
+/// |\ ___----''' | | - O: Outer: intersection between the straight outer lines
+/// | \ / | | - S_: start
+/// | MO | | - M_: midpoint
+/// | /Z\ __-----------E | - E_: end
+/// |/ \M | ro - r_: radius
+/// | /\ | | - middle of the stroke
+/// | / \ | | - arc from S through M to E with center C and radius r
+/// | | MI--EI------- | - outer curve
+/// | | / \ | - arc from SO through MO to EO with center CO and radius ro
+/// SO | | \ CO --- - inner curve
+/// | | | \ - arc from SI through MI to EI with center CI and radius ri
+/// |--S-SI-----CI C
+/// |--ri--|
+/// |-------r--------|
+/// ```
+struct ControlPoints {
+ radius: Abs,
+ stroke_after: Abs,
+ stroke_before: Abs,
+ corner: Corner,
+ size: Size,
+ same: bool,
+}
+
+impl ControlPoints {
+ /// Move and rotate the point from top-left to the required corner.
+ fn rotate(&self, point: Point) -> Point {
+ match self.corner {
+ Corner::TopLeft => point,
+ Corner::TopRight => Point { x: self.size.x - point.y, y: point.x },
+ Corner::BottomRight => {
+ Point { x: self.size.x - point.x, y: self.size.y - point.y }
+ }
+ Corner::BottomLeft => Point { x: point.y, y: self.size.y - point.x },
+ }
+ }
+
+ /// Outside intersection of the sides.
+ pub fn outer(&self) -> Point {
+ self.rotate(Point { x: -self.stroke_before, y: -self.stroke_after })
+ }
+
+ /// Center for the outer arc.
+ pub fn center_outer(&self) -> Point {
+ let r = self.radius_outer();
+ self.rotate(Point {
+ x: r - self.stroke_before,
+ y: r - self.stroke_after,
+ })
+ }
+
+ /// Center for the middle arc.
+ pub fn center(&self) -> Point {
+ let r = self.radius();
+ self.rotate(Point { x: r, y: r })
+ }
+
+ /// Center for the inner arc.
+ pub fn center_inner(&self) -> Point {
+ let r = self.radius_inner();
+
+ self.rotate(Point {
+ x: self.stroke_before + r,
+ y: self.stroke_after + r,
+ })
+ }
+
+ /// Radius of the outer arc.
+ pub fn radius_outer(&self) -> Abs {
+ self.radius
+ }
+
+ /// Radius of the middle arc.
+ pub fn radius(&self) -> Abs {
+ (self.radius - self.stroke_before.min(self.stroke_after)).max(Abs::zero())
+ }
+
+ /// Radius of the inner arc.
+ pub fn radius_inner(&self) -> Abs {
+ (self.radius - 2.0 * self.stroke_before.max(self.stroke_after)).max(Abs::zero())
+ }
+
+ /// Middle of the corner on the outside of the stroke.
+ pub fn mid_outer(&self) -> Point {
+ let c_i = self.center_inner();
+ let c_o = self.center_outer();
+ let o = self.outer();
+ let r = self.radius_outer();
+
+ // https://math.stackexchange.com/a/311956
+ // intersection between the line from inner center to outside and the outer arc
+ let a = (o.x - c_i.x).to_raw().powi(2) + (o.y - c_i.y).to_raw().powi(2);
+ let b = 2.0 * (o.x - c_i.x).to_raw() * (c_i.x - c_o.x).to_raw()
+ + 2.0 * (o.y - c_i.y).to_raw() * (c_i.y - c_o.y).to_raw();
+ let c = (c_i.x - c_o.x).to_raw().powi(2) + (c_i.y - c_o.y).to_raw().powi(2)
+ - r.to_raw().powi(2);
+ let t = (-b + (b * b - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a);
+ c_i + t * (o - c_i)
+ }
+
+ /// Middle of the corner in the middle of the stroke.
+ pub fn mid(&self) -> Point {
+ let center = self.center_outer();
+ let outer = self.outer();
+ let diff = outer - center;
+ center + diff / diff.hypot().to_raw() * self.radius().to_raw()
+ }
+
+ /// Middle of the corner on the inside of the stroke.
+ pub fn mid_inner(&self) -> Point {
+ let center = self.center_inner();
+ let outer = self.outer();
+ let diff = outer - center;
+ center + diff / diff.hypot().to_raw() * self.radius_inner().to_raw()
+ }
+
+ /// If an outer arc is required.
+ pub fn arc_outer(&self) -> bool {
+ self.radius_outer() > Abs::zero()
+ }
+
+ pub fn arc(&self) -> bool {
+ self.radius() > Abs::zero()
+ }
+
+ /// If an inner arc is required.
+ pub fn arc_inner(&self) -> bool {
+ self.radius_inner() > Abs::zero()
+ }
+
+ /// Start of the corner on the outside of the stroke.
+ pub fn start_outer(&self) -> Point {
+ self.rotate(Point {
+ x: -self.stroke_before,
+ y: self.radius_outer() - self.stroke_after,
+ })
+ }
+
+ /// Start of the corner in the center of the stroke.
+ pub fn start(&self) -> Point {
+ self.rotate(Point::with_y(self.radius()))
+ }
+
+ /// Start of the corner on the inside of the stroke.
+ pub fn start_inner(&self) -> Point {
+ self.rotate(Point {
+ x: self.stroke_before,
+ y: self.stroke_after + self.radius_inner(),
+ })
+ }
+
+ /// End of the corner on the outside of the stroke.
+ pub fn end_outer(&self) -> Point {
+ self.rotate(Point {
+ x: self.radius_outer() - self.stroke_before,
+ y: -self.stroke_after,
+ })
+ }
+
+ /// End of the corner in the center of the stroke.
+ pub fn end(&self) -> Point {
+ self.rotate(Point::with_x(self.radius()))
+ }
+
+ /// End of the corner on the inside of the stroke.
+ pub fn end_inner(&self) -> Point {
+ self.rotate(Point {
+ x: self.stroke_before + self.radius_inner(),
+ y: self.stroke_after,
+ })
+ }
+}
+
+/// Helper to draw arcs with bezier curves.
+trait PathExt {
+ fn arc(&mut self, start: Point, center: Point, end: Point);
+ fn arc_move(&mut self, start: Point, center: Point, end: Point);
+ fn arc_line(&mut self, start: Point, center: Point, end: Point);
+}
+
+impl PathExt for Path {
+ fn arc(&mut self, start: Point, center: Point, end: Point) {
+ let arc = bezier_arc_control(start, center, end);
+ self.cubic_to(arc[0], arc[1], end);
+ }
+
+ fn arc_move(&mut self, start: Point, center: Point, end: Point) {
+ self.move_to(start);
+ self.arc(start, center, end);
+ }
+
+ fn arc_line(&mut self, start: Point, center: Point, end: Point) {
+ self.line_to(start);
+ self.arc(start, center, end);
+ }
+}
+
+/// Get the control points for a bezier curve that approximates a circular arc for
+/// a start point, an end point and a center of the circle whose arc connects
+/// the two.
+fn bezier_arc_control(start: Point, center: Point, end: Point) -> [Point; 2] {
+ // https://stackoverflow.com/a/44829356/1567835
+ let a = start - center;
+ let b = end - center;
+
+ let q1 = a.x.to_raw() * a.x.to_raw() + a.y.to_raw() * a.y.to_raw();
+ let q2 = q1 + a.x.to_raw() * b.x.to_raw() + a.y.to_raw() * b.y.to_raw();
+ let k2 = (4.0 / 3.0) * ((2.0 * q1 * q2).sqrt() - q2)
+ / (a.x.to_raw() * b.y.to_raw() - a.y.to_raw() * b.x.to_raw());
+
+ let control_1 = Point::new(center.x + a.x - k2 * a.y, center.y + a.y + k2 * a.x);
+ let control_2 = Point::new(center.x + b.x + k2 * b.y, center.y + b.y - k2 * b.x);
+
+ [control_1, control_2]
+}
diff --git a/crates/typst-layout/src/stack.rs b/crates/typst-layout/src/stack.rs
new file mode 100644
index 00000000..a3ebc9f3
--- /dev/null
+++ b/crates/typst-layout/src/stack.rs
@@ -0,0 +1,324 @@
+use typst_library::diag::{bail, SourceResult};
+use typst_library::engine::Engine;
+use typst_library::foundations::{Content, Packed, Resolve, StyleChain, StyledElem};
+use typst_library::introspection::{Locator, SplitLocator};
+use typst_library::layout::{
+ Abs, AlignElem, Axes, Axis, Dir, FixedAlignment, Fr, Fragment, Frame, HElem, Point,
+ Regions, Size, Spacing, StackChild, StackElem, VElem,
+};
+use typst_syntax::Span;
+use typst_utils::{Get, Numeric};
+
+/// Layout the stack.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_stack(
+ elem: &Packed<StackElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ regions: Regions,
+) -> SourceResult<Fragment> {
+ let mut layouter =
+ StackLayouter::new(elem.span(), elem.dir(styles), locator, styles, regions);
+
+ let axis = layouter.dir.axis();
+
+ // Spacing to insert before the next block.
+ let spacing = elem.spacing(styles);
+ let mut deferred = None;
+
+ for child in elem.children() {
+ match child {
+ StackChild::Spacing(kind) => {
+ layouter.layout_spacing(*kind);
+ deferred = None;
+ }
+ StackChild::Block(block) => {
+ // Transparently handle `h`.
+ if let (Axis::X, Some(h)) = (axis, block.to_packed::<HElem>()) {
+ layouter.layout_spacing(*h.amount());
+ deferred = None;
+ continue;
+ }
+
+ // Transparently handle `v`.
+ if let (Axis::Y, Some(v)) = (axis, block.to_packed::<VElem>()) {
+ layouter.layout_spacing(*v.amount());
+ deferred = None;
+ continue;
+ }
+
+ if let Some(kind) = deferred {
+ layouter.layout_spacing(kind);
+ }
+
+ layouter.layout_block(engine, block, styles)?;
+ deferred = spacing;
+ }
+ }
+ }
+
+ layouter.finish()
+}
+
+/// Performs stack layout.
+struct StackLayouter<'a> {
+ /// The span to raise errors at during layout.
+ span: Span,
+ /// The stacking direction.
+ dir: Dir,
+ /// The axis of the stacking direction.
+ axis: Axis,
+ /// Provides unique locations to the stack's children.
+ locator: SplitLocator<'a>,
+ /// The inherited styles.
+ styles: StyleChain<'a>,
+ /// The regions to layout children into.
+ regions: Regions<'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: GenericSize<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<FixedAlignment>),
+}
+
+impl<'a> StackLayouter<'a> {
+ /// Create a new stack layouter.
+ fn new(
+ span: Span,
+ dir: Dir,
+ locator: Locator<'a>,
+ styles: StyleChain<'a>,
+ mut regions: Regions<'a>,
+ ) -> Self {
+ let axis = dir.axis();
+ let expand = regions.expand;
+
+ // Disable expansion along the block axis for children.
+ regions.expand.set(axis, false);
+
+ Self {
+ span,
+ dir,
+ axis,
+ locator: locator.split(),
+ styles,
+ regions,
+ expand,
+ initial: regions.size,
+ used: GenericSize::zero(),
+ fr: Fr::zero(),
+ items: vec![],
+ finished: vec![],
+ }
+ }
+
+ /// Add spacing along the spacing direction.
+ 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.
+ fn layout_block(
+ &mut self,
+ engine: &mut Engine,
+ block: &Content,
+ styles: StyleChain,
+ ) -> SourceResult<()> {
+ if self.regions.is_full() {
+ self.finish_region()?;
+ }
+
+ // Block-axis alignment of the `AlignElem` is respected by stacks.
+ let align = if let Some(align) = block.to_packed::<AlignElem>() {
+ align.alignment(styles)
+ } else if let Some(styled) = block.to_packed::<StyledElem>() {
+ AlignElem::alignment_in(styles.chain(&styled.styles))
+ } else {
+ AlignElem::alignment_in(styles)
+ }
+ .resolve(styles);
+
+ let fragment = crate::layout_fragment(
+ engine,
+ block,
+ self.locator.next(&block.span()),
+ 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 specific_size = frame.size();
+ if self.dir.axis() == Axis::Y {
+ self.regions.size.y -= specific_size.y;
+ }
+
+ let generic_size = match self.axis {
+ Axis::X => GenericSize::new(specific_size.y, specific_size.x),
+ Axis::Y => GenericSize::new(specific_size.x, specific_size.y),
+ };
+
+ self.used.main += generic_size.main;
+ self.used.cross.set_max(generic_size.cross);
+
+ self.items.push(StackItem::Frame(frame, align));
+
+ if i + 1 < len {
+ self.finish_region()?;
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Advance to the next region.
+ fn finish_region(&mut self) -> SourceResult<()> {
+ // 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);
+ }
+
+ if !size.is_finite() {
+ bail!(self.span, "stack spacing is infinite");
+ }
+
+ let mut output = Frame::hard(size);
+ let mut cursor = Abs::zero();
+ let mut ruler: FixedAlignment = 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, align) => {
+ if self.dir.is_positive() {
+ ruler = ruler.max(align.get(self.axis));
+ } else {
+ ruler = ruler.min(align.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 = align
+ .get(other)
+ .position(size.get(other) - frame.size().get(other));
+
+ let pos = GenericSize::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 = GenericSize::zero();
+ self.fr = Fr::zero();
+ self.finished.push(output);
+
+ Ok(())
+ }
+
+ /// Finish layouting and return the resulting frames.
+ fn finish(mut self) -> SourceResult<Fragment> {
+ self.finish_region()?;
+ Ok(Fragment::frames(self.finished))
+ }
+}
+
+/// A generic size with main and cross axes. The axes are generic, meaning the
+/// main axis could correspond to either the X or the Y axis.
+#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)]
+struct GenericSize<T> {
+ /// The cross component, along the axis perpendicular to the main.
+ pub cross: T,
+ /// The main component.
+ pub main: T,
+}
+
+impl<T> GenericSize<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 GenericSize<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-layout/src/transforms.rs b/crates/typst-layout/src/transforms.rs
new file mode 100644
index 00000000..5ac9f777
--- /dev/null
+++ b/crates/typst-layout/src/transforms.rs
@@ -0,0 +1,246 @@
+use once_cell::unsync::Lazy;
+use typst_library::diag::{bail, SourceResult};
+use typst_library::engine::Engine;
+use typst_library::foundations::{Content, Packed, Resolve, Smart, StyleChain};
+use typst_library::introspection::Locator;
+use typst_library::layout::{
+ Abs, Axes, FixedAlignment, Frame, MoveElem, Point, Ratio, Region, Rel, RotateElem,
+ ScaleAmount, ScaleElem, Size, SkewElem, Transform,
+};
+use typst_utils::Numeric;
+
+/// Layout the moved content.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_move(
+ elem: &Packed<MoveElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ let mut frame = crate::layout_frame(engine, &elem.body, locator, styles, region)?;
+ let delta = Axes::new(elem.dx(styles), elem.dy(styles)).resolve(styles);
+ let delta = delta.zip_map(region.size, Rel::relative_to);
+ frame.translate(delta.to_point());
+ Ok(frame)
+}
+
+/// Layout the rotated content.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_rotate(
+ elem: &Packed<RotateElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ let angle = elem.angle(styles);
+ let align = elem.origin(styles).resolve(styles);
+
+ // Compute the new region's approximate size.
+ let is_finite = region.size.is_finite();
+ let size = if is_finite {
+ compute_bounding_box(region.size, Transform::rotate(-angle)).1
+ } else {
+ Size::splat(Abs::inf())
+ };
+
+ measure_and_layout(
+ engine,
+ locator,
+ region,
+ size,
+ styles,
+ elem.body(),
+ Transform::rotate(angle),
+ align,
+ elem.reflow(styles),
+ )
+}
+
+/// Layout the scaled content.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_scale(
+ elem: &Packed<ScaleElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ // Compute the new region's approximate size.
+ let scale = resolve_scale(elem, engine, locator.relayout(), region.size, styles)?;
+ let size = region
+ .size
+ .zip_map(scale, |r, s| if r.is_finite() { Ratio::new(1.0 / s).of(r) } else { r })
+ .map(Abs::abs);
+
+ measure_and_layout(
+ engine,
+ locator,
+ region,
+ size,
+ styles,
+ elem.body(),
+ Transform::scale(scale.x, scale.y),
+ elem.origin(styles).resolve(styles),
+ elem.reflow(styles),
+ )
+}
+
+/// Resolves scale parameters, preserving aspect ratio if one of the scales
+/// is set to `auto`.
+fn resolve_scale(
+ elem: &Packed<ScaleElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ container: Size,
+ styles: StyleChain,
+) -> SourceResult<Axes<Ratio>> {
+ fn resolve_axis(
+ axis: Smart<ScaleAmount>,
+ body: impl Fn() -> SourceResult<Abs>,
+ styles: StyleChain,
+ ) -> SourceResult<Smart<Ratio>> {
+ Ok(match axis {
+ Smart::Auto => Smart::Auto,
+ Smart::Custom(amt) => Smart::Custom(match amt {
+ ScaleAmount::Ratio(ratio) => ratio,
+ ScaleAmount::Length(length) => {
+ let length = length.resolve(styles);
+ Ratio::new(length / body()?)
+ }
+ }),
+ })
+ }
+
+ let size = Lazy::new(|| {
+ let pod = Region::new(container, Axes::splat(false));
+ let frame = crate::layout_frame(engine, &elem.body, locator, styles, pod)?;
+ SourceResult::Ok(frame.size())
+ });
+
+ let x = resolve_axis(
+ elem.x(styles),
+ || size.as_ref().map(|size| size.x).map_err(Clone::clone),
+ styles,
+ )?;
+
+ let y = resolve_axis(
+ elem.y(styles),
+ || size.as_ref().map(|size| size.y).map_err(Clone::clone),
+ styles,
+ )?;
+
+ match (x, y) {
+ (Smart::Auto, Smart::Auto) => {
+ bail!(elem.span(), "x and y cannot both be auto")
+ }
+ (Smart::Custom(x), Smart::Custom(y)) => Ok(Axes::new(x, y)),
+ (Smart::Auto, Smart::Custom(v)) | (Smart::Custom(v), Smart::Auto) => {
+ Ok(Axes::splat(v))
+ }
+ }
+}
+
+/// Layout the skewed content.
+#[typst_macros::time(span = elem.span())]
+pub fn layout_skew(
+ elem: &Packed<SkewElem>,
+ engine: &mut Engine,
+ locator: Locator,
+ styles: StyleChain,
+ region: Region,
+) -> SourceResult<Frame> {
+ let ax = elem.ax(styles);
+ let ay = elem.ay(styles);
+ let align = elem.origin(styles).resolve(styles);
+
+ // Compute the new region's approximate size.
+ let size = if region.size.is_finite() {
+ compute_bounding_box(region.size, Transform::skew(ax, ay)).1
+ } else {
+ Size::splat(Abs::inf())
+ };
+
+ measure_and_layout(
+ engine,
+ locator,
+ region,
+ size,
+ styles,
+ elem.body(),
+ Transform::skew(ax, ay),
+ align,
+ elem.reflow(styles),
+ )
+}
+
+/// Applies a transformation to a frame, reflowing the layout if necessary.
+#[allow(clippy::too_many_arguments)]
+fn measure_and_layout(
+ engine: &mut Engine,
+ locator: Locator,
+ region: Region,
+ size: Size,
+ styles: StyleChain,
+ body: &Content,
+ transform: Transform,
+ align: Axes<FixedAlignment>,
+ reflow: bool,
+) -> SourceResult<Frame> {
+ if reflow {
+ // Measure the size of the body.
+ let pod = Region::new(size, Axes::splat(false));
+ let frame = crate::layout_frame(engine, body, locator.relayout(), styles, pod)?;
+
+ // Actually perform the layout.
+ let pod = Region::new(frame.size(), Axes::splat(true));
+ let mut frame = crate::layout_frame(engine, body, locator, styles, pod)?;
+ let Axes { x, y } = align.zip_map(frame.size(), FixedAlignment::position);
+
+ // Compute the transform.
+ let ts = Transform::translate(x, y)
+ .pre_concat(transform)
+ .pre_concat(Transform::translate(-x, -y));
+
+ // Compute the bounding box and offset and wrap in a new frame.
+ let (offset, size) = compute_bounding_box(frame.size(), ts);
+ frame.transform(ts);
+ frame.translate(offset);
+ frame.set_size(size);
+ Ok(frame)
+ } else {
+ // Layout the body.
+ let mut frame = crate::layout_frame(engine, body, locator, styles, region)?;
+ let Axes { x, y } = align.zip_map(frame.size(), FixedAlignment::position);
+
+ // Compute the transform.
+ let ts = Transform::translate(x, y)
+ .pre_concat(transform)
+ .pre_concat(Transform::translate(-x, -y));
+
+ // Apply the transform.
+ frame.transform(ts);
+ Ok(frame)
+ }
+}
+
+/// Computes the bounding box and offset of a transformed area.
+fn compute_bounding_box(size: Size, ts: Transform) -> (Point, Size) {
+ let top_left = Point::zero().transform_inf(ts);
+ let top_right = Point::with_x(size.x).transform_inf(ts);
+ let bottom_left = Point::with_y(size.y).transform_inf(ts);
+ let bottom_right = size.to_point().transform_inf(ts);
+
+ // We first compute the new bounding box of the rotated area.
+ let min_x = top_left.x.min(top_right.x).min(bottom_left.x).min(bottom_right.x);
+ let min_y = top_left.y.min(top_right.y).min(bottom_left.y).min(bottom_right.y);
+ let max_x = top_left.x.max(top_right.x).max(bottom_left.x).max(bottom_right.x);
+ let max_y = top_left.y.max(top_right.y).max(bottom_left.y).max(bottom_right.y);
+
+ // Then we compute the new size of the area.
+ let width = max_x - min_x;
+ let height = max_y - min_y;
+
+ (Point::new(-min_x, -min_y), Size::new(width.abs(), height.abs()))
+}