diff --git a/crates/typst/src/introspection/counter.rs b/crates/typst/src/introspection/counter.rs index e7dd2ba08..ba126e180 100644 --- a/crates/typst/src/introspection/counter.rs +++ b/crates/typst/src/introspection/counter.rs @@ -304,7 +304,7 @@ impl Counter { route: Route::extend(route).unnested(), }; - let mut state = CounterState::init(&self.0); + let mut state = CounterState::init(matches!(self.0, CounterKey::Page)); let mut page = NonZeroUsize::ONE; let mut stops = eco_vec![(state.clone(), page)]; @@ -656,12 +656,9 @@ pub struct CounterState(pub SmallVec<[usize; 3]>); impl CounterState { /// Get the initial counter state for the key. - pub fn init(key: &CounterKey) -> Self { - Self(match key { - // special case, because pages always start at one. - CounterKey::Page => smallvec![1], - _ => smallvec![0], - }) + pub fn init(page: bool) -> Self { + // Special case, because pages always start at one. + Self(smallvec![usize::from(page)]) } /// Advance the counter and return the numbers for the given heading. diff --git a/crates/typst/src/layout/columns.rs b/crates/typst/src/layout/columns.rs index b46351f8e..5812e38aa 100644 --- a/crates/typst/src/layout/columns.rs +++ b/crates/typst/src/layout/columns.rs @@ -10,34 +10,39 @@ use crate::layout::{ /// Separates a region into multiple equally sized columns. /// -/// The `column` function allows to separate the interior of any container into -/// multiple columns. It will not equalize the height of the columns, instead, -/// the columns will take up the height of their container or the remaining -/// height on the page. The columns function can break across pages if -/// necessary. +/// The `column` function lets you separate the interior of any container into +/// multiple columns. It will currently not balance the height of the columns. +/// Instead, the columns will take up the height of their container or the +/// remaining height on the page. Support for balanced columns is planned for +/// the future. /// -/// If you need to insert columns across your whole document, you can use the -/// [`{page}` function's `columns` parameter]($page.columns) instead. +/// # Page-level columns { #page-level } +/// If you need to insert columns across your whole document, use the `{page}` +/// function's [`columns` parameter]($page.columns) instead. This will create +/// the columns directly at the page-level rather than wrapping all of your +/// content in a layout container. As a result, things like +/// [pagebreaks]($pagebreak), [footnotes]($footnote), and [line +/// numbers]($par.line) will continue to work as expected. For more information, +/// also read the [relevant part of the page setup +/// guide]($guides/page-setup/#columns). /// -/// # Example -/// ```example -/// = Towards Advanced Deep Learning +/// # Breaking out of columns { #breaking-out } +/// To temporarily break out of columns (e.g. for a paper's title), use +/// page-scoped floating placement: /// -/// #box(height: 68pt, -/// columns(2, gutter: 11pt)[ -/// #set par(justify: true) -/// This research was funded by the -/// National Academy of Sciences. -/// NAoS provided support for field -/// tests and interviews with a -/// grant of up to USD 40.000 for a -/// period of 6 months. -/// ] +/// ```example:single +/// #set page(columns: 2, height: 150pt) +/// +/// #place( +/// top + center, +/// scope: "page", +/// float: true, +/// text(1.4em, weight: "bold")[ +/// My document +/// ], /// ) /// -/// In recent years, deep learning has -/// increasingly been used to solve a -/// variety of problems. +/// #lorem(40) /// ``` #[elem(Show)] pub struct ColumnsElem { @@ -59,7 +64,6 @@ pub struct ColumnsElem { impl Show for Packed { fn show(&self, _: &mut Engine, _: StyleChain) -> SourceResult { Ok(BlockElem::multi_layouter(self.clone(), layout_columns) - .with_rootable(true) .pack() .spanned(self.span())) } diff --git a/crates/typst/src/layout/container.rs b/crates/typst/src/layout/container.rs index b5a8f7f83..2ff0f0fd6 100644 --- a/crates/typst/src/layout/container.rs +++ b/crates/typst/src/layout/container.rs @@ -143,7 +143,7 @@ impl Packed { let inset = self.inset(styles).unwrap_or_default(); // Build the pod region. - let pod = Self::pod(&width, &height, &inset, styles, region); + let pod = unbreakable_pod(&width, &height.into(), &inset, styles, region); // Layout the body. let mut frame = match self.body(styles) { @@ -166,14 +166,6 @@ impl Packed { crate::layout::grow(&mut frame, &inset); } - // 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 = self.baseline(styles).relative_to(frame.height()); - if !shift.is_zero() { - frame.set_baseline(frame.baseline() - shift); - } - // Prepare fill and stroke. let fill = self.fill(styles); let stroke = self @@ -196,49 +188,20 @@ impl Packed { frame.fill_and_stroke(fill, &stroke, &outset, &radius, self.span()); } + // Assign label to the frame. if let Some(label) = self.label() { frame.group(|group| group.label = Some(label)) } - Ok(frame) - } - - /// Builds the pod region for box layout. - fn pod( - width: &Sizing, - height: &Smart, - inset: &Sides>, - styles: StyleChain, - region: Size, - ) -> Region { - // Resolve the size. - let mut size = Size::new( - match width { - // For auto, the whole region is available. - Sizing::Auto => region.x, - // Resolve the relative sizing. - Sizing::Rel(rel) => rel.resolve(styles).relative_to(region.x), - // Fr is handled outside and already factored into the `region`, - // so we can treat it equivalently to 100%. - Sizing::Fr(_) => region.x, - }, - match height { - // See above. Note that fr is not supported on this axis. - Smart::Auto => region.y, - Smart::Custom(rel) => rel.resolve(styles).relative_to(region.y), - }, - ); - - // Take the inset, if any, into account. - if !inset.is_zero() { - size = crate::layout::shrink(size, inset); + // 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 = self.baseline(styles).relative_to(frame.height()); + if !shift.is_zero() { + frame.set_baseline(frame.baseline() - shift); } - // If the child is not auto-sized, the size is forced and we should - // enable expansion. - let expand = Axes::new(*width != Sizing::Auto, *height != Smart::Auto); - - Region::new(size, expand) + Ok(frame) } } @@ -355,7 +318,7 @@ pub struct BlockElem { /// fill: aqua, /// ) /// ``` - pub height: Smart>, + pub height: Sizing, /// Whether the block can be broken and continue on the next page. /// @@ -453,20 +416,16 @@ pub struct BlockElem { #[default(false)] pub clip: bool, - /// Whether this block must stick to the following one. + /// Whether this block must stick to the following one, with no break in + /// between. /// - /// Use this to prevent page breaks between e.g. a heading and its body. - #[internal] + /// This is, by default, set on heading blocks to prevent orphaned headings + /// at the bottom of the page. + /// + /// Marking a block as sticky makes it unbreakable. #[default(false)] - #[parse(None)] pub sticky: bool, - /// Whether this block can host footnotes. - #[internal] - #[default(false)] - #[parse(None)] - pub rootable: bool, - /// The contents of the block. #[positional] #[borrowed] @@ -513,9 +472,97 @@ impl BlockElem { } impl Packed { - /// Layout this block as part of a flow. + /// Lay this out as an unbreakable block. #[typst_macros::time(name = "block", span = self.span())] - pub fn layout( + pub fn layout_single( + &self, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + base: Size, + ) -> SourceResult { + // Fetch sizing properties. + let width = self.width(styles); + let height = self.height(styles); + let inset = self.inset(styles).unwrap_or_default(); + + // Build the pod regions. + let pod = unbreakable_pod(&width.into(), &height, &inset, styles, base); + + // Layout the body. + let body = self.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)) => { + 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)) => { + 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::layout::grow(&mut frame, &inset); + } + + // Prepare fill and stroke. + let fill = self.fill(styles); + let stroke = self + .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(|| self.outset(styles).unwrap_or_default()); + let radius = Lazy::new(|| self.radius(styles).unwrap_or_default()); + + // Clip the contents, if requested. + if self.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) { + frame.fill_and_stroke(fill, &stroke, &outset, &radius, self.span()); + } + + // Assign label to each frame in the fragment. + if let Some(label) = self.label() { + frame.group(|group| group.label = Some(label)); + } + + Ok(frame) + } +} + +impl Packed { + /// Lay this out as a breakable block. + #[typst_macros::time(name = "block", span = self.span())] + pub fn layout_multiple( &self, engine: &mut Engine, locator: Locator, @@ -526,14 +573,13 @@ impl Packed { let width = self.width(styles); let height = self.height(styles); let inset = self.inset(styles).unwrap_or_default(); - let breakable = self.breakable(styles); // Allocate a small vector for backlogs. let mut buf = SmallVec::<[Abs; 2]>::new(); // Build the pod regions. let pod = - Self::pod(&width, &height, &inset, breakable, styles, regions, &mut buf); + breakable_pod(&width.into(), &height, &inset, styles, regions, &mut buf); // Layout the body. let body = self.body(styles); @@ -673,116 +719,6 @@ impl Packed { Ok(fragment) } - - /// Builds the pod regions for block layout. - /// - /// If `breakable` is `false`, this will only ever return a single region. - fn pod<'a>( - width: &Smart, - height: &Smart, - inset: &Sides>, - breakable: bool, - 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 { - Smart::Auto => { - if breakable { - // If the block automatically sized and breakable, we can - // just inherit the regions. - first = regions.size.y; - buf.extend_from_slice(regions.backlog); - backlog = buf; - last = regions.last; - } else { - // If the block is automatically sized, but not breakable, - // we provide the full base height. It doesn't really make - // sense to provide just the remaining height to an - // unbreakable block. - first = regions.full; - backlog = &mut []; - last = None; - } - - // Since we're automatically sized, we inherit the base size. - full = regions.full; - } - - Smart::Custom(rel) => { - // Resolve the sizing to a concrete size. - let resolved = rel.resolve(styles).relative_to(base.y); - - if breakable { - // If the block is fixed-height and breakable, distribute - // the fixed height across a start region and a backlog. - (first, backlog) = distribute(resolved, regions, buf); - } else { - // If the block is fixed-height, but not breakable, the - // fixed height is all in the first region, and we have no - // backlog. - first = resolved; - backlog = &mut []; - } - - // Since we're manually sized, the resolved size is also the - // base height. - full = resolved; - - // 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 { - Smart::Auto => regions.size.x, - Smart::Custom(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::layout::shrink_multiple( - &mut size, &mut full, backlog, &mut last, inset, - ); - } - - // If the child is manually sized along an axis (i.e. not `auto`), then - // it should expand along that axis. We also ensure that we only expand - // if the size is finite because it just doesn't make sense to expand - // into infinite regions. - let expand = Axes::new(*width != Smart::Auto, *height != Smart::Auto) - & size.map(Abs::is_finite); - - Regions { - size, - full, - backlog, - last, - expand, - // This will only ever be set by the flow if the block is - // `rootable`. It is important that we propagate this, so that - // columns can hold footnotes. - root: regions.root, - } - } } /// The contents of a block. @@ -873,6 +809,118 @@ cast! { v: Fr => Self::Fr(v), } +/// Builds the pod region for an unbreakable sized container. +fn unbreakable_pod( + width: &Sizing, + height: &Sizing, + inset: &Sides>, + 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::layout::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>, + 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::layout::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>( diff --git a/crates/typst/src/layout/flow/collect.rs b/crates/typst/src/layout/flow/collect.rs index 13b49d860..73659ee2d 100644 --- a/crates/typst/src/layout/flow/collect.rs +++ b/crates/typst/src/layout/flow/collect.rs @@ -1,3 +1,7 @@ +use std::cell::RefCell; +use std::fmt::{self, Debug, Formatter}; +use std::hash::Hash; + use bumpalo::boxed::Box as BumpBox; use bumpalo::Bump; use once_cell::unsync::Lazy; @@ -5,39 +9,18 @@ use once_cell::unsync::Lazy; use crate::diag::{bail, SourceResult}; use crate::engine::Engine; use crate::foundations::{Packed, Resolve, Smart, StyleChain}; -use crate::introspection::{Locator, Tag, TagElem}; +use crate::introspection::{Locator, SplitLocator, Tag, TagElem}; use crate::layout::{ layout_frame, Abs, AlignElem, Alignment, Axes, BlockElem, ColbreakElem, - FixedAlignment, FlushElem, Fr, Fragment, Frame, PagebreakElem, PlaceElem, Ratio, - Region, Regions, Rel, Size, Spacing, VElem, + FixedAlignment, FlushElem, Fr, Fragment, Frame, PagebreakElem, PlaceElem, + PlacementScope, Ratio, Region, Regions, Rel, Size, Sizing, Spacing, VElem, }; use crate::model::ParElem; use crate::realize::Pair; use crate::text::TextElem; -/// A prepared child in flow layout. -/// -/// The larger variants are bump-boxed to keep the enum size down. -pub enum Child<'a> { - /// An introspection tag. - Tag(&'a Tag), - /// Relative spacing with a specific weakness. - Rel(Rel, u8), - /// Fractional spacing. - Fr(Fr), - /// An already layouted line of a paragraph. - Line(BumpBox<'a, LineChild>), - /// A potentially breakable block. - Block(BumpBox<'a, BlockChild<'a>>), - /// An absolutely or floatingly placed element. - Placed(BumpBox<'a, PlacedChild<'a>>), - /// A column break. - Break(bool), - /// A place flush. - Flush, -} - -/// Collects all content of the flow into prepared children. +/// 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, @@ -47,218 +30,510 @@ pub fn collect<'a>( base: Size, expand: bool, ) -> SourceResult>> { - let mut locator = locator.split(); - let mut output = Vec::with_capacity(children.len()); - let mut last_was_par = false; + Collector { + engine, + bump, + children, + locator: locator.split(), + base, + expand, + output: Vec::with_capacity(children.len()), + last_was_par: false, + } + .run() +} - for &(child, styles) in children { - if let Some(elem) = child.to_packed::() { - output.push(Child::Tag(&elem.tag)); - } else if let Some(elem) = child.to_packed::() { - output.push(match elem.amount { - Spacing::Rel(rel) => { - Child::Rel(rel.resolve(styles), elem.weak(styles) as u8) - } - Spacing::Fr(fr) => Child::Fr(fr), - }); - } else if let Some(elem) = child.to_packed::() { - output.push(Child::Break(elem.weak(styles))); - } else if let Some(elem) = child.to_packed::() { - 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); +/// 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>, + last_was_par: bool, +} - let lines = crate::layout::layout_inline( - engine, - &elem.children, - locator.next(&elem.span()), - styles, - last_was_par, - base, - expand, - )? - .into_frames(); +impl<'a> Collector<'a, '_, '_> { + /// Perform the collection. + fn run(mut self) -> SourceResult>> { + for (idx, &(child, styles)) in self.children.iter().enumerate() { + if let Some(elem) = child.to_packed::() { + self.output.push(Child::Tag(&elem.tag)); + } else if let Some(elem) = child.to_packed::() { + self.v(elem, styles); + } else if let Some(elem) = child.to_packed::() { + self.par(elem, styles)?; + } else if let Some(elem) = child.to_packed::() { + self.block(elem, styles); + } else if let Some(elem) = child.to_packed::() { + self.place(idx, elem, styles)?; + } else if child.is::() { + self.output.push(Child::Flush); + } else if let Some(elem) = child.to_packed::() { + self.output.push(Child::Break(elem.weak(styles))); + } else if child.is::() { + 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()); + } + } - output.push(Child::Rel(spacing.into(), 4)); + Ok(self.output) + } - // 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; + /// Collect vertical spacing into a relative or fractional child. + fn v(&mut self, elem: &'a Packed, 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), + }); + } - // 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)); + /// 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, + 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); - for (i, frame) in lines.into_iter().enumerate() { - if i > 0 { - output.push(Child::Rel(leading.into(), 5)); - } + let lines = crate::layout::layout_inline( + self.engine, + &elem.children, + self.locator.next(&elem.span()), + styles, + self.last_was_par, + self.base, + self.expand, + )? + .into_frames(); - // 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::Rel(spacing.into(), 4)); - let child = LineChild { frame, align, need }; - output.push(Child::Line(BumpBox::new_in(child, bump))); + // 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)); } - output.push(Child::Rel(spacing.into(), 4)); - last_was_par = true; - } else if let Some(elem) = child.to_packed::() { - let locator = locator.next(&elem.span()); - let align = AlignElem::alignment_in(styles).resolve(styles); - let sticky = elem.sticky(styles); - let rootable = elem.rootable(styles); - - 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), + // 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() }; - output.push(spacing(elem.above(styles))); + self.output + .push(Child::Line(self.boxed(LineChild { frame, align, need }))); + } - let child = BlockChild { align, sticky, rootable, elem, styles, locator }; - output.push(Child::Block(BumpBox::new_in(child, bump))); + self.output.push(Child::Rel(spacing.into(), 4)); + self.last_was_par = true; - output.push(spacing(elem.below(styles))); - last_was_par = false; - } else if let Some(elem) = child.to_packed::() { - let locator = locator.next(&elem.span()); - let float = elem.float(styles); - let clearance = elem.clearance(styles); - let delta = Axes::new(elem.dx(styles), elem.dy(styles)).resolve(styles); + Ok(()) + } - 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))); + /// Collect a block into a [`SingleChild`] or [`MultiChild`] depending on + /// whether it is breakable. + fn block(&mut self, elem: &'a Packed, styles: StyleChain<'a>) { + let locator = self.locator.next(&elem.span()); + let align = AlignElem::alignment_in(styles).resolve(styles); + let sticky = elem.sticky(styles); + let breakable = elem.breakable(styles); + let fr = match elem.height(styles) { + Sizing::Fr(fr) => Some(fr), + _ => None, + }; - match (float, align_y) { - (true, Smart::Custom(None | Some(FixedAlignment::Center))) => bail!( - elem.span(), - "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, ..)`" - ), - _ => {} - } + 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), + }; - let child = PlacedChild { - float, - clearance, - delta, - align_x, - align_y, + self.output.push(spacing(elem.above(styles))); + + if !breakable || sticky || fr.is_some() { + self.output.push(Child::Single(self.boxed(SingleChild { + align, + sticky, + fr, elem, styles, locator, - alignment, - }; - output.push(Child::Placed(BumpBox::new_in(child, bump))); - } else if child.is::() { - output.push(Child::Flush); - } else if child.is::() { - bail!( - child.span(), "pagebreaks are not allowed inside of containers"; - hint: "try using a `#colbreak()` instead", - ); + cell: CachedCell::new(), + }))); } else { - bail!(child.span(), "{} is not allowed here", child.func().name()); - } + let alone = self.children.len() == 1; + self.output.push(Child::Multi(self.boxed(MultiChild { + align, + alone, + elem, + styles, + locator, + cell: CachedCell::new(), + }))); + }; + + self.output.push(spacing(elem.below(styles))); + self.last_was_par = false; } - Ok(output) + /// Collects a placed element into a [`PlacedChild`]. + fn place( + &mut self, + idx: usize, + elem: &'a Packed, + 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::Page { + bail!( + elem.span(), + "page-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 { + idx, + 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(&self, value: T) -> BumpBox<'a, T> { + BumpBox::new_in(value, self.bump) + } } -/// A child that encapsulates a paragraph line. +/// 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, 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, pub need: Abs, } -/// A child that encapsulates a prepared block. -pub struct BlockChild<'a> { +/// A child that encapsulates a prepared unbreakable block. +#[derive(Debug)] +pub struct SingleChild<'a> { pub align: Axes, pub sticky: bool, - pub rootable: bool, + pub fr: Option, elem: &'a Packed, styles: StyleChain<'a>, locator: Locator<'a>, + cell: CachedCell>, } -impl BlockChild<'_> { +impl SingleChild<'_> { + /// Build the child's frame given the region's base size. + pub fn layout(&self, engine: &mut Engine, base: Size) -> SourceResult { + self.cell.get_or_init(base, |base| { + self.elem + .layout_single(engine, self.locator.relayout(), self.styles, base) + .map(|frame| frame.post_processed(self.styles)) + }) + } +} + +/// A child that encapsulates a prepared breakable block. +#[derive(Debug)] +pub struct MultiChild<'a> { + pub align: Axes, + alone: bool, + elem: &'a Packed, + styles: StyleChain<'a>, + locator: Locator<'a>, + cell: CachedCell>, +} + +impl<'a> MultiChild<'a> { /// Build the child's frames given regions. - pub fn layout( + pub fn layout<'b>( + &'b self, + engine: &mut Engine, + regions: Regions, + ) -> SourceResult<(Frame, Option>)> { + let fragment = self.layout_impl(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![], + }); + } + + Ok((frame, spill)) + } + + /// The shared internal implementation of [`Self::layout`] and + /// [`MultiSpill::layout`]. + fn layout_impl( &self, engine: &mut Engine, regions: Regions, ) -> SourceResult { - let mut 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; self.elem - .layout(engine, self.locator.relayout(), self.styles, regions)?; + .layout_multiple(engine, self.locator.relayout(), self.styles, regions) + .map(|mut fragment| { + for frame in &mut fragment { + frame.post_process(self.styles); + } + fragment + }) + }) + } +} - for frame in &mut fragment { - frame.post_process(self.styles); +/// 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, +} + +impl MultiSpill<'_, '_> { + /// Build the spill's frames given regions. + pub fn layout( + mut self, + engine: &mut Engine, + regions: Regions, + ) -> SourceResult<(Frame, Option)> { + // We build regions for the whole `MultiChild` with the sizes passed to + // earlier parts of it plus the new regions. Then, we layout the + // complete block, but extract only the suffix that interests us. + self.backlog.push(regions.size.y); + + let mut backlog: Vec<_> = + self.backlog.iter().chain(regions.backlog).copied().collect(); + + // Remove unnecessary backlog items (also to prevent it from growing + // unnecessarily, which would change the region's hash). + while !backlog.is_empty() && backlog.last().copied() == regions.last { + backlog.pop(); } - Ok(fragment) + // 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_impl(engine, pod)? + .into_iter() + .skip(self.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 { + self.multi.align } } /// A child that encapsulates a prepared placed element. +#[derive(Debug)] pub struct PlacedChild<'a> { + pub idx: usize, + pub align_x: FixedAlignment, + pub align_y: Smart>, + pub scope: PlacementScope, pub float: bool, pub clearance: Abs, pub delta: Axes>, - pub align_x: FixedAlignment, - pub align_y: Smart>, elem: &'a Packed, styles: StyleChain<'a>, locator: Locator<'a>, alignment: Smart, + cell: CachedCell>, } impl PlacedChild<'_> { /// Build the child's frame given the region's base size. pub fn layout(&self, engine: &mut Engine, base: Size) -> SourceResult { - let align = self.alignment.unwrap_or_else(|| Alignment::CENTER); - let aligned = AlignElem::set_alignment(align).wrap(); - - let mut frame = layout_frame( - engine, - &self.elem.body, - self.locator.relayout(), - self.styles.chain(&aligned), - Region::new(base, Axes::splat(false)), - )?; - - frame.post_process(self.styles); - Ok(frame) + self.cell.get_or_init(base, |base| { + let align = self.alignment.unwrap_or_else(|| Alignment::CENTER); + let aligned = AlignElem::set_alignment(align).wrap(); + layout_frame( + engine, + &self.elem.body, + self.locator.relayout(), + self.styles.chain(&aligned), + Region::new(base, Axes::splat(false)), + ) + .map(|frame| frame.post_processed(self.styles)) + }) + } +} + +/// 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(RefCell>); + +impl CachedCell { + /// Create an empty cached cell. + fn new() -> Self { + Self(RefCell::new(None)) + } + + /// Perform the computation `f` with caching. + fn get_or_init(&self, input: I, f: F) -> T + where + I: Hash, + T: Clone, + F: FnOnce(I) -> T, + { + let input_hash = crate::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 Default for CachedCell { + fn default() -> Self { + Self::new() + } +} + +impl Debug for CachedCell { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.pad("CachedCell(..)") } } diff --git a/crates/typst/src/layout/flow/compose.rs b/crates/typst/src/layout/flow/compose.rs new file mode 100644 index 000000000..e5d6f7b12 --- /dev/null +++ b/crates/typst/src/layout/flow/compose.rs @@ -0,0 +1,843 @@ +use std::num::NonZeroUsize; + +use super::{distribute, Config, FlowResult, PlacedChild, Skip, Stop, Work}; +use crate::diag::SourceResult; +use crate::engine::Engine; +use crate::foundations::{Content, NativeElement, Packed, Resolve, Smart}; +use crate::introspection::{ + Counter, CounterDisplayElem, CounterState, CounterUpdate, Locator, SplitLocator, + TagKind, +}; +use crate::layout::{ + layout_fragment, layout_frame, Abs, Axes, Dir, FixedAlignment, Frame, FrameItem, + OuterHAlignment, PlacementScope, Point, Region, Regions, Rel, Size, +}; +use crate::model::{ + FootnoteElem, FootnoteEntry, LineNumberingScope, Numbering, ParLine, ParLineMarker, +}; +use crate::syntax::Span; +use crate::utils::NonZeroExt; + +/// 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 { + 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>, + footnote_queue: Vec>, +} + +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 { + // This loop can restart region layout when requested to do so by a + // `Stop`. This happens when there is a page-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::Page)) => { + *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 { + // 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 { + // 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 self.config.root { + layout_line_numbers( + self.engine, + self.config, + locator, + self.column, + &mut output, + )?; + } + + Ok(output) + } + + /// Lay out the inner contents of a column. + fn column_contents(&mut self, regions: Regions) -> FlowResult { + // 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, ®ions, true)?; + } + + 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. + if self.skipped(Skip::Placed(placed.idx)) { + 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::Page => 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::Page => { + 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 { Abs::zero() } else { placed.clearance }; + let need = frame.height() + clearance; + + // If the float doesn't fit, queue it for the next region. + if !remaining.fits(need) && !regions.in_last() { + 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::Page => &mut self.page_insertions, + }; + + // Put the float there. + area.push_float(placed, frame, align_y); + area.skips.push(Skip::Placed(placed.idx)); + + // 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 notes = find_in_frame::(frame); + if notes.is_empty() { + return Ok(()); + } + + let mut relayout = false; + let mut regions = *regions; + let mut migratable = !breakable && !regions.in_last(); + + 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, + 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(Skip::Footnote(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_fragment( + self.engine, + &FootnoteEntry::new(elem.clone()).pack(), + Locator::synthesize(elem.location().unwrap()), + self.config.shared, + pod, + )? + .into_frames(); + + // Find nested footnotes in the entry. + let nested = find_in_frames::(&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(Skip::Footnote(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, + 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, skip: Skip) -> bool { + self.work.skips.contains(&skip) + || self.page_insertions.skips.contains(&skip) + || self.column_insertions.skips.contains(&skip) + } + + /// 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 { + layout_frame( + engine, + &config.footnote.separator, + Locator::root(), + config.shared, + Region::new(base, Axes::new(config.footnote.expand, false)), + ) +} + +/// 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, + footnote_separator: Option, + top_size: Abs, + bottom_size: Abs, + width: Abs, + skips: Vec, +} + +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); + + 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); + } + + 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); + } + + 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, + 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 + && ParLine::numbering_scope_in(config.shared) == 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::(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) + }; + + // Compute the marker's horizontal position. Will be adjusted based on + // the maximum number width later. + let clearance = marker.number_clearance.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 { + let counter = Counter::of(ParLineMarker::elem()); + let update = CounterUpdate::Set(CounterState::init(false)); + let content = counter.update(Span::detached(), update); + 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 { + 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 = 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(frame: &Frame) -> Vec<(Abs, Packed)> { + 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(frames: &[Frame]) -> Vec<(Abs, Packed)> { + let mut output = vec![]; + for frame in frames { + find_in_frame_impl(&mut output, frame, Abs::zero()); + } + output +} + +fn find_in_frame_impl( + output: &mut Vec<(Abs, Packed)>, + 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) if tag.kind() == TagKind::Start => { + if let Some(elem) = tag.elem().to_packed::() { + output.push((y, elem.clone())); + } + } + _ => {} + } + } +} diff --git a/crates/typst/src/layout/flow/distribute.rs b/crates/typst/src/layout/flow/distribute.rs new file mode 100644 index 000000000..71f9598b8 --- /dev/null +++ b/crates/typst/src/layout/flow/distribute.rs @@ -0,0 +1,512 @@ +use super::{ + Child, Composer, FlowResult, LineChild, MultiChild, MultiSpill, PlacedChild, + SingleChild, Stop, Work, +}; +use crate::introspection::Tag; +use crate::layout::{ + Abs, Axes, FixedAlignment, Fr, Frame, FrameItem, Point, Region, Regions, Rel, Size, +}; +use crate::utils::Numeric; + +/// 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 { + let mut distributor = Distributor { + composer, + regions, + items: vec![], + sticky: None, + stickable: false, + }; + let init = distributor.snapshot(); + let forced = match distributor.run() { + Ok(()) => true, + 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>, + /// A snapshot which can be restored to migrate a suffix of sticky blocks to + /// the next region. + sticky: Option>, + /// 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> { + /// 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), + /// 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::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); + } + + /// Processes relative spacing. + fn rel(&mut self, amount: Rel, 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::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::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::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 we're allowed to break, finish the + // region. + if !self.regions.size.y.fits(line.frame.height()) && !self.regions.in_last() { + 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<()> { + // Handle fractionally sized blocks. + if let Some(fr) = single.fr { + self.items.push(Item::Fr(fr, Some(single))); + return Ok(()); + } + + // Lay out the block. + let frame = single.layout(self.composer.engine, self.regions.base())?; + + // If the block doesn't fit and we're allowed to break, finish the + // region. + if !self.regions.size.y.fits(frame.height()) && !self.regions.in_last() { + 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, false, 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, + mut frame: Frame, + align: Axes, + sticky: bool, + breakable: bool, + ) -> FlowResult<()> { + if sticky { + // If the frame is sticky and we haven't remember 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; + } + + if !frame.is_empty() { + // Drain tags. + let tags = &mut self.composer.work.tags; + if !tags.is_empty() { + frame.prepend_multiple( + tags.iter().map(|&tag| (Point::zero(), FrameItem::Tag(tag.clone()))), + ); + } + + // Handle footnotes. + self.composer + .footnotes(&self.regions, &frame, frame.height(), breakable)?; + + // Clear the drained tags _after_ the footnotes are handled because + // a [`Stop::Finish`] could otherwise lose them. + self.composer.work.tags.clear(); + } + + // Push an item for the frame. + self.regions.size.y -= frame.height(); + 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.is_empty())?; + 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.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 { + if !forced { + 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(); + + // 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, _) => frs += *v, + Item::Frame(frame, _) => { + used.y += frame.height(); + used.x.set_max(frame.width()); + } + Item::Placed(..) => {} + } + } + + // When we have fractional spacing, occupy the remaining space with it. + let mut fr_space = Abs::zero(); + let mut fr_frames = vec![]; + 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. + for item in &self.items { + let Item::Fr(v, Some(single)) = item else { continue }; + let length = v.share(frs, fr_space); + let base = Size::new(region.size.x, length); + let frame = single.layout(self.composer.engine, base)?; + 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 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::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(size.y - used.y); + 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(size.y - used.y), + }; + + let pos = Point::new(x, y) + + placed.delta.zip_map(size, Rel::relative_to).to_point(); + + output.push_frame(pos, frame); + } + } + } + + // If this is the very end of the flow, drain trailing tags. + if forced && !self.composer.work.tags.is_empty() { + let tags = &mut self.composer.work.tags; + let pos = Point::with_y(offset); + output.push_multiple( + tags.iter().map(|&tag| (pos, FrameItem::Tag(tag.clone()))), + ); + tags.clear(); + } + + 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/src/layout/flow/mod.rs b/crates/typst/src/layout/flow/mod.rs index 351ef6b11..2491ef7c8 100644 --- a/crates/typst/src/layout/flow/mod.rs +++ b/crates/typst/src/layout/flow/mod.rs @@ -1,41 +1,40 @@ -//! Layout of content -//! - at the top-level, into a [`Document`]. -//! - inside of a container, into a [`Frame`] or [`Fragment`]. +//! Layout of content into a [`Frame`] or [`Fragment`]. mod collect; +mod compose; +mod distribute; use std::collections::HashSet; use std::num::NonZeroUsize; +use std::rc::Rc; use bumpalo::Bump; use comemo::{Track, Tracked, TrackedMut}; +use ecow::EcoVec; -use self::collect::{collect, BlockChild, Child, LineChild, PlacedChild}; -use crate::diag::{bail, At, SourceResult}; -use crate::engine::{Engine, Route, Sink, Traced}; -use crate::foundations::{ - Content, NativeElement, Packed, Resolve, SequenceElem, Smart, StyleChain, +use self::collect::{ + collect, Child, LineChild, MultiChild, MultiSpill, PlacedChild, SingleChild, }; +use self::compose::{compose, Composer}; +use self::distribute::distribute; +use crate::diag::{bail, At, SourceDiagnostic, SourceResult}; +use crate::engine::{Engine, Route, Sink, Traced}; +use crate::foundations::{Content, Packed, StyleChain}; use crate::introspection::{ - Counter, CounterDisplayElem, CounterKey, CounterState, CounterUpdate, Introspector, - Location, Locator, LocatorLink, SplitLocator, Tag, + Introspector, Location, Locator, LocatorLink, SplitLocator, Tag, }; use crate::layout::{ - Abs, Axes, BlockElem, Dir, FixedAlignment, Fr, Fragment, Frame, FrameItem, - OuterHAlignment, Point, Region, Regions, Rel, Size, -}; -use crate::model::{ - FootnoteElem, FootnoteEntry, ParLine, ParLineMarker, ParLineNumberingScope, + Abs, Dir, Fragment, Frame, PlacementScope, Region, Regions, Rel, Size, }; +use crate::model::{FootnoteElem, FootnoteEntry}; use crate::realize::{realize, Arenas, Pair, RealizationKind}; -use crate::syntax::Span; use crate::text::TextElem; use crate::utils::{NonZeroExt, Numeric}; use crate::World; -/// Layout content into multiple regions. +/// Lays out content into multiple regions. /// -/// When just layouting into a single region, prefer [`layout_frame`]. +/// When laying out into just one region, prefer [`layout_frame`]. pub fn layout_fragment( engine: &mut Engine, content: &Content, @@ -58,12 +57,10 @@ pub fn layout_fragment( ) } -/// Layout content into regions with columns. +/// Lays out content into regions with columns. /// -/// For now, this just invokes normal layout on cycled smaller regions. However, -/// in the future, columns will be able to interact (e.g. through floating -/// figures), so this is already factored out because it'll be conceptually -/// different from just layouting into more smaller regions. +/// This is different from just laying out into column-sized regions as the +/// columns can interact due to page-scoped placed elements. pub fn layout_fragment_with_columns( engine: &mut Engine, content: &Content, @@ -88,7 +85,7 @@ pub fn layout_fragment_with_columns( ) } -/// Layout content into a single region. +/// Lays out content into a single region, producing a single frame. pub fn layout_frame( engine: &mut Engine, content: &Content, @@ -116,6 +113,13 @@ fn layout_fragment_impl( columns: NonZeroUsize, column_gutter: Rel, ) -> SourceResult { + 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 { @@ -140,1108 +144,219 @@ fn layout_fragment_impl( layout_flow( &mut engine, - &arenas.bump, &children, &mut locator, styles, regions, columns, column_gutter, - content.span(), + false, ) } -/// Layout flow content. +/// Lays out realized content into regions, potentially with columns. #[allow(clippy::too_many_arguments)] pub(crate) fn layout_flow( engine: &mut Engine, - bump: &Bump, children: &[Pair], locator: &mut SplitLocator, shared: StyleChain, - regions: Regions, + mut regions: Regions, columns: NonZeroUsize, column_gutter: Rel, - span: Span, + root: bool, ) -> SourceResult { - // Separating the infinite space into infinite columns does not make - // much sense. - let mut columns = columns.get(); - if !regions.size.x.is_finite() { - columns = 1; - } - - // Determine the width of the gutter and each column. - let column_gutter = column_gutter.relative_to(regions.base().x); - - let backlog: Vec; - let mut pod = if columns > 1 { - backlog = std::iter::once(®ions.size.y) - .chain(regions.backlog) - .flat_map(|&height| std::iter::repeat(height).take(columns)) - .skip(1) - .collect(); - - let width = - (regions.size.x - column_gutter * (columns - 1) as f64) / columns as f64; - - // Create the pod regions. - Regions { - size: Size::new(width, regions.size.y), - full: regions.full, - backlog: &backlog, - last: regions.last, - expand: Axes::new(true, regions.expand.y), - root: regions.root, - } - } else { - regions - }; - - // The children aren't root. - pod.root = false; - - // Check whether we have just a single multiple-layoutable element. In - // that case, we do not set `expand.y` to `false`, but rather keep it at - // its original value (since that element can take the full space). - // - // Consider the following code: `block(height: 5cm, pad(10pt, - // align(bottom, ..)))`. Thanks to the code below, the expansion will be - // passed all the way through the block & pad and reach the innermost - // flow, so that things are properly bottom-aligned. - let mut alone = false; - if let [(child, _)] = children { - alone = child.is::(); - } - - // Disable vertical expansion when there are multiple or not directly - // layoutable children. - if !alone { - pod.expand.y = false; - } - - let children = - collect(engine, bump, children, locator.next(&()), pod.base(), pod.expand.x)?; - - let layouter = FlowLayouter { - engine, - span, - root: regions.root, - locator, + // Prepare configuration that is shared across the whole flow. + let config = Config { + root, shared, - columns, - column_gutter, - regions: pod, - expand: regions.expand, - initial: pod.size, - items: vec![], - pending_tags: vec![], - pending_floats: vec![], - has_footnotes: false, - footnote_config: FootnoteConfig { + 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, }, - visited_footnotes: HashSet::new(), - finished: vec![], }; - layouter.layout(&children, regions) + // 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)) } -/// Layouts a collection of block-level elements. -struct FlowLayouter<'a, 'b, 'x, 'y> { - /// The engine. - engine: &'a mut Engine<'x>, - /// A span to use for errors. - span: Span, - /// Whether this is the root flow. +/// 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>, + /// 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>, + /// Spilled frames of a footnote that didn't fully fit. Similar to `spill`. + footnote_spill: Option>, + /// 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>, +} + +/// Identifies an element that that can be skipped if visited because it was +/// already processed. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +enum Skip { + /// Uniquely identifies a placed elements. We can't use a [`Location`] + /// because `PlaceElem` is not currently locatable. + Placed(usize), + /// Uniquely identifies a footnote. + Footnote(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: &[Skip]) { + 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, - /// Provides unique locations to the flow's children. - locator: &'a mut SplitLocator<'y>, - /// The shared styles. - shared: StyleChain<'a>, - /// The number of columns. - columns: usize, - /// The gutter between columns. - column_gutter: Abs, - /// The regions to layout children into. These already incorporate the - /// columns. - regions: Regions<'a>, - /// Whether the flow should expand to fill the region. - expand: Axes, - /// The initial size of `regions.size` that was available before we started - /// subtracting. - initial: Size, - /// Spacing and layouted blocks for the current region. - items: Vec>, - /// A queue of tags that will be attached to the next frame. - pending_tags: Vec<&'a Tag>, - /// A queue of floating elements. - pending_floats: Vec>, - /// Whether we have any footnotes in the current region. - has_footnotes: bool, - /// Footnote configuration. - footnote_config: FootnoteConfig, - /// Footnotes that we have already processed. - visited_footnotes: HashSet, - /// Finished frames for previous regions. - finished: Vec, + /// 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, } -/// Cached footnote configuration. +/// 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, } -/// Information needed to generate a line number. -struct CollectedParLine { - y: Abs, - marker: Packed, +/// 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, } -/// A prepared item in a flow layout. -enum FlowItem<'a, 'b> { - /// Spacing between other items and its weakness level. - Absolute(Abs, u8), - /// Fractional spacing between other items. - Fractional(Fr), - /// A frame for a layouted block. - Frame { - /// The frame itself. - frame: Frame, - /// How to align the frame. - align: Axes, - /// Whether the frame sticks to the item after it (for orphan prevention). - sticky: bool, - /// Whether the frame comes from a rootable block, which may be laid - /// out as a root flow and thus display its own line numbers. - /// Therefore, we do not display line numbers for these frames. - /// - /// Currently, this is only used by columns. - rootable: bool, - /// Whether the frame is movable; that is, kept together with its - /// footnotes. - /// - /// This is true for frames created by paragraphs and - /// [`BlockElem::single_layouter`] elements. - movable: bool, - }, - /// An absolutely placed frame. - Placed(&'b PlacedChild<'a>, Frame, Smart>), - /// A footnote frame (can also be the separator). - Footnote(Frame), +/// The result type for flow layout. +/// +/// The `Err(_)` variant incorporate control flow events for finishing and +/// relayouting regions. +type FlowResult = Result; + +/// 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), } -impl FlowItem<'_, '_> { - /// Whether this item is out-of-flow. - /// - /// Out-of-flow items are guaranteed to have a [zero size][Size::zero()]. - fn is_out_of_flow(&self) -> bool { - match self { - Self::Placed(placed, ..) => !placed.float, - Self::Frame { frame, .. } => { - frame.size().is_zero() - && frame.items().all(|(_, item)| { - matches!(item, FrameItem::Link(_, _) | FrameItem::Tag(_)) - }) - } - _ => false, - } - } -} - -impl<'a, 'b, 'x, 'y> FlowLayouter<'a, 'b, 'x, 'y> { - /// Layout the flow. - fn layout( - mut self, - children: &'b [Child<'a>], - regions: Regions, - ) -> SourceResult { - for child in children { - match child { - Child::Tag(tag) => { - self.pending_tags.push(tag); - } - Child::Rel(amount, weakness) => { - self.handle_rel(*amount, *weakness)?; - } - Child::Fr(fr) => { - self.handle_item(FlowItem::Fractional(*fr))?; - } - Child::Line(line) => { - self.handle_line(line)?; - } - Child::Block(block) => { - self.handle_block(block)?; - } - Child::Placed(placed) => { - self.handle_placed(placed)?; - } - Child::Break(weak) => { - self.handle_colbreak(*weak)?; - } - Child::Flush => { - self.handle_flush()?; - } - } - } - - self.finish(regions) - } - - /// Layout relative spacing, handling weakness. - fn handle_rel(&mut self, amount: Rel, weakness: u8) -> SourceResult<()> { - self.handle_item(FlowItem::Absolute( - // Resolve the spacing relative to the current base height. - amount.relative_to(self.initial.y), - weakness, - )) - } - - /// Layout a paragraph. - fn handle_line(&mut self, line: &LineChild) -> SourceResult<()> { - // If the first line doesn’t fit in this region, then defer any - // previous sticky frame to the next region (if available) - if !self.regions.in_last() - && !self.regions.size.y.fits(line.need) - && self - .regions - .iter() - .nth(1) - .is_some_and(|region| region.y.fits(line.need)) - { - self.finish_region_with_migration()?; - } - - let mut frame = line.frame.clone(); - self.drain_tag(&mut frame); - self.handle_item(FlowItem::Frame { - frame, - align: line.align, - sticky: false, - rootable: false, - movable: true, - }) - } - - /// Layout into multiple regions. - fn handle_block(&mut self, block: &BlockChild) -> SourceResult<()> { - // If the block is "rootable" it may host footnotes. In that case, we - // defer rootness to it temporarily. We disable our own rootness to - // prevent duplicate footnotes. - let is_root = self.root; - if is_root && block.rootable { - self.root = false; - self.regions.root = true; - } - - // Skip directly if region is already full. - if self.regions.is_full() { - self.finish_region(false)?; - } - - // Layout the block itself. - let fragment = block.layout(self.engine, self.regions)?; - - let mut notes = Vec::new(); - for (i, mut frame) in fragment.into_iter().enumerate() { - // Find footnotes in the frame. - if self.root { - self.collect_footnotes(&mut notes, &frame); - } - - if i > 0 { - self.finish_region(false)?; - } - - self.drain_tag(&mut frame); - self.handle_item(FlowItem::Frame { - frame, - align: block.align, - sticky: block.sticky, - rootable: block.rootable, - movable: false, - })?; - } - - self.try_handle_footnotes(notes)?; - - self.root = is_root; - self.regions.root = false; - - Ok(()) - } - - /// Layout a placed element. - fn handle_placed(&mut self, placed: &'b PlacedChild<'a>) -> SourceResult<()> { - let frame = placed.layout(self.engine, self.regions.base())?; - self.handle_item(FlowItem::Placed(placed, frame, placed.align_y)) - } - - /// Layout a column break. - fn handle_colbreak(&mut self, _weak: bool) -> SourceResult<()> { - // If there is still an available region, skip to it. - // TODO: Turn this into a region abstraction. - if !self.regions.backlog.is_empty() || self.regions.last.is_some() { - self.finish_region(true)?; - } - Ok(()) - } - - /// Lays out all floating elements before continuing with other content. - fn handle_flush(&mut self) -> SourceResult<()> { - for item in std::mem::take(&mut self.pending_floats) { - self.handle_item(item)?; - } - while !self.pending_floats.is_empty() { - self.finish_region(false)?; - } - Ok(()) - } - - /// Layout a finished frame. - fn handle_item(&mut self, mut item: FlowItem<'a, 'b>) -> SourceResult<()> { - match item { - FlowItem::Absolute(v, weakness) => { - if weakness > 0 { - let mut has_frame = false; - for prev in self.items.iter_mut().rev() { - match prev { - FlowItem::Frame { .. } => { - has_frame = true; - break; - } - FlowItem::Absolute(prev_amount, prev_level) - if *prev_level > 0 => - { - if *prev_level >= weakness { - let diff = v - *prev_amount; - if *prev_level > weakness || diff > Abs::zero() { - self.regions.size.y -= diff; - *prev = item; - } - } - return Ok(()); - } - FlowItem::Fractional(_) => return Ok(()), - _ => {} - } - } - if !has_frame { - return Ok(()); - } - } - self.regions.size.y -= v; - } - FlowItem::Fractional(..) => { - self.trim_weak_spacing(); - } - FlowItem::Frame { ref frame, movable, .. } => { - let height = frame.height(); - while !self.regions.size.y.fits(height) && !self.regions.in_last() { - self.finish_region(false)?; - } - - let in_last = self.regions.in_last(); - self.regions.size.y -= height; - if self.root && movable { - let mut notes = Vec::new(); - self.collect_footnotes(&mut notes, frame); - self.items.push(item); - - // When we are already in_last, we can directly force the - // footnotes. - if !self.handle_footnotes(&mut notes, true, in_last)? { - let item = self.items.pop(); - self.finish_region(false)?; - self.items.extend(item); - self.regions.size.y -= height; - self.handle_footnotes(&mut notes, true, true)?; - } - return Ok(()); - } - } - FlowItem::Placed(placed, ..) if !placed.float => {} - FlowItem::Placed(placed, ref mut frame, ref mut align_y) => { - // If there is a queued float in front or if the float doesn't - // fit, queue it for the next region. - if !self.pending_floats.is_empty() - || (!self.regions.size.y.fits(frame.height() + placed.clearance) - && !self.regions.in_last()) - { - self.pending_floats.push(item); - return Ok(()); - } - - // Select the closer placement, top or bottom. - if align_y.is_auto() { - // When the figure'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 = self.regions.full - self.regions.size.y; - let half = (frame.height() + placed.clearance) / 2.0; - let ratio = (used + half) / self.regions.full; - let better_align = if ratio <= 0.5 { - FixedAlignment::Start - } else { - FixedAlignment::End - }; - *align_y = Smart::Custom(Some(better_align)); - } - - // Add some clearance so that the float doesn't touch the main - // content. - frame.size_mut().y += placed.clearance; - if *align_y == Smart::Custom(Some(FixedAlignment::End)) { - frame.translate(Point::with_y(placed.clearance)); - } - - self.regions.size.y -= frame.height(); - - // Find footnotes in the frame. - if self.root { - let mut notes = vec![]; - self.collect_footnotes(&mut notes, frame); - self.try_handle_footnotes(notes)?; - } - } - FlowItem::Footnote(_) => {} - } - - self.items.push(item); - Ok(()) - } - - /// Trim trailing weak spacing from the items. - fn trim_weak_spacing(&mut self) { - for (i, item) in self.items.iter().enumerate().rev() { - match item { - FlowItem::Absolute(amount, 1..) => { - self.regions.size.y += *amount; - self.items.remove(i); - return; - } - FlowItem::Frame { .. } => return, - _ => {} - } - } - } - - /// Attach currently pending metadata to the frame. - fn drain_tag(&mut self, frame: &mut Frame) { - if !self.pending_tags.is_empty() && !frame.is_empty() { - frame.prepend_multiple( - self.pending_tags - .drain(..) - .map(|tag| (Point::zero(), FrameItem::Tag(tag.clone()))), - ); - } - } - - /// Finisht the region, migrating all sticky items to the next one. - /// - /// Returns whether we migrated into a last region. - fn finish_region_with_migration(&mut self) -> SourceResult<()> { - // Find the suffix of sticky items. - let mut sticky = self.items.len(); - for (i, item) in self.items.iter().enumerate().rev() { - match *item { - FlowItem::Absolute(_, _) => {} - FlowItem::Frame { sticky: true, .. } => sticky = i, - _ => break, - } - } - - let carry: Vec<_> = self.items.drain(sticky..).collect(); - self.finish_region(false)?; - - for item in carry { - self.handle_item(item)?; - } - - Ok(()) - } - - /// Finish the frame for one region. - /// - /// Set `force` to `true` to allow creating a frame for out-of-flow elements - /// only (this is used to force the creation of a frame in case the - /// remaining elements are all out-of-flow). - fn finish_region(&mut self, force: bool) -> SourceResult<()> { - self.trim_weak_spacing(); - - // Early return if we don't have any relevant items. - if !force - && !self.items.is_empty() - && self.items.iter().all(FlowItem::is_out_of_flow) - { - // Run line number layout here even though we have no line numbers - // to ensure we reset line numbers at the start of the page if - // requested, which is still necessary if e.g. the first column is - // empty when the others aren't. - let mut output = Frame::soft(self.initial); - self.layout_line_numbers(&mut output, self.initial, vec![])?; - - self.finished.push(output); - self.regions.next(); - self.initial = self.regions.size; - return Ok(()); - } - - // Determine the used size. - let mut fr = Fr::zero(); - let mut used = Size::zero(); - let mut footnote_height = Abs::zero(); - let mut float_top_height = Abs::zero(); - let mut float_bottom_height = Abs::zero(); - let mut first_footnote = true; - for item in &self.items { - match item { - FlowItem::Absolute(v, _) => used.y += *v, - FlowItem::Fractional(v) => fr += *v, - FlowItem::Frame { frame, .. } => { - used.y += frame.height(); - used.x.set_max(frame.width()); - } - FlowItem::Placed(placed, ..) if !placed.float => {} - FlowItem::Placed(_, frame, align_y) => match align_y { - Smart::Custom(Some(FixedAlignment::Start)) => { - float_top_height += frame.height() - } - Smart::Custom(Some(FixedAlignment::End)) => { - float_bottom_height += frame.height() - } - _ => {} - }, - FlowItem::Footnote(frame) => { - footnote_height += frame.height(); - if !first_footnote { - footnote_height += self.footnote_config.gap; - } - first_footnote = false; - used.x.set_max(frame.width()); - } - } - } - used.y += footnote_height + float_top_height + float_bottom_height; - - // Determine the size of the flow in this region depending on whether - // the region expands. Also account for fractional spacing and - // footnotes. - let mut size = self.expand.select(self.initial, used).min(self.initial); - if (fr.get() > 0.0 || self.has_footnotes) && self.initial.y.is_finite() { - size.y = self.initial.y; - } - - if !self.regions.size.x.is_finite() && self.expand.x { - bail!(self.span, "cannot expand into infinite width"); - } - if !self.regions.size.y.is_finite() && self.expand.y { - bail!(self.span, "cannot expand into infinite height"); - } - - let mut output = Frame::soft(size); - let mut ruler = FixedAlignment::Start; - let mut float_top_offset = Abs::zero(); - let mut offset = float_top_height; - let mut float_bottom_offset = Abs::zero(); - let mut footnote_offset = Abs::zero(); - - let mut lines: Vec = vec![]; - - // Place all frames. - for item in self.items.drain(..) { - match item { - FlowItem::Absolute(v, _) => { - offset += v; - } - FlowItem::Fractional(v) => { - let remaining = self.initial.y - used.y; - let length = v.share(fr, remaining); - offset += length; - } - FlowItem::Frame { frame, align, rootable, .. } => { - ruler = ruler.max(align.y); - let x = align.x.position(size.x - frame.width()); - let y = offset + ruler.position(size.y - used.y); - let pos = Point::new(x, y); - offset += frame.height(); - - // Do not display line numbers for frames coming from - // rootable blocks as they will display their own line - // numbers when laid out as a root flow themselves. - if self.root && !rootable { - collect_par_lines(&mut lines, &frame, pos, Abs::zero()); - } - - output.push_frame(pos, frame); - } - FlowItem::Placed(placed, frame, align_y) => { - let x = placed.align_x.position(size.x - frame.width()); - let y = if placed.float { - match align_y { - Smart::Custom(Some(FixedAlignment::Start)) => { - let y = float_top_offset; - float_top_offset += frame.height(); - y - } - Smart::Custom(Some(FixedAlignment::End)) => { - let y = size.y - footnote_height - float_bottom_height - + float_bottom_offset; - float_bottom_offset += frame.height(); - y - } - _ => unreachable!("float must be y aligned"), - } - } else { - match align_y { - Smart::Custom(Some(align)) => { - align.position(size.y - frame.height()) - } - _ => offset + ruler.position(size.y - used.y), - } - }; - - let pos = Point::new(x, y) - + placed.delta.zip_map(size, Rel::relative_to).to_point(); - - if self.root { - collect_par_lines(&mut lines, &frame, pos, Abs::zero()); - } - - output.push_frame(pos, frame); - } - FlowItem::Footnote(frame) => { - let y = size.y - footnote_height + footnote_offset; - footnote_offset += frame.height() + self.footnote_config.gap; - output.push_frame(Point::with_y(y), frame); - } - } - } - - // Sort, deduplicate and layout line numbers. - // - // We do this after placing all frames since they might not necessarily - // be ordered by height (e.g. you can have a `place(bottom)` followed - // by a paragraph, but the paragraph appears at the top), so we buffer - // all line numbers to later sort and deduplicate them based on how - // close they are to each other in `layout_line_numbers`. - self.layout_line_numbers(&mut output, size, lines)?; - - if force && !self.pending_tags.is_empty() { - let pos = Point::with_y(offset); - output.push_multiple( - self.pending_tags - .drain(..) - .map(|tag| (pos, FrameItem::Tag(tag.clone()))), - ); - } - - // Advance to the next region. - self.finished.push(output); - self.regions.next(); - self.initial = self.regions.size; - self.has_footnotes = false; - - // Try to place floats into the next region. - for item in std::mem::take(&mut self.pending_floats) { - self.handle_item(item)?; - } - - Ok(()) - } - - /// Finish layouting and return the resulting fragment. - fn finish(mut self, regions: Regions) -> SourceResult { - if self.expand.y { - while !self.regions.backlog.is_empty() { - self.finish_region(true)?; - } - } - - self.finish_region(true)?; - while !self.items.is_empty() { - self.finish_region(true)?; - } - - if self.columns == 1 { - return Ok(Fragment::frames(self.finished)); - } - - // Stitch together the column for each region. - let dir = TextElem::dir_in(self.shared); - let total = (self.finished.len() as f32 / self.columns as f32).ceil() as usize; - - let mut collected = vec![]; - let mut iter = self.finished.into_iter(); - for region in regions.iter().take(total) { - // The height should be the parent height if we should expand. - // Otherwise its the maximum column height for the frame. In that - // case, the frame is first created with zero height and then - // resized. - let height = if regions.expand.y { region.y } else { Abs::zero() }; - let mut output = Frame::hard(Size::new(regions.size.x, height)); - let mut cursor = Abs::zero(); - - for _ in 0..self.columns { - let Some(frame) = iter.next() else { break }; - if !regions.expand.y { - output.size_mut().y.set_max(frame.height()); - } - - let width = frame.width(); - let x = if dir == Dir::LTR { - cursor - } else { - regions.size.x - cursor - width - }; - - output.push_frame(Point::with_x(x), frame); - cursor += width + self.column_gutter; - } - - collected.push(output); - } - - Ok(Fragment::frames(collected)) - } - - /// Tries to process all footnotes in the frame, placing them - /// in the next region if they could not be placed in the current - /// one. - fn try_handle_footnotes( - &mut self, - mut notes: Vec>, - ) -> SourceResult<()> { - // When we are already in_last, we can directly force the - // footnotes. - if self.root - && !self.handle_footnotes(&mut notes, false, self.regions.in_last())? - { - self.finish_region(false)?; - self.handle_footnotes(&mut notes, false, true)?; - } - Ok(()) - } - - /// Processes all footnotes in the frame. - /// - /// Returns true if the footnote entries fit in the allotted - /// regions. - fn handle_footnotes( - &mut self, - notes: &mut Vec>, - movable: bool, - force: bool, - ) -> SourceResult { - let prev_notes_len = notes.len(); - let prev_items_len = self.items.len(); - let prev_size = self.regions.size; - let prev_has_footnotes = self.has_footnotes; - - // Process footnotes one at a time. - let mut k = 0; - while k < notes.len() { - if notes[k].is_ref() { - k += 1; - continue; - } - - if !self.has_footnotes { - self.layout_footnote_separator()?; - } - - self.regions.size.y -= self.footnote_config.gap; - let frames = layout_fragment( - self.engine, - &FootnoteEntry::new(notes[k].clone()).pack(), - Locator::synthesize(notes[k].location().unwrap()), - self.shared, - self.regions.with_root(false), - )? - .into_frames(); - - // If the entries didn't fit, abort (to keep footnote and entry - // together). - if !force - && (k == 0 || movable) - && frames.first().is_some_and(Frame::is_empty) - { - // Undo everything. - notes.truncate(prev_notes_len); - self.items.truncate(prev_items_len); - self.regions.size = prev_size; - self.has_footnotes = prev_has_footnotes; - return Ok(false); - } - - let prev = notes.len(); - for (i, frame) in frames.into_iter().enumerate() { - self.collect_footnotes(notes, &frame); - if i > 0 { - self.finish_region(false)?; - self.layout_footnote_separator()?; - self.regions.size.y -= self.footnote_config.gap; - } - self.regions.size.y -= frame.height(); - self.items.push(FlowItem::Footnote(frame)); - } - - k += 1; - - // Process the nested notes before dealing with further top-level - // notes. - let nested = notes.len() - prev; - if nested > 0 { - notes[k..].rotate_right(nested); - } - } - - Ok(true) - } - - /// Layout and save the footnote separator, typically a line. - fn layout_footnote_separator(&mut self) -> SourceResult<()> { - let expand = Axes::new(self.regions.expand.x, false); - let pod = Region::new(self.regions.base(), expand); - let separator = &self.footnote_config.separator; - - // FIXME: Shouldn't use `root()` here. - let mut frame = - layout_frame(self.engine, separator, Locator::root(), self.shared, pod)?; - frame.size_mut().y += self.footnote_config.clearance; - frame.translate(Point::with_y(self.footnote_config.clearance)); - - self.has_footnotes = true; - self.regions.size.y -= frame.height(); - self.items.push(FlowItem::Footnote(frame)); - - Ok(()) - } - - /// Layout 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( - &mut self, - output: &mut Frame, - size: Size, - mut lines: Vec, - ) -> SourceResult<()> { - // Reset page-scoped line numbers if currently at the first column. - if self.root - && (self.columns == 1 || self.finished.len() % self.columns == 0) - && ParLine::numbering_scope_in(self.shared) == ParLineNumberingScope::Page - { - let reset = - CounterState::init(&CounterKey::Selector(ParLineMarker::elem().select())); - let counter = Counter::of(ParLineMarker::elem()); - let update = counter.update(Span::detached(), CounterUpdate::Set(reset)); - let locator = self.locator.next(&update); - let pod = Region::new(Axes::splat(Abs::zero()), Axes::splat(false)); - let reset_frame = - layout_frame(self.engine, &update, locator, self.shared, pod)?; - output.push_frame(Point::zero(), reset_frame); - } - - if lines.is_empty() { - // We always stop here if this is not the root flow. - 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(|line| line.y); - - // 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![]; - // Used for horizontal alignment. - let mut max_number_width = Abs::zero(); - let mut prev_bottom = None; - for line in lines { - if prev_bottom.is_some_and(|prev_bottom| line.y < prev_bottom) { - // Lines are too close together. Display as the same line - // number. - continue; - } - - let current_column = self.finished.len() % self.columns; - let number_margin = if self.columns >= 2 && current_column + 1 == self.columns - { - // The last column will always place line numbers at the end - // margin. This should become configurable in the future. - OuterHAlignment::End.resolve(self.shared) - } else { - line.marker.number_margin().resolve(self.shared) - }; - - let number_align = line - .marker - .number_align() - .map(|align| align.resolve(self.shared)) - .unwrap_or_else(|| number_margin.inv()); - - let number_clearance = line.marker.number_clearance().resolve(self.shared); - let number = self.layout_line_number(line.marker)?; - let number_x = match number_margin { - FixedAlignment::Start => -number_clearance, - FixedAlignment::End => size.x + number_clearance, - - // Shouldn't be specifiable by the user due to - // 'OuterHAlignment'. - FixedAlignment::Center => unreachable!(), - }; - let number_pos = Point::new(number_x, line.y); - - // 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(line.y + number.height().max(Abs::pt(1.0))); - - // Collect line numbers and compute the max width so we can align - // them later. - max_number_width.set_max(number.width()); - line_numbers.push((number_pos, number, number_align, number_margin)); - } - - for (mut pos, number, align, margin) in line_numbers { - if matches!(margin, FixedAlignment::Start) { - // Move the line number backwards the more aligned to the left - // it is, instead of moving to the right when it's right - // aligned. We do it this way, without fully overriding the - // 'x' coordinate, to preserve the original clearance between - // the line numbers and the text. - pos.x -= - max_number_width - align.position(max_number_width - number.width()); - } else { - // Move the line number forwards when aligned to the right. - // Leave as is when aligned to the left. - pos.x += align.position(max_number_width - number.width()); - } - - output.push_frame(pos, number); - } - - Ok(()) - } - - /// 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, as 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( - &mut self, - marker: Packed, - ) -> SourceResult { - let counter = Counter::of(ParLineMarker::elem()); - let counter_update = counter - .clone() - .update(Span::detached(), CounterUpdate::Step(NonZeroUsize::ONE)); - let counter_display = CounterDisplayElem::new( - counter, - Smart::Custom(marker.numbering().clone()), - false, - ); - let number = SequenceElem::new(vec![counter_update, counter_display.pack()]); - let locator = self.locator.next(&number); - - let pod = Region::new(Axes::splat(Abs::inf()), Axes::splat(false)); - let mut frame = - layout_frame(self.engine, &number.pack(), locator, self.shared, pod)?; - - // Ensure the baseline of the line number aligns with the line's own - // baseline. - frame.translate(Point::with_y(-frame.baseline())); - - Ok(frame) - } - - /// Collect all footnotes in a frame. - fn collect_footnotes( - &mut self, - notes: &mut Vec>, - frame: &Frame, - ) { - for (_, item) in frame.items() { - match item { - FrameItem::Group(group) => self.collect_footnotes(notes, &group.frame), - FrameItem::Tag(tag) => { - let Some(footnote) = tag.elem().to_packed::() else { - continue; - }; - if self.visited_footnotes.insert(tag.location()) { - notes.push(footnote.clone()); - } - } - _ => {} - } - } - } -} - -/// Collect all numbered paragraph lines in the frame. -/// The 'prev_y' parameter starts at 0 on the first call to 'collect_par_lines'. -/// On each subframe we encounter, we add that subframe's position to 'prev_y', -/// until we reach a line's tag, at which point we add the tag's position -/// and finish. That gives us the relative height of the line from the start of -/// the initial frame. -fn collect_par_lines( - lines: &mut Vec, - frame: &Frame, - frame_pos: Point, - prev_y: Abs, -) { - for (pos, item) in frame.items() { - match item { - FrameItem::Group(group) => { - collect_par_lines(lines, &group.frame, frame_pos, prev_y + pos.y) - } - - // Unlike footnotes, we don't need to guard against duplicate tags - // here, since we already deduplicate line markers based on their - // height later on, in `finish_region`. - FrameItem::Tag(tag) => { - let Some(marker) = tag.elem().to_packed::() else { - continue; - }; - - // 1. 'prev_y' is the accumulated relative height from the top - // of the frame we're searching so far; - // 2. 'prev_y + pos.y' gives us the final relative height of - // the line we just found from the top of the initial frame; - // 3. 'frame_pos.y' is the height of the initial frame relative - // to the root flow (and thus its absolute 'y'); - // 4. Therefore, 'y' will be the line's absolute 'y' in the - // page based on its marker's position, and thus the 'y' we - // should use for line numbers. In particular, this represents - // the 'y' at the line's general baseline, due to the marker - // placement logic within the 'line::commit()' function in the - // 'inline' module. We only account for the line number's own - // baseline later, upon layout. - let y = frame_pos.y + prev_y + pos.y; - - lines.push(CollectedParLine { y, marker: marker.clone() }); - } - _ => {} - } +impl From> for Stop { + fn from(error: EcoVec) -> Self { + Stop::Error(error) } } diff --git a/crates/typst/src/layout/frame.rs b/crates/typst/src/layout/frame.rs index 60b690dd3..2f68e9361 100644 --- a/crates/typst/src/layout/frame.rs +++ b/crates/typst/src/layout/frame.rs @@ -320,6 +320,12 @@ impl Frame { /// that result from realization will take care of it and the styles can /// only apply to them as a whole, not part of it (because they don't manage /// styles). + pub fn post_processed(mut self, styles: StyleChain) -> Self { + self.post_process(styles); + self + } + + /// Post process in place. pub fn post_process(&mut self, styles: StyleChain) { if !self.is_empty() { self.post_process_raw( diff --git a/crates/typst/src/layout/inline/line.rs b/crates/typst/src/layout/inline/line.rs index 1ac56e521..7115630ef 100644 --- a/crates/typst/src/layout/inline/line.rs +++ b/crates/typst/src/layout/inline/line.rs @@ -511,28 +511,25 @@ pub fn commit( let region = Size::new(amount, full); let mut frame = elem.layout(engine, loc.relayout(), *styles, region)?; - frame.post_process(*styles); frame.translate(Point::with_y(TextElem::baseline_in(*styles))); - push(&mut offset, frame); + push(&mut offset, frame.post_processed(*styles)); } else { offset += amount; } } Item::Text(shaped) => { - let mut frame = shaped.build( + let frame = shaped.build( engine, &p.spans, justification_ratio, extra_justification, ); - frame.post_process(shaped.styles); - push(&mut offset, frame); + push(&mut offset, frame.post_processed(shaped.styles)); } Item::Frame(frame, styles) => { let mut frame = frame.clone(); - frame.post_process(*styles); frame.translate(Point::with_y(TextElem::baseline_in(*styles))); - push(&mut offset, frame); + push(&mut offset, frame.post_processed(*styles)); } Item::Tag(tag) => { let mut frame = Frame::soft(Size::zero()); diff --git a/crates/typst/src/layout/pages/mod.rs b/crates/typst/src/layout/pages/mod.rs index 860c43f94..574703d73 100644 --- a/crates/typst/src/layout/pages/mod.rs +++ b/crates/typst/src/layout/pages/mod.rs @@ -1,3 +1,5 @@ +//! Layout of content into a [`Document`]. + mod collect; mod finalize; mod run; diff --git a/crates/typst/src/layout/pages/run.rs b/crates/typst/src/layout/pages/run.rs index 091dba221..b5c2834fc 100644 --- a/crates/typst/src/layout/pages/run.rs +++ b/crates/typst/src/layout/pages/run.rs @@ -13,7 +13,6 @@ use crate::layout::{ }; use crate::model::Numbering; use crate::realize::Pair; -use crate::syntax::Span; use crate::text::TextElem; use crate::utils::Numeric; use crate::visualize::Paint; @@ -117,11 +116,6 @@ fn layout_page_run_impl( .resolve(styles) .relative_to(size); - // Realize columns. - let area = size - margin.sum_by_axis(); - let mut regions = Regions::repeat(area, area.map(Abs::is_finite)); - regions.root = true; - let fill = PageElem::fill_in(styles); let foreground = PageElem::foreground_in(styles); let background = PageElem::background_in(styles); @@ -167,17 +161,16 @@ fn layout_page_run_impl( }; // Layout the children. - let bump = bumpalo::Bump::new(); + let area = size - margin.sum_by_axis(); let fragment = layout_flow( &mut engine, - &bump, children, &mut locator, styles, - regions, + Regions::repeat(area, area.map(Abs::is_finite)), PageElem::columns_in(styles), ColumnsElem::gutter_in(styles), - Span::detached(), + true, )?; // Layouts a single marginal. diff --git a/crates/typst/src/layout/place.rs b/crates/typst/src/layout/place.rs index 5c508872b..dfca40f46 100644 --- a/crates/typst/src/layout/place.rs +++ b/crates/typst/src/layout/place.rs @@ -1,4 +1,4 @@ -use crate::foundations::{elem, scope, Content, Smart}; +use crate::foundations::{elem, scope, Cast, Content, Smart}; use crate::layout::{Alignment, Em, Length, Rel}; /// Places content at an absolute position. @@ -35,6 +35,19 @@ pub struct PlaceElem { #[default(Smart::Custom(Alignment::START))] pub alignment: Smart, + /// Relative to which containing scope something is placed. + /// + /// Page-scoped floating placement is primarily used with figures and, for + /// this reason, the figure function has a mirrored [`scope` + /// parameter]($figure.scope). Nonetheless, it can also be more generally + /// useful to break out of the columns. A typical example would be to + /// [create a single-column title section]($guides/page-setup/#columns) in a + /// two-column document. + /// + /// Note that page-scoped placement is currently only supported if `float` + /// is `{true}`. This may change in the future. + pub scope: PlacementScope, + /// Whether the placed element has floating layout. /// /// Floating elements are positioned at the top or bottom of the page, @@ -61,6 +74,8 @@ pub struct PlaceElem { pub float: bool, /// The amount of clearance the placed element has in a floating layout. + /// + /// Has no effect if `float` is `{false}`. #[default(Em::new(1.5).into())] #[resolve] pub clearance: Length, @@ -98,6 +113,16 @@ impl PlaceElem { type FlushElem; } +/// Relative to which containing scope something shall be placed. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum PlacementScope { + /// Place into the current column. + #[default] + Column, + /// Place relative to the page, letting the content span over all columns. + Page, +} + /// Asks the layout algorithm to place pending floating elements before /// continuing with the content. /// diff --git a/crates/typst/src/layout/regions.rs b/crates/typst/src/layout/regions.rs index 7ff2e1c42..68ad4b7a9 100644 --- a/crates/typst/src/layout/regions.rs +++ b/crates/typst/src/layout/regions.rs @@ -27,7 +27,6 @@ impl From for Regions<'_> { full: region.size.y, backlog: &[], last: None, - root: false, } } } @@ -53,11 +52,6 @@ pub struct Regions<'a> { /// The height of the final region that is repeated once the backlog is /// drained. The width is the same for all regions. pub last: Option, - /// Whether these are the root regions or direct descendants. - /// - /// True for the padded page regions and columns directly in the page, - /// false otherwise. - pub root: bool, } impl Regions<'_> { @@ -69,7 +63,6 @@ impl Regions<'_> { backlog: &[], last: Some(size.y), expand, - root: false, } } @@ -98,7 +91,6 @@ impl Regions<'_> { backlog, last: self.last.map(|y| f(Size::new(x, y)).y), expand: self.expand, - root: self.root, } } @@ -114,11 +106,6 @@ impl Regions<'_> { self.backlog.is_empty() && self.last.map_or(true, |height| self.size.y == height) } - /// The same regions, but with different `root` configuration. - pub fn with_root(self, root: bool) -> Self { - Self { root, ..self } - } - /// Advance to the next region if there is any. pub fn next(&mut self) { if let Some(height) = self diff --git a/crates/typst/src/math/fragment.rs b/crates/typst/src/math/fragment.rs index 17e988785..a3fcc9c64 100644 --- a/crates/typst/src/math/fragment.rs +++ b/crates/typst/src/math/fragment.rs @@ -467,12 +467,11 @@ pub struct FrameFragment { } impl FrameFragment { - pub fn new(ctx: &MathContext, styles: StyleChain, mut frame: Frame) -> Self { + pub fn new(ctx: &MathContext, styles: StyleChain, frame: Frame) -> Self { let base_ascent = frame.ascent(); let accent_attach = frame.width() / 2.0; - frame.post_process(styles); Self { - frame, + 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), diff --git a/crates/typst/src/model/figure.rs b/crates/typst/src/model/figure.rs index 618a6b4e4..08606c265 100644 --- a/crates/typst/src/model/figure.rs +++ b/crates/typst/src/model/figure.rs @@ -15,7 +15,7 @@ use crate::introspection::{ }; use crate::layout::{ AlignElem, Alignment, BlockBody, BlockElem, Em, HAlignment, Length, OuterVAlignment, - PlaceElem, VAlignment, VElem, + PlaceElem, PlacementScope, VAlignment, VElem, }; use crate::model::{Numbering, NumberingPattern, Outlinable, Refable, Supplement}; use crate::text::{Lang, Region, TextElem}; @@ -133,6 +133,14 @@ pub struct FigureElem { /// ``` pub placement: Option>, + /// Relative to which containing scope something is placed. + /// + /// Set this to `{"page"}` to create a full-width figure in a two-column + /// document. + /// + /// Has no effect if `placement` is `{none}`. + pub scope: PlacementScope, + /// The figure's caption. pub caption: Option>, @@ -325,8 +333,9 @@ impl Show for Packed { // Wrap in a float. if let Some(align) = self.placement(styles) { realized = PlaceElem::new(realized) - .with_float(true) .with_alignment(align.map(|align| HAlignment::Center + align)) + .with_scope(self.scope(styles)) + .with_float(true) .pack() .spanned(self.span()); } diff --git a/crates/typst/src/model/footnote.rs b/crates/typst/src/model/footnote.rs index 2aeaad1a7..813990a99 100644 --- a/crates/typst/src/model/footnote.rs +++ b/crates/typst/src/model/footnote.rs @@ -125,6 +125,9 @@ impl Packed { let footnote = element .to_packed::() .ok_or("referenced element should be a footnote")?; + if self.location() == footnote.location() { + bail!("footnote cannot reference itself"); + } footnote.declaration_location(engine) } _ => Ok(self.location().unwrap()), diff --git a/crates/typst/src/model/par.rs b/crates/typst/src/model/par.rs index 326d151eb..2e23bd74c 100644 --- a/crates/typst/src/model/par.rs +++ b/crates/typst/src/model/par.rs @@ -297,8 +297,8 @@ pub struct ParLine { /// Second line again /// ``` #[ghost] - #[default(ParLineNumberingScope::Document)] - pub numbering_scope: ParLineNumberingScope, + #[default(LineNumberingScope::Document)] + pub numbering_scope: LineNumberingScope, } impl Construct for ParLine { @@ -310,7 +310,7 @@ impl Construct for ParLine { /// Possible line numbering scope options, indicating how often the line number /// counter should be reset. #[derive(Debug, Cast, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ParLineNumberingScope { +pub enum LineNumberingScope { /// Indicates the line number counter spans the whole document, that is, /// is never automatically reset. Document, diff --git a/crates/typst/src/visualize/image/mod.rs b/crates/typst/src/visualize/image/mod.rs index b11cf5871..d267ca044 100644 --- a/crates/typst/src/visualize/image/mod.rs +++ b/crates/typst/src/visualize/image/mod.rs @@ -22,7 +22,7 @@ use crate::foundations::{ use crate::introspection::Locator; use crate::layout::{ Abs, Axes, BlockElem, FixedAlignment, Frame, FrameItem, Length, Point, Region, Rel, - Size, + Size, Sizing, }; use crate::loading::Readable; use crate::model::Figurable; @@ -79,7 +79,7 @@ pub struct ImageElem { pub width: Smart>, /// The height of the image. - pub height: Smart>, + pub height: Sizing, /// A text describing the image. pub alt: Option, @@ -127,7 +127,7 @@ impl ImageElem { width: Option>>, /// The height of the image. #[named] - height: Option>>, + height: Option, /// A text describing the image. #[named] alt: Option>, diff --git a/crates/typst/src/visualize/shape.rs b/crates/typst/src/visualize/shape.rs index b2125bf5b..633872ccf 100644 --- a/crates/typst/src/visualize/shape.rs +++ b/crates/typst/src/visualize/shape.rs @@ -8,7 +8,7 @@ use crate::foundations::{ use crate::introspection::Locator; use crate::layout::{ layout_frame, Abs, Axes, BlockElem, Corner, Corners, Frame, FrameItem, Length, Point, - Ratio, Region, Rel, Sides, Size, + Ratio, Region, Rel, Sides, Size, Sizing, }; use crate::syntax::Span; use crate::utils::Get; @@ -33,7 +33,7 @@ pub struct RectElem { pub width: Smart>, /// The rectangle's height, relative to its parent container. - pub height: Smart>, + pub height: Sizing, /// How to fill the rectangle. /// @@ -202,9 +202,9 @@ pub struct SquareElem { /// height. #[parse(match size { None => args.named("height")?, - size => size, + size => size.map(Into::into), })] - pub height: Smart>, + pub height: Sizing, /// How to fill the square. See the [rectangle's documentation]($rect.fill) /// for more details. @@ -293,7 +293,7 @@ pub struct EllipseElem { pub width: Smart>, /// The ellipse's height, relative to its parent container. - pub height: Smart>, + pub height: Sizing, /// How to fill the ellipse. See the [rectangle's documentation]($rect.fill) /// for more details. @@ -399,9 +399,9 @@ pub struct CircleElem { /// height. #[parse(match size { None => args.named("height")?, - size => size, + size => size.map(Into::into), })] - pub height: Smart>, + pub height: Sizing, /// How to fill the circle. See the [rectangle's documentation]($rect.fill) /// for more details. diff --git a/docs/guides/page-setup.md b/docs/guides/page-setup.md index 14c35e1ba..ac475583d 100644 --- a/docs/guides/page-setup.md +++ b/docs/guides/page-setup.md @@ -390,59 +390,52 @@ Add columns to your document to fit more on a page while maintaining legible line lengths. Columns are vertical blocks of text which are separated by some whitespace. This space is called the gutter. -If all of your content needs to be laid out in columns, you can just specify the -desired number of columns in the [`{page}`]($page.columns) set rule: +To lay out your content in columns, just specify the desired number of columns +in a [`{page}`]($page.columns) set rule. To adjust the amount of space between +the columns, add a set rule on the [`columns` function]($columns), specifying +the `gutter` parameter. ```example >>> #set page(height: 120pt) #set page(columns: 2) +#set columns(gutter: 12pt) + #lorem(30) ``` -If you need to adjust the gutter between the columns, refer to the method used -in the next section. - -### Use columns anywhere in your document { #columns-anywhere } Very commonly, scientific papers have a single-column title and abstract, while -the main body is set in two-columns. To achieve this effect, Typst includes a -standalone [`{columns}` function]($columns) that can be used to insert columns -anywhere on a page. - -Conceptually, the `columns` function must wrap the content of the columns: +the main body is set in two-columns. To achieve this effect, Typst's [`place` +function]($place) can temporarily escape the two-column layout by specifying +`{float: true}` and `{scope: "page"}`: ```example:single >>> #set page(height: 180pt) -= Impacts of Odobenidae - +#set page(columns: 2) #set par(justify: true) ->>> #h(11pt) -#columns(2)[ - == About seals in the wild - #lorem(80) -] -``` -However, we can use the ["everything show rule"]($styling/#show-rules) to reduce -nesting and write more legible Typst markup: - -```example:single ->>> #set page(height: 180pt) -= Impacts of Odobenidae - -#set par(justify: true) ->>> #h(11pt) -#show: columns.with(2) +#place( + top + center, + float: true, + scope: "page", + text(1.4em, weight: "bold")[ + Impacts of Odobenidae + ], +) == About seals in the wild #lorem(80) ``` -The show rule will wrap everything that comes after it in its function. The -[`with` method]($function.with) allows us to pass arguments, in this case, the -column count, to a function without calling it. +_Floating placement_ refers to elements being pushed to the top or bottom of the +column or page, with the remaining content flowing in between. It is also +frequently used for [figures]($figure.placement). -Another use of the `columns` function is to create columns inside of a container -like a rectangle or to customize gutter size: +### Use columns anywhere in your document { #columns-anywhere } +To create columns within a nested layout, e.g. within a rectangle, you can use +the [`columns` function]($columns) directly. However, it should really only be +used within nested layouts. At the page-level, the page set rule is preferrable +because it has better interactions with things like page-level floats, +footnotes, and line numbers. ```example #rect( diff --git a/tests/ref/block-fr-height-auto-width.png b/tests/ref/block-fr-height-auto-width.png new file mode 100644 index 000000000..21cd3f519 Binary files /dev/null and b/tests/ref/block-fr-height-auto-width.png differ diff --git a/tests/ref/block-fr-height-first-child.png b/tests/ref/block-fr-height-first-child.png new file mode 100644 index 000000000..0a1795769 Binary files /dev/null and b/tests/ref/block-fr-height-first-child.png differ diff --git a/tests/ref/block-fr-height-multiple.png b/tests/ref/block-fr-height-multiple.png new file mode 100644 index 000000000..f502343c8 Binary files /dev/null and b/tests/ref/block-fr-height-multiple.png differ diff --git a/tests/ref/block-fr-height.png b/tests/ref/block-fr-height.png new file mode 100644 index 000000000..657cb43a7 Binary files /dev/null and b/tests/ref/block-fr-height.png differ diff --git a/tests/ref/block-sticky-alone.png b/tests/ref/block-sticky-alone.png new file mode 100644 index 000000000..74e30b3b1 Binary files /dev/null and b/tests/ref/block-sticky-alone.png differ diff --git a/tests/ref/block-sticky-colbreak.png b/tests/ref/block-sticky-colbreak.png new file mode 100644 index 000000000..a56b25d9f Binary files /dev/null and b/tests/ref/block-sticky-colbreak.png differ diff --git a/tests/ref/block-sticky-many.png b/tests/ref/block-sticky-many.png new file mode 100644 index 000000000..cdcf291dd Binary files /dev/null and b/tests/ref/block-sticky-many.png differ diff --git a/tests/ref/block-sticky.png b/tests/ref/block-sticky.png new file mode 100644 index 000000000..4f236c898 Binary files /dev/null and b/tests/ref/block-sticky.png differ diff --git a/tests/ref/box-width-fr.png b/tests/ref/box-fr-width.png similarity index 100% rename from tests/ref/box-width-fr.png rename to tests/ref/box-fr-width.png diff --git a/tests/ref/colbreak-weak.png b/tests/ref/colbreak-weak.png new file mode 100644 index 000000000..e2ce5b96a Binary files /dev/null and b/tests/ref/colbreak-weak.png differ diff --git a/tests/ref/figure-placement.png b/tests/ref/figure-placement.png new file mode 100644 index 000000000..c9ebd2bae Binary files /dev/null and b/tests/ref/figure-placement.png differ diff --git a/tests/ref/footnote-block-at-end.png b/tests/ref/footnote-block-at-end.png new file mode 100644 index 000000000..86416c485 Binary files /dev/null and b/tests/ref/footnote-block-at-end.png differ diff --git a/tests/ref/footnote-break-across-pages-block.png b/tests/ref/footnote-break-across-pages-block.png new file mode 100644 index 000000000..ae27b41a5 Binary files /dev/null and b/tests/ref/footnote-break-across-pages-block.png differ diff --git a/tests/ref/footnote-break-across-pages-float.png b/tests/ref/footnote-break-across-pages-float.png new file mode 100644 index 000000000..eb5f6f5da Binary files /dev/null and b/tests/ref/footnote-break-across-pages-float.png differ diff --git a/tests/ref/footnote-break-across-pages-nested.png b/tests/ref/footnote-break-across-pages-nested.png new file mode 100644 index 000000000..490618446 Binary files /dev/null and b/tests/ref/footnote-break-across-pages-nested.png differ diff --git a/tests/ref/footnote-break-across-pages.png b/tests/ref/footnote-break-across-pages.png index 73a483b2d..8e5deabc3 100644 Binary files a/tests/ref/footnote-break-across-pages.png and b/tests/ref/footnote-break-across-pages.png differ diff --git a/tests/ref/footnote-float-priority.png b/tests/ref/footnote-float-priority.png new file mode 100644 index 000000000..267973311 Binary files /dev/null and b/tests/ref/footnote-float-priority.png differ diff --git a/tests/ref/footnote-in-list.png b/tests/ref/footnote-in-list.png new file mode 100644 index 000000000..504c35788 Binary files /dev/null and b/tests/ref/footnote-in-list.png differ diff --git a/tests/ref/footnote-in-place.png b/tests/ref/footnote-in-place.png new file mode 100644 index 000000000..d41316dbe Binary files /dev/null and b/tests/ref/footnote-in-place.png differ diff --git a/tests/ref/footnote-in-table.png b/tests/ref/footnote-in-table.png index 3f8f50ca1..7aa2bbf46 100644 Binary files a/tests/ref/footnote-in-table.png and b/tests/ref/footnote-in-table.png differ diff --git a/tests/ref/footnote-multiple-in-one-line.png b/tests/ref/footnote-multiple-in-one-line.png new file mode 100644 index 000000000..6f60b609c Binary files /dev/null and b/tests/ref/footnote-multiple-in-one-line.png differ diff --git a/tests/ref/footnote-nested-same-frame.png b/tests/ref/footnote-nested-same-frame.png deleted file mode 100644 index b22276d5c..000000000 Binary files a/tests/ref/footnote-nested-same-frame.png and /dev/null differ diff --git a/tests/ref/footnote-nested.png b/tests/ref/footnote-nested.png index fecf2e8de..50cc2637f 100644 Binary files a/tests/ref/footnote-nested.png and b/tests/ref/footnote-nested.png differ diff --git a/tests/ref/issue-2213-align-fr.png b/tests/ref/issue-2213-align-fr.png new file mode 100644 index 000000000..66f56d76f Binary files /dev/null and b/tests/ref/issue-2213-align-fr.png differ diff --git a/tests/ref/issue-3481-cite-location.png b/tests/ref/issue-3481-cite-location.png index cfc13db51..63388bd25 100644 Binary files a/tests/ref/issue-3481-cite-location.png and b/tests/ref/issue-3481-cite-location.png differ diff --git a/tests/ref/issue-3641-float-loop.png b/tests/ref/issue-3641-float-loop.png index c898d54e9..c294c1e47 100644 Binary files a/tests/ref/issue-3641-float-loop.png and b/tests/ref/issue-3641-float-loop.png differ diff --git a/tests/ref/issue-3866-block-migration.png b/tests/ref/issue-3866-block-migration.png new file mode 100644 index 000000000..6e48f4864 Binary files /dev/null and b/tests/ref/issue-3866-block-migration.png differ diff --git a/tests/ref/issue-footnotes-skip-first-page.png b/tests/ref/issue-footnotes-skip-first-page.png index d24387e3b..fd973af71 100644 Binary files a/tests/ref/issue-footnotes-skip-first-page.png and b/tests/ref/issue-footnotes-skip-first-page.png differ diff --git a/tests/ref/issue-multiple-footnote-in-one-line.png b/tests/ref/issue-multiple-footnote-in-one-line.png deleted file mode 100644 index cdb83af20..000000000 Binary files a/tests/ref/issue-multiple-footnote-in-one-line.png and /dev/null differ diff --git a/tests/ref/place-float-block-backlog.png b/tests/ref/place-float-block-backlog.png new file mode 100644 index 000000000..c212717ec Binary files /dev/null and b/tests/ref/place-float-block-backlog.png differ diff --git a/tests/ref/place-float-clearance-empty.png b/tests/ref/place-float-clearance-empty.png new file mode 100644 index 000000000..f170df34e Binary files /dev/null and b/tests/ref/place-float-clearance-empty.png differ diff --git a/tests/ref/place-float-column-align-auto.png b/tests/ref/place-float-column-align-auto.png new file mode 100644 index 000000000..a1dc9203a Binary files /dev/null and b/tests/ref/place-float-column-align-auto.png differ diff --git a/tests/ref/place-float-in-column-queued.png b/tests/ref/place-float-column-queued.png similarity index 100% rename from tests/ref/place-float-in-column-queued.png rename to tests/ref/place-float-column-queued.png diff --git a/tests/ref/place-float-counter.png b/tests/ref/place-float-counter.png new file mode 100644 index 000000000..aa669521d Binary files /dev/null and b/tests/ref/place-float-counter.png differ diff --git a/tests/ref/place-float-delta.png b/tests/ref/place-float-delta.png new file mode 100644 index 000000000..578d93011 Binary files /dev/null and b/tests/ref/place-float-delta.png differ diff --git a/tests/ref/place-float-flow-size-alone.png b/tests/ref/place-float-flow-size-alone.png new file mode 100644 index 000000000..e1b6adac5 Binary files /dev/null and b/tests/ref/place-float-flow-size-alone.png differ diff --git a/tests/ref/place-float-flow-size.png b/tests/ref/place-float-flow-size.png new file mode 100644 index 000000000..60bbc7cd4 Binary files /dev/null and b/tests/ref/place-float-flow-size.png differ diff --git a/tests/ref/place-float-fr.png b/tests/ref/place-float-fr.png new file mode 100644 index 000000000..83d310540 Binary files /dev/null and b/tests/ref/place-float-fr.png differ diff --git a/tests/ref/place-float-in-column-align-auto.png b/tests/ref/place-float-in-column-align-auto.png deleted file mode 100644 index 58ba97415..000000000 Binary files a/tests/ref/place-float-in-column-align-auto.png and /dev/null differ diff --git a/tests/ref/place-float-rel-sizing.png b/tests/ref/place-float-rel-sizing.png new file mode 100644 index 000000000..1b4e44b2d Binary files /dev/null and b/tests/ref/place-float-rel-sizing.png differ diff --git a/tests/ref/place-float-threecolumn-block-backlog.png b/tests/ref/place-float-threecolumn-block-backlog.png new file mode 100644 index 000000000..768d4edaf Binary files /dev/null and b/tests/ref/place-float-threecolumn-block-backlog.png differ diff --git a/tests/ref/place-float-threecolumn.png b/tests/ref/place-float-threecolumn.png new file mode 100644 index 000000000..65ad88ac6 Binary files /dev/null and b/tests/ref/place-float-threecolumn.png differ diff --git a/tests/ref/place-float-twocolumn-align-auto.png b/tests/ref/place-float-twocolumn-align-auto.png new file mode 100644 index 000000000..5d9932a35 Binary files /dev/null and b/tests/ref/place-float-twocolumn-align-auto.png differ diff --git a/tests/ref/place-float-twocolumn-fits-not.png b/tests/ref/place-float-twocolumn-fits-not.png new file mode 100644 index 000000000..e533daf91 Binary files /dev/null and b/tests/ref/place-float-twocolumn-fits-not.png differ diff --git a/tests/ref/place-float-twocolumn-fits.png b/tests/ref/place-float-twocolumn-fits.png new file mode 100644 index 000000000..07e4c25ff Binary files /dev/null and b/tests/ref/place-float-twocolumn-fits.png differ diff --git a/tests/ref/place-float-twocolumn-queued.png b/tests/ref/place-float-twocolumn-queued.png new file mode 100644 index 000000000..e5fa387d6 Binary files /dev/null and b/tests/ref/place-float-twocolumn-queued.png differ diff --git a/tests/ref/place-float-twocolumn.png b/tests/ref/place-float-twocolumn.png new file mode 100644 index 000000000..3ed2f7295 Binary files /dev/null and b/tests/ref/place-float-twocolumn.png differ diff --git a/tests/ref/query-running-header.png b/tests/ref/query-running-header.png index 210c78103..1dd044181 100644 Binary files a/tests/ref/query-running-header.png and b/tests/ref/query-running-header.png differ diff --git a/tests/skip.txt b/tests/skip.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/src/collect.rs b/tests/src/collect.rs index 4dae0b70a..cc3ff7360 100644 --- a/tests/src/collect.rs +++ b/tests/src/collect.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use ecow::{eco_format, EcoString}; +use once_cell::sync::Lazy; use typst::syntax::package::PackageVersion; use typst::syntax::{is_id_continue, is_ident, is_newline, FileId, Source, VirtualPath}; use unscanny::Scanner; @@ -389,6 +390,18 @@ impl<'a> Parser<'a> { /// Whether a test is within the selected set to run. fn selected(name: &str, abs: PathBuf) -> bool { + static SKIPPED: Lazy> = Lazy::new(|| { + String::leak(std::fs::read_to_string(crate::SKIP_PATH).unwrap()) + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty() && !line.starts_with("//")) + .collect() + }); + + if SKIPPED.contains(name) { + return false; + } + let paths = &crate::ARGS.path; if !paths.is_empty() && !paths.iter().any(|path| abs.starts_with(path)) { return false; diff --git a/tests/src/tests.rs b/tests/src/tests.rs index a2d85fecc..58bd7cf7e 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -30,6 +30,9 @@ const STORE_PATH: &str = "tests/store"; /// The directory where the reference images are stored. const REF_PATH: &str = "tests/ref"; +/// The file where the skipped tests are stored. +const SKIP_PATH: &str = "tests/skip.txt"; + /// The maximum size of reference images that aren't marked as `// LARGE`. const REF_LIMIT: usize = 20 * 1024; diff --git a/tests/suite/introspection/query.typ b/tests/suite/introspection/query.typ index b078863e4..ddb518f58 100644 --- a/tests/suite/introspection/query.typ +++ b/tests/suite/introspection/query.typ @@ -25,10 +25,10 @@ #outline() = Introduction -#v(1cm) +#lines(1) = Background -#v(2cm) +#lines(2) = Approach diff --git a/tests/suite/layout/align.typ b/tests/suite/layout/align.typ index 61b799757..c4ed9ab95 100644 --- a/tests/suite/layout/align.typ +++ b/tests/suite/layout/align.typ @@ -140,3 +140,11 @@ To the right! Where the sunlight peeks behind the mountain. // Test right-aligning a line and a rectangle. #align(right, line(length: 30%)) #align(right, rect()) + +--- issue-2213-align-fr --- +// Test a mix of alignment and fr units (fr wins). +#set page(height: 80pt) +A +#v(1fr) +B +#align(bottom + right)[C] diff --git a/tests/suite/layout/columns.typ b/tests/suite/layout/columns.typ index 87a9f773a..b86b798b6 100644 --- a/tests/suite/layout/columns.typ +++ b/tests/suite/layout/columns.typ @@ -122,3 +122,10 @@ Hallo = B Text ] + +--- colbreak-weak --- +#set page(columns: 2) +#colbreak(weak: true) +A +#colbreak(weak: true) +B diff --git a/tests/suite/layout/container.typ b/tests/suite/layout/container.typ index 9ce3dc7d9..508f1a368 100644 --- a/tests/suite/layout/container.typ +++ b/tests/suite/layout/container.typ @@ -18,10 +18,41 @@ Apart #block(width: 50%, height: 60%, fill: blue) ] ---- box-width-fr --- +--- box-fr-width --- // Test fr box. Hello #box(width: 1fr, rect(height: 0.7em, width: 100%)) World +--- block-fr-height --- +#set page(height: 100pt) +#rect(height: 10pt, width: 100%) +#align(center, block(height: 1fr, width: 20pt, stroke: 1pt)) +#rect(height: 10pt, width: 100%) + +--- block-fr-height-auto-width --- +// Test that the fr block can also expand its parent. +#set page(height: 100pt) +#set align(center) +#block(inset: 5pt, stroke: green)[ + #rect(height: 10pt) + #block(height: 1fr, stroke: 1pt, inset: 5pt)[ + #set align(center + horizon) + I am the widest + ] + #rect(height: 10pt) +] + +--- block-fr-height-first-child --- +// Test that block spacing is not trimmed if only an fr block precedes it. +#set page(height: 100pt) +#rect(height: 1fr) +#rect() + +--- block-fr-height-multiple --- +#set page(height: 100pt) +#rect(height: 1fr) +#rect() +#block(height: 1fr, line(length: 100%, angle: 90deg)) + --- block-multiple-pages --- // Test block over multiple pages. #set page(height: 60pt) @@ -121,6 +152,34 @@ Paragraph #show bibliography: none #bibliography("/assets/bib/works.bib") +--- block-sticky --- +#set page(height: 100pt) +#lines(3) +#block(sticky: true)[D] +#block(sticky: true)[E] +F + +--- block-sticky-alone --- +#set page(height: 50pt) +#block(sticky: true)[A] + +--- block-sticky-many --- +#set page(height: 80pt) +#set block(sticky: true) +#block[A] +#block[B] +#block[C] +#block[D] +E +#block[F] +#block[G] + +--- block-sticky-colbreak --- +A +#block(sticky: true)[B] +#colbreak() +C + --- box-clip-rect --- // Test box clipping with a rectangle Hello #box(width: 1em, height: 1em, clip: false)[#rect(width: 3em, height: 3em, fill: red)] diff --git a/tests/suite/layout/flow/flow.typ b/tests/suite/layout/flow/flow.typ index 88075c5b9..fcbc005b6 100644 --- a/tests/suite/layout/flow/flow.typ +++ b/tests/suite/layout/flow/flow.typ @@ -55,9 +55,6 @@ --- issue-3641-float-loop --- // Flow layout should terminate! -// -// This is not yet ideal: The heading should not move to the second page, but -// that's a separate bug and not a regression. #set page(height: 40pt) = Heading @@ -69,3 +66,13 @@ #metadata(none) #v(10pt, weak: true) Hi + +--- issue-3866-block-migration --- +#set page(height: 120pt) +#set text(costs: (widow: 0%, orphan: 0%)) +#v(50pt) +#columns(2)[ + #lines(6) + #block(rect(width: 80%, height: 80pt), breakable: false) + #lines(6) +] diff --git a/tests/suite/model/footnote.typ b/tests/suite/layout/flow/footnote.typ similarity index 74% rename from tests/suite/model/footnote.typ rename to tests/suite/layout/flow/footnote.typ index 410912288..f7722e156 100644 --- a/tests/suite/model/footnote.typ +++ b/tests/suite/layout/flow/footnote.typ @@ -9,16 +9,12 @@ A#footnote[A] \ A #footnote[A] --- footnote-nested --- -// Test nested footnotes. -First \ -Second #footnote[A, #footnote[B, #footnote[C]]] \ -Third #footnote[D, #footnote[E]] \ -Fourth - ---- footnote-nested-same-frame --- // Currently, numbers a bit out of order if a nested footnote ends up in the // same frame as another one. :( -#footnote[A, #footnote[B]], #footnote[C] +First \ +Second #footnote[A, #footnote[B, #footnote[C]]] +Third #footnote[D, #footnote[E]] \ +Fourth #footnote[F] --- footnote-entry --- // Test customization. @@ -48,18 +44,94 @@ Beautiful footnotes. #footnote[Wonderful, aren't they?] #lines(6) #footnote[V] // 5 ---- footnote-in-columns --- -// Test footnotes in columns, even those that are not enabled via `set page`. -#set page(height: 120pt) -#align(center, strong[Title]) +--- footnote-break-across-pages-block --- +#set page(height: 100pt) +#block[ + #lines(3) #footnote(lines(6, "1")) + #footnote[Y] + #footnote[Z] +] + +--- footnote-break-across-pages-float --- +#set page(height: 180pt) + +#lines(5) + +#place( + bottom, + float: true, + rect(height: 50pt, width: 100%, { + footnote(lines(6, "1")) + footnote(lines(2, "I")) + }) +) + +#lines(5) + +--- footnote-break-across-pages-nested --- +#set page(height: 120pt) +#block[ + #lines(4) + #footnote[ + #lines(6, "1") + #footnote(lines(3, "I")) + ] +] + +--- footnote-in-columns --- +#set page(height: 120pt, columns: 2) + +#place( + top + center, + float: true, + scope: "page", + clearance: 12pt, + strong[Title], +) -#show: columns.with(2) #lines(3) #footnote(lines(4, "1")) #lines(2) #footnote(lines(2, "1")) +--- footnote-in-list --- +#set page(height: 120pt) + +- A #footnote[a] +- B #footnote[b] +- C #footnote[c] +- D #footnote[d] +- E #footnote[e] +- F #footnote[f] +- G #footnote[g] + +--- footnote-block-at-end --- +#set page(height: 50pt) +A +#block(footnote[hello]) + +--- footnote-float-priority --- +#set page(height: 100pt) + +#lines(3) + +#place( + top, + float: true, + rect(height: 40pt) +) + +#block[ + V + #footnote[1] + #footnote[2] + #footnote[3] + #footnote[4] +] + +#lines(5) + --- footnote-in-caption --- // Test footnote in caption. Read the docs #footnote[https://typst.app/docs]! @@ -71,6 +143,15 @@ Read the docs #footnote[https://typst.app/docs]! ) More #footnote[just for ...] footnotes #footnote[... testing. :)] +--- footnote-in-place --- +A +#place(top + right, footnote[A]) +#figure( + placement: bottom, + caption: footnote[B], + rect(), +) + --- footnote-duplicate --- // Test duplicate footnotes. #let lang = footnote[Languages.] @@ -105,6 +186,10 @@ A #footnote(lines(6, "1")) A footnote #footnote[Hi] \ A reference to it @fn +--- footnote-self-ref --- +// Error: 2-16 footnote cannot reference itself +#footnote() + --- footnote-ref-multiple --- // Multiple footnotes are refs First #footnote[A] \ @@ -163,10 +248,7 @@ Ref @fn .map(v => upper(v) + footnote(v)) ) ---- issue-multiple-footnote-in-one-line --- -// Test that the logic that keeps footnote entry together with -// their markers also works for multiple footnotes in a single -// line. +--- footnote-multiple-in-one-line --- #set page(height: 100pt) #v(50pt) A #footnote[a] diff --git a/tests/suite/layout/flow/place-float.typ b/tests/suite/layout/flow/place-float.typ deleted file mode 100644 index 50a8a1129..000000000 --- a/tests/suite/layout/flow/place-float.typ +++ /dev/null @@ -1,83 +0,0 @@ ---- place-float-flow-around --- -#set page(height: 80pt) -#set place(float: true) -#place(bottom + center, rect(height: 20pt)) -#lines(4) - ---- place-float-queued --- -#set page(height: 180pt) -#set figure(placement: auto) - -#figure(rect(height: 60pt), caption: [I]) -#figure(rect(height: 40pt), caption: [II]) -#figure(rect(), caption: [III]) -#figure(rect(), caption: [IV]) -A - ---- place-float-align-auto --- -#set page(height: 140pt) -#set place(clearance: 5pt) -#set place(auto, float: true) - -#place(rect[A]) -#place(rect[B]) -1 \ 2 -#place(rect[C]) -#place(rect[D]) - ---- place-float-in-column-align-auto --- -#set page(height: 150pt, columns: 2) -#set place(auto, float: true, clearance: 10pt) -#set rect(width: 75%) - -#place(rect[I]) -#place(rect[II]) -#place(rect[III]) -#place(rect[IV]) - -#lines(6) - -#place(rect[V]) - ---- place-float-in-column-queued --- -#set page(height: 100pt, columns: 2) -#set place(float: true, clearance: 10pt) -#set rect(width: 75%) -#set text(costs: (widow: 0%, orphan: 0%)) - -#lines(3) - -#place(top, rect[I]) -#place(top, rect[II]) -#place(bottom, rect[III]) - -#lines(3) - ---- place-float-missing --- -// Error: 2-20 automatic positioning is only available for floating placement -// Hint: 2-20 you can enable floating placement with `place(float: true, ..)` -#place(auto)[Hello] - ---- place-float-center-horizon --- -// Error: 2-45 floating placement must be `auto`, `top`, or `bottom` -#place(center + horizon, float: true)[Hello] - ---- place-float-horizon --- -// Error: 2-36 floating placement must be `auto`, `top`, or `bottom` -#place(horizon, float: true)[Hello] - ---- place-float-default --- -// Error: 2-27 floating placement must be `auto`, `top`, or `bottom` -#place(float: true)[Hello] - ---- place-float-right --- -// Error: 2-34 floating placement must be `auto`, `top`, or `bottom` -#place(right, float: true)[Hello] - ---- issue-2595-float-overlap --- -#set page(height: 80pt) - -1 -#place(auto, float: true, block(height: 100%, width: 100%, fill: aqua)) -#place(auto, float: true, block(height: 100%, width: 100%, fill: red)) -#lines(7) diff --git a/tests/suite/layout/flow/place-flush.typ b/tests/suite/layout/flow/place-flush.typ deleted file mode 100644 index 8f55a6fd4..000000000 --- a/tests/suite/layout/flow/place-flush.typ +++ /dev/null @@ -1,29 +0,0 @@ ---- place-flush --- -#set page(height: 120pt) -#let floater(align, height) = place( - align, - float: true, - rect(width: 100%, height: height), -) - -#floater(top, 30pt) -A - -#floater(bottom, 50pt) -#place.flush() -B // Should be on the second page. - ---- place-flush-figure --- -#set page(height: 120pt) -#let floater(align, height, caption) = figure( - placement: align, - caption: caption, - rect(width: 100%, height: height), -) - -#floater(top, 30pt)[I] -A - -#floater(bottom, 50pt)[II] -#place.flush() -B // Should be on the second page. diff --git a/tests/suite/layout/flow/place.typ b/tests/suite/layout/flow/place.typ index f3231735b..dc655ec59 100644 --- a/tests/suite/layout/flow/place.typ +++ b/tests/suite/layout/flow/place.typ @@ -70,6 +70,288 @@ Second #line(length: 50pt) ] +--- place-float-flow-around --- +#set page(height: 80pt) +#set place(float: true) +#place(bottom + center, rect(height: 20pt)) +#lines(4) + +--- place-float-queued --- +#set page(height: 180pt) +#set figure(placement: auto) + +#figure(rect(height: 60pt), caption: [I]) +#figure(rect(height: 40pt), caption: [II]) +#figure(rect(), caption: [III]) +A +#figure(rect(), caption: [IV]) + +--- place-float-align-auto --- +#set page(height: 140pt) +#set place(auto, float: true, clearance: 5pt) + +#place(rect[A]) +#place(rect[B]) +1 \ 2 +#place(rect[C]) +#place(rect[D]) + +--- place-float-delta --- +#place(top + center, float: true, dx: 10pt, rect[I]) +A +#place(bottom + center, float: true, dx: -10pt, rect[II]) + +--- place-float-flow-size --- +#set page(width: auto, height: auto) +#set place(float: true, clearance: 5pt) + +#place(bottom, rect(width: 80pt, height: 10pt)) +#place(top + center, rect(height: 20pt)) +#align(center)[A] +#pagebreak() +#align(center)[B] +#place(bottom, scope: "page", rect(height: 10pt)) + +--- place-float-flow-size-alone --- +#set page(width: auto, height: auto) +#set place(float: true, clearance: 5pt) +#place(auto)[A] + +--- place-float-fr --- +#set page(height: 120pt, columns: 2) +#set place(float: true, clearance: 10pt) +#set rect(width: 70%) + +#place(top + center, rect[I]) +#place(bottom + center, scope: "page", rect[II]) + +A +#v(1fr) +B +#colbreak() +C +#align(bottom)[D] + +--- place-float-rel-sizing --- +#set page(height: 100pt, columns: 2) +#set place(float: true, clearance: 10pt) +#set rect(width: 70%) + +#place(top + center, scope: "page", rect[I]) +#place(top + center, rect[II]) + +// This test result is not ideal: The first column takes 30% of the full page, +// while the second takes 30% of the remaining space since there is no concept +// of `full` for followup pages. +#set align(bottom) +#rect(width: 100%, height: 30%) +#rect(width: 100%, height: 30%) + +--- place-float-block-backlog --- +#set page(height: 100pt) +#v(60pt) +#place(top, float: true, rect()) +#list(.."ABCDEFGHIJ".clusters()) + +--- place-float-clearance-empty --- +// Check that we don't require space for clearance if there is no content. +#set page(height: 100pt) +#v(1fr) +#table( + columns: (1fr, 1fr), + lines(2), + [], + lines(8), + place(auto, float: true, block(width: 100%, height: 100%, fill: aqua)) +) + + +--- place-float-column-align-auto --- +#set page(height: 150pt, columns: 2) +#set place(auto, float: true, clearance: 10pt) +#set rect(width: 75%) + +#place(rect[I]) +#place(rect[II]) +#place(rect[III]) +#place(rect[IV]) + +#lines(6) + +#place(rect[V]) +#place(rect[VI]) + +--- place-float-column-queued --- +#set page(height: 100pt, columns: 2) +#set place(float: true, clearance: 10pt) +#set rect(width: 75%) +#set text(costs: (widow: 0%, orphan: 0%)) + +#lines(3) + +#place(top, rect[I]) +#place(top, rect[II]) +#place(bottom, rect[III]) + +#lines(3) + +--- place-float-twocolumn --- +#set page(height: 100pt, columns: 2) +#set place(float: true, clearance: 10pt) +#set rect(width: 70%) + +#place(top + center, scope: "page", rect[I]) +#place(top + center, rect[II]) +#lines(4) +#place(top + center, rect[III]) +#block(width: 100%, height: 70pt, fill: conifer) +#place(bottom + center, scope: "page", rect[IV]) +#place(bottom + center, rect[V]) +#v(1pt, weak: true) +#block(width: 100%, height: 60pt, fill: aqua) + +--- place-float-twocolumn-queued --- +#set page(height: 100pt, columns: 2) +#set place(float: true, scope: "page", clearance: 10pt) +#let t(align, fill) = place(top + align, rect(fill: fill, height: 25pt)) + +#t(left, aqua) +#t(center, forest) +#t(right, conifer) +#lines(7) + +--- place-float-twocolumn-align-auto --- +#set page(height: 100pt, columns: 2) +#set place(float: true, clearance: 10pt) +#set rect(width: 70%) + +#place(auto, scope: "page", rect[I]) // Should end up `top` +#lines(4) +#place(auto, scope: "page", rect[II]) // Should end up `bottom` +#lines(4) + +--- place-float-twocolumn-fits --- +#set page(height: 100pt, columns: 2) +#set place(float: true, clearance: 10pt) +#set rect(width: 70%) + +#lines(6) +#place(auto, scope: "page", rect[I]) +#lines(12, "1") + +--- place-float-twocolumn-fits-not --- +#set page(height: 100pt, columns: 2) +#set place(float: true, clearance: 10pt) +#set rect(width: 70%) + +#lines(10) +#place(auto, scope: "page", rect[I]) +#lines(10, "1") + +--- place-float-threecolumn --- +#set page(height: 100pt, columns: 3) +#set place(float: true, clearance: 10pt) +#set rect(width: 70%) + +#place(bottom + center, scope: "page", rect[I]) +#lines(21) +#place(top + center, scope: "page", rect[II]) + +--- place-float-threecolumn-block-backlog --- +#set page(height: 100pt, columns: 3) +#set place(float: true, clearance: 10pt) +#set rect(width: 70%) + +// The most important part of this test is that we get the backlog of the +// conifer (green) block right. +#place(top + center, scope: "page", rect[I]) +#block(fill: aqua, width: 100%, height: 70pt) +#block(fill: conifer, width: 100%, height: 160pt) +#place(bottom + center, scope: "page", rect[II]) +#place(top, rect(height: 40%)[III]) +#block(fill: yellow, width: 100%, height: 60pt) + +--- place-float-counter --- +#let c = counter("c") +#let cd = context c.display() + +#set page( + height: 100pt, + margin: (y: 20pt), + header: [H: #cd], + footer: [F: #cd], + columns: 2, +) + +#let t(align, scope: "column", n) = place( + align, + float: true, + scope: scope, + clearance: 10pt, + line(length: 100%) + c.update(n), +) + +#t(bottom, 6) +#cd +#t(top, 3) +#colbreak() +#cd +#t(scope: "page", bottom, 11) +#colbreak() +#cd +#t(top, 12) + +--- place-float-missing --- +// Error: 2-20 automatic positioning is only available for floating placement +// Hint: 2-20 you can enable floating placement with `place(float: true, ..)` +#place(auto)[Hello] + +--- place-float-center-horizon --- +// Error: 2-45 vertical floating placement must be `auto`, `top`, or `bottom` +#place(center + horizon, float: true)[Hello] + +--- place-float-horizon --- +// Error: 2-36 vertical floating placement must be `auto`, `top`, or `bottom` +#place(horizon, float: true)[Hello] + +--- place-float-default --- +// Error: 2-27 vertical floating placement must be `auto`, `top`, or `bottom` +#place(float: true)[Hello] + +--- place-float-right --- +// Error: 2-34 vertical floating placement must be `auto`, `top`, or `bottom` +#place(right, float: true)[Hello] + +--- place-flush --- +#set page(height: 120pt) +#let floater(align, height) = place( + align, + float: true, + rect(width: 100%, height: height), +) + +#floater(top, 30pt) +A + +#floater(bottom, 50pt) +#place.flush() +B // Should be on the second page. + +--- place-flush-figure --- +#set page(height: 120pt) +#let floater(align, height, caption) = figure( + placement: align, + caption: caption, + rect(width: 100%, height: height), +) + +#floater(top, 30pt)[I] +A + +#floater(bottom, 50pt)[II] +#place.flush() +B // Should be on the second page. + --- issue-place-base --- // Test that placement is relative to container and not itself. #set page(height: 80pt, margin: 0pt) @@ -98,3 +380,11 @@ Paragraph after float. Paragraph before place. #place(rect()) Paragraph after place. + +--- issue-2595-float-overlap --- +#set page(height: 80pt) + +1 +#place(auto, float: true, block(height: 100%, width: 100%, fill: aqua)) +#place(auto, float: true, block(height: 100%, width: 100%, fill: red)) +#lines(7) diff --git a/tests/suite/model/cite.typ b/tests/suite/model/cite.typ index ffbd3b52f..f69fe9f41 100644 --- a/tests/suite/model/cite.typ +++ b/tests/suite/model/cite.typ @@ -102,8 +102,7 @@ B #cite() #cite(). // Everything moves to the second page because we want to keep the line and // its footnotes together. -#footnote[@netwok] -#footnote[A] +#footnote[@netwok \ A] #show bibliography: none #bibliography("/assets/bib/works.bib") diff --git a/tests/suite/model/figure.typ b/tests/suite/model/figure.typ index 13e944811..d71d92e3e 100644 --- a/tests/suite/model/figure.typ +++ b/tests/suite/model/figure.typ @@ -41,6 +41,42 @@ We can clearly see that @fig-cylinder and caption: "A table containing images." ) +--- figure-placement --- +#set page(height: 160pt, columns: 2) +#set place(clearance: 10pt) + +#lines(4) + +#figure( + placement: auto, + scope: "page", + caption: [I], + rect(height: 15pt, width: 80%), +) + +#figure( + placement: bottom, + caption: [II], + rect(height: 15pt, width: 80%), +) + +#lines(2) + +#figure( + placement: bottom, + caption: [III], + rect(height: 25pt, width: 80%), +) + +#figure( + placement: auto, + scope: "page", + caption: [IV], + rect(width: 80%), +) + +#lines(15) + --- figure-theorem --- // Testing show rules with figures with a simple theorem display #show figure.where(kind: "theorem"): it => {