diff options
| author | Laurenz <laurmaedje@gmail.com> | 2024-10-27 19:04:55 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-10-27 18:04:55 +0000 |
| commit | be7cfc85d08c545abfac08098b7b33b4bd71f37e (patch) | |
| tree | f4137fa2aaa57babae1f7603a9b2ed7e688f43d8 /crates/typst-layout | |
| parent | b8034a343831e8609aec2ec81eb7eeda57aa5d81 (diff) | |
Split out four new crates (#5302)
Diffstat (limited to 'crates/typst-layout')
47 files changed, 20291 insertions, 0 deletions
diff --git a/crates/typst-layout/Cargo.toml b/crates/typst-layout/Cargo.toml new file mode 100644 index 00000000..4c133a4e --- /dev/null +++ b/crates/typst-layout/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "typst-layout" +description = "Typst's layout engine." +version = { workspace = true } +rust-version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +categories = { workspace = true } +keywords = { workspace = true } +readme = { workspace = true } + +[dependencies] +typst-assets = { workspace = true } +typst-library = { workspace = true } +typst-macros = { workspace = true } +typst-syntax = { workspace = true } +typst-timing = { workspace = true } +typst-utils = { workspace = true } +az = { workspace = true } +bumpalo = { workspace = true } +comemo = { workspace = true } +ecow = { workspace = true } +hypher = { workspace = true } +icu_properties = { workspace = true } +icu_provider = { workspace = true } +icu_provider_adapters = { workspace = true } +icu_provider_blob = { workspace = true } +icu_segmenter = { workspace = true } +kurbo = { workspace = true } +once_cell = { workspace = true } +rustybuzz = { workspace = true } +smallvec = { workspace = true } +ttf-parser = { workspace = true } +unicode-bidi = { workspace = true } +unicode-math-class = { workspace = true } +unicode-script = { workspace = true } +unicode-segmentation = { workspace = true } + +[lints] +workspace = true diff --git a/crates/typst-layout/src/flow/block.rs b/crates/typst-layout/src/flow/block.rs new file mode 100644 index 00000000..1dd98812 --- /dev/null +++ b/crates/typst-layout/src/flow/block.rs @@ -0,0 +1,401 @@ +use once_cell::unsync::Lazy; +use smallvec::SmallVec; +use typst_library::diag::SourceResult; +use typst_library::engine::Engine; +use typst_library::foundations::{Packed, Resolve, StyleChain}; +use typst_library::introspection::Locator; +use typst_library::layout::{ + Abs, Axes, BlockBody, BlockElem, Fragment, Frame, FrameKind, Region, Regions, Rel, + Sides, Size, Sizing, +}; +use typst_library::visualize::Stroke; +use typst_utils::Numeric; + +use crate::shapes::{clip_rect, fill_and_stroke}; + +/// Lay this out as an unbreakable block. +#[typst_macros::time(name = "block", span = elem.span())] +pub fn layout_single_block( + elem: &Packed<BlockElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + // Fetch sizing properties. + let width = elem.width(styles); + let height = elem.height(styles); + let inset = elem.inset(styles).unwrap_or_default(); + + // Build the pod regions. + let pod = unbreakable_pod(&width.into(), &height, &inset, styles, region.size); + + // Layout the body. + let body = elem.body(styles); + let mut frame = match body { + // If we have no body, just create one frame. Its size will be + // adjusted below. + None => Frame::hard(Size::zero()), + + // If we have content as our body, just layout it. + Some(BlockBody::Content(body)) => { + crate::layout_frame(engine, body, locator.relayout(), styles, pod)? + } + + // If we have a child that wants to layout with just access to the + // base region, give it that. + Some(BlockBody::SingleLayouter(callback)) => { + callback.call(engine, locator, styles, pod)? + } + + // If we have a child that wants to layout with full region access, + // we layout it. + Some(BlockBody::MultiLayouter(callback)) => { + let expand = (pod.expand | region.expand) & pod.size.map(Abs::is_finite); + let pod = Region { expand, ..pod }; + callback.call(engine, locator, styles, pod.into())?.into_frame() + } + }; + + // Explicit blocks are boundaries for gradient relativeness. + if matches!(body, None | Some(BlockBody::Content(_))) { + frame.set_kind(FrameKind::Hard); + } + + // Enforce a correct frame size on the expanded axes. Do this before + // applying the inset, since the pod shrunk. + frame.set_size(pod.expand.select(pod.size, frame.size())); + + // Apply the inset. + if !inset.is_zero() { + crate::pad::grow(&mut frame, &inset); + } + + // Prepare fill and stroke. + let fill = elem.fill(styles); + let stroke = elem + .stroke(styles) + .unwrap_or_default() + .map(|s| s.map(Stroke::unwrap_or_default)); + + // Only fetch these if necessary (for clipping or filling/stroking). + let outset = Lazy::new(|| elem.outset(styles).unwrap_or_default()); + let radius = Lazy::new(|| elem.radius(styles).unwrap_or_default()); + + // Clip the contents, if requested. + if elem.clip(styles) { + let size = frame.size() + outset.relative_to(frame.size()).sum_by_axis(); + frame.clip(clip_rect(size, &radius, &stroke)); + } + + // Add fill and/or stroke. + if fill.is_some() || stroke.iter().any(Option::is_some) { + fill_and_stroke(&mut frame, fill, &stroke, &outset, &radius, elem.span()); + } + + // Assign label to each frame in the fragment. + if let Some(label) = elem.label() { + frame.label(label); + } + + Ok(frame) +} + +/// Lay this out as a breakable block. +#[typst_macros::time(name = "block", span = elem.span())] +pub fn layout_multi_block( + elem: &Packed<BlockElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + regions: Regions, +) -> SourceResult<Fragment> { + // Fetch sizing properties. + let width = elem.width(styles); + let height = elem.height(styles); + let inset = elem.inset(styles).unwrap_or_default(); + + // Allocate a small vector for backlogs. + let mut buf = SmallVec::<[Abs; 2]>::new(); + + // Build the pod regions. + let pod = breakable_pod(&width.into(), &height, &inset, styles, regions, &mut buf); + + // Layout the body. + let body = elem.body(styles); + let mut fragment = match body { + // If we have no body, just create one frame plus one per backlog + // region. We create them zero-sized; if necessary, their size will + // be adjusted below. + None => { + let mut frames = vec![]; + frames.push(Frame::hard(Size::zero())); + if pod.expand.y { + let mut iter = pod; + while !iter.backlog.is_empty() { + frames.push(Frame::hard(Size::zero())); + iter.next(); + } + } + Fragment::frames(frames) + } + + // If we have content as our body, just layout it. + Some(BlockBody::Content(body)) => { + let mut fragment = + crate::layout_fragment(engine, body, locator.relayout(), styles, pod)?; + + // If the body is automatically sized and produced more than one + // fragment, ensure that the width was consistent across all + // regions. If it wasn't, we need to relayout with expansion. + if !pod.expand.x + && fragment + .as_slice() + .windows(2) + .any(|w| !w[0].width().approx_eq(w[1].width())) + { + let max_width = + fragment.iter().map(|frame| frame.width()).max().unwrap_or_default(); + let pod = Regions { + size: Size::new(max_width, pod.size.y), + expand: Axes::new(true, pod.expand.y), + ..pod + }; + fragment = crate::layout_fragment(engine, body, locator, styles, pod)?; + } + + fragment + } + + // If we have a child that wants to layout with just access to the + // base region, give it that. + Some(BlockBody::SingleLayouter(callback)) => { + let pod = Region::new(pod.base(), pod.expand); + callback.call(engine, locator, styles, pod).map(Fragment::frame)? + } + + // If we have a child that wants to layout with full region access, + // we layout it. + // + // For auto-sized multi-layouters, we propagate the outer expansion + // so that they can decide for themselves. We also ensure again to + // only expand if the size is finite. + Some(BlockBody::MultiLayouter(callback)) => { + let expand = (pod.expand | regions.expand) & pod.size.map(Abs::is_finite); + let pod = Regions { expand, ..pod }; + callback.call(engine, locator, styles, pod)? + } + }; + + // Prepare fill and stroke. + let fill = elem.fill(styles); + let stroke = elem + .stroke(styles) + .unwrap_or_default() + .map(|s| s.map(Stroke::unwrap_or_default)); + + // Only fetch these if necessary (for clipping or filling/stroking). + let outset = Lazy::new(|| elem.outset(styles).unwrap_or_default()); + let radius = Lazy::new(|| elem.radius(styles).unwrap_or_default()); + + // Fetch/compute these outside of the loop. + let clip = elem.clip(styles); + let has_fill_or_stroke = fill.is_some() || stroke.iter().any(Option::is_some); + let has_inset = !inset.is_zero(); + let is_explicit = matches!(body, None | Some(BlockBody::Content(_))); + + // Skip filling/stroking the first frame if it is empty and a non-empty + // one follows. + let mut skip_first = false; + if let [first, rest @ ..] = fragment.as_slice() { + skip_first = has_fill_or_stroke + && first.is_empty() + && rest.iter().any(|frame| !frame.is_empty()); + } + + // Post-process to apply insets, clipping, fills, and strokes. + for (i, (frame, region)) in fragment.iter_mut().zip(pod.iter()).enumerate() { + // Explicit blocks are boundaries for gradient relativeness. + if is_explicit { + frame.set_kind(FrameKind::Hard); + } + + // Enforce a correct frame size on the expanded axes. Do this before + // applying the inset, since the pod shrunk. + frame.set_size(pod.expand.select(region, frame.size())); + + // Apply the inset. + if has_inset { + crate::pad::grow(frame, &inset); + } + + // Clip the contents, if requested. + if clip { + let size = frame.size() + outset.relative_to(frame.size()).sum_by_axis(); + frame.clip(clip_rect(size, &radius, &stroke)); + } + + // Add fill and/or stroke. + if has_fill_or_stroke && (i > 0 || !skip_first) { + fill_and_stroke(frame, fill.clone(), &stroke, &outset, &radius, elem.span()); + } + } + + // Assign label to each frame in the fragment. + if let Some(label) = elem.label() { + for frame in fragment.iter_mut() { + frame.label(label); + } + } + + Ok(fragment) +} + +/// Builds the pod region for an unbreakable sized container. +pub(crate) fn unbreakable_pod( + width: &Sizing, + height: &Sizing, + inset: &Sides<Rel<Abs>>, + styles: StyleChain, + base: Size, +) -> Region { + // Resolve the size. + let mut size = Size::new( + match width { + // - For auto, the whole region is available. + // - Fr is handled outside and already factored into the `region`, + // so we can treat it equivalently to 100%. + Sizing::Auto | Sizing::Fr(_) => base.x, + // Resolve the relative sizing. + Sizing::Rel(rel) => rel.resolve(styles).relative_to(base.x), + }, + match height { + Sizing::Auto | Sizing::Fr(_) => base.y, + Sizing::Rel(rel) => rel.resolve(styles).relative_to(base.y), + }, + ); + + // Take the inset, if any, into account. + if !inset.is_zero() { + size = crate::pad::shrink(size, inset); + } + + // If the child is manually, the size is forced and we should enable + // expansion. + let expand = Axes::new( + *width != Sizing::Auto && size.x.is_finite(), + *height != Sizing::Auto && size.y.is_finite(), + ); + + Region::new(size, expand) +} + +/// Builds the pod regions for a breakable sized container. +fn breakable_pod<'a>( + width: &Sizing, + height: &Sizing, + inset: &Sides<Rel<Abs>>, + styles: StyleChain, + regions: Regions, + buf: &'a mut SmallVec<[Abs; 2]>, +) -> Regions<'a> { + let base = regions.base(); + + // The vertical region sizes we're about to build. + let first; + let full; + let backlog: &mut [Abs]; + let last; + + // If the block has a fixed height, things are very different, so we + // handle that case completely separately. + match height { + Sizing::Auto | Sizing::Fr(_) => { + // If the block is automatically sized, we can just inherit the + // regions. + first = regions.size.y; + full = regions.full; + buf.extend_from_slice(regions.backlog); + backlog = buf; + last = regions.last; + } + + Sizing::Rel(rel) => { + // Resolve the sizing to a concrete size. + let resolved = rel.resolve(styles).relative_to(base.y); + + // Since we're manually sized, the resolved size is the base height. + full = resolved; + + // Distribute the fixed height across a start region and a backlog. + (first, backlog) = distribute(resolved, regions, buf); + + // If the height is manually sized, we don't want a final repeatable + // region. + last = None; + } + }; + + // Resolve the horizontal sizing to a concrete width and combine + // `width` and `first` into `size`. + let mut size = Size::new( + match width { + Sizing::Auto | Sizing::Fr(_) => regions.size.x, + Sizing::Rel(rel) => rel.resolve(styles).relative_to(base.x), + }, + first, + ); + + // Take the inset, if any, into account, applying it to the + // individual region components. + let (mut full, mut last) = (full, last); + if !inset.is_zero() { + crate::pad::shrink_multiple(&mut size, &mut full, backlog, &mut last, inset); + } + + // If the child is manually, the size is forced and we should enable + // expansion. + let expand = Axes::new( + *width != Sizing::Auto && size.x.is_finite(), + *height != Sizing::Auto && size.y.is_finite(), + ); + + Regions { size, full, backlog, last, expand } +} + +/// Distribute a fixed height spread over existing regions into a new first +/// height and a new backlog. +fn distribute<'a>( + height: Abs, + mut regions: Regions, + buf: &'a mut SmallVec<[Abs; 2]>, +) -> (Abs, &'a mut [Abs]) { + // Build new region heights from old regions. + let mut remaining = height; + loop { + let limited = regions.size.y.clamp(Abs::zero(), remaining); + buf.push(limited); + remaining -= limited; + if remaining.approx_empty() + || !regions.may_break() + || (!regions.may_progress() && limited.approx_empty()) + { + break; + } + regions.next(); + } + + // If there is still something remaining, apply it to the + // last region (it will overflow, but there's nothing else + // we can do). + if !remaining.approx_empty() { + if let Some(last) = buf.last_mut() { + *last += remaining; + } + } + + // Distribute the heights to the first region and the + // backlog. There is no last region, since the height is + // fixed. + (buf[0], &mut buf[1..]) +} diff --git a/crates/typst-layout/src/flow/collect.rs b/crates/typst-layout/src/flow/collect.rs new file mode 100644 index 00000000..aee5d508 --- /dev/null +++ b/crates/typst-layout/src/flow/collect.rs @@ -0,0 +1,648 @@ +use std::cell::RefCell; +use std::fmt::{self, Debug, Formatter}; +use std::hash::Hash; + +use bumpalo::boxed::Box as BumpBox; +use bumpalo::Bump; +use comemo::{Track, Tracked, TrackedMut}; +use once_cell::unsync::Lazy; +use typst_library::diag::{bail, SourceResult}; +use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::foundations::{Packed, Resolve, Smart, StyleChain}; +use typst_library::introspection::{ + Introspector, Location, Locator, LocatorLink, SplitLocator, Tag, TagElem, +}; +use typst_library::layout::{ + Abs, AlignElem, Alignment, Axes, BlockElem, ColbreakElem, FixedAlignment, FlushElem, + Fr, Fragment, Frame, PagebreakElem, PlaceElem, PlacementScope, Ratio, Region, + Regions, Rel, Size, Sizing, Spacing, VElem, +}; +use typst_library::model::ParElem; +use typst_library::routines::{Pair, Routines}; +use typst_library::text::TextElem; +use typst_library::World; + +use super::{layout_multi_block, layout_single_block}; + +/// Collects all elements of the flow into prepared children. These are much +/// simpler to handle than the raw elements. +#[typst_macros::time] +pub fn collect<'a>( + engine: &mut Engine, + bump: &'a Bump, + children: &[Pair<'a>], + locator: Locator<'a>, + base: Size, + expand: bool, +) -> SourceResult<Vec<Child<'a>>> { + Collector { + engine, + bump, + children, + locator: locator.split(), + base, + expand, + output: Vec::with_capacity(children.len()), + last_was_par: false, + } + .run() +} + +/// State for collection. +struct Collector<'a, 'x, 'y> { + engine: &'x mut Engine<'y>, + bump: &'a Bump, + children: &'x [Pair<'a>], + base: Size, + expand: bool, + locator: SplitLocator<'a>, + output: Vec<Child<'a>>, + last_was_par: bool, +} + +impl<'a> Collector<'a, '_, '_> { + /// Perform the collection. + fn run(mut self) -> SourceResult<Vec<Child<'a>>> { + for &(child, styles) in self.children { + if let Some(elem) = child.to_packed::<TagElem>() { + self.output.push(Child::Tag(&elem.tag)); + } else if let Some(elem) = child.to_packed::<VElem>() { + self.v(elem, styles); + } else if let Some(elem) = child.to_packed::<ParElem>() { + self.par(elem, styles)?; + } else if let Some(elem) = child.to_packed::<BlockElem>() { + self.block(elem, styles); + } else if let Some(elem) = child.to_packed::<PlaceElem>() { + self.place(elem, styles)?; + } else if child.is::<FlushElem>() { + self.output.push(Child::Flush); + } else if let Some(elem) = child.to_packed::<ColbreakElem>() { + self.output.push(Child::Break(elem.weak(styles))); + } else if child.is::<PagebreakElem>() { + bail!( + child.span(), "pagebreaks are not allowed inside of containers"; + hint: "try using a `#colbreak()` instead", + ); + } else { + bail!(child.span(), "{} is not allowed here", child.func().name()); + } + } + + Ok(self.output) + } + + /// Collect vertical spacing into a relative or fractional child. + fn v(&mut self, elem: &'a Packed<VElem>, styles: StyleChain<'a>) { + self.output.push(match elem.amount { + Spacing::Rel(rel) => Child::Rel(rel.resolve(styles), elem.weak(styles) as u8), + Spacing::Fr(fr) => Child::Fr(fr), + }); + } + + /// Collect a paragraph into [`LineChild`]ren. This already performs line + /// layout since it is not dependent on the concrete regions. + fn par( + &mut self, + elem: &'a Packed<ParElem>, + styles: StyleChain<'a>, + ) -> SourceResult<()> { + let align = AlignElem::alignment_in(styles).resolve(styles); + let leading = ParElem::leading_in(styles); + let spacing = ParElem::spacing_in(styles); + let costs = TextElem::costs_in(styles); + + let lines = crate::layout_inline( + self.engine, + &elem.children, + self.locator.next(&elem.span()), + styles, + self.last_was_par, + self.base, + self.expand, + )? + .into_frames(); + + self.output.push(Child::Rel(spacing.into(), 4)); + + // Determine whether to prevent widow and orphans. + let len = lines.len(); + let prevent_orphans = + costs.orphan() > Ratio::zero() && len >= 2 && !lines[1].is_empty(); + let prevent_widows = + costs.widow() > Ratio::zero() && len >= 2 && !lines[len - 2].is_empty(); + let prevent_all = len == 3 && prevent_orphans && prevent_widows; + + // Store the heights of lines at the edges because we'll potentially + // need these later when `lines` is already moved. + let height_at = |i| lines.get(i).map(Frame::height).unwrap_or_default(); + let front_1 = height_at(0); + let front_2 = height_at(1); + let back_2 = height_at(len.saturating_sub(2)); + let back_1 = height_at(len.saturating_sub(1)); + + for (i, frame) in lines.into_iter().enumerate() { + if i > 0 { + self.output.push(Child::Rel(leading.into(), 5)); + } + + // To prevent widows and orphans, we require enough space for + // - all lines if it's just three + // - the first two lines if we're at the first line + // - the last two lines if we're at the second to last line + let need = if prevent_all && i == 0 { + front_1 + leading + front_2 + leading + back_1 + } else if prevent_orphans && i == 0 { + front_1 + leading + front_2 + } else if prevent_widows && i >= 2 && i + 2 == len { + back_2 + leading + back_1 + } else { + frame.height() + }; + + self.output + .push(Child::Line(self.boxed(LineChild { frame, align, need }))); + } + + self.output.push(Child::Rel(spacing.into(), 4)); + self.last_was_par = true; + + Ok(()) + } + + /// Collect a block into a [`SingleChild`] or [`MultiChild`] depending on + /// whether it is breakable. + fn block(&mut self, elem: &'a Packed<BlockElem>, styles: StyleChain<'a>) { + let locator = self.locator.next(&elem.span()); + let align = AlignElem::alignment_in(styles).resolve(styles); + let alone = self.children.len() == 1; + let sticky = elem.sticky(styles); + let breakable = elem.breakable(styles); + let fr = match elem.height(styles) { + Sizing::Fr(fr) => Some(fr), + _ => None, + }; + + let fallback = Lazy::new(|| ParElem::spacing_in(styles)); + let spacing = |amount| match amount { + Smart::Auto => Child::Rel((*fallback).into(), 4), + Smart::Custom(Spacing::Rel(rel)) => Child::Rel(rel.resolve(styles), 3), + Smart::Custom(Spacing::Fr(fr)) => Child::Fr(fr), + }; + + self.output.push(spacing(elem.above(styles))); + + if !breakable || fr.is_some() { + self.output.push(Child::Single(self.boxed(SingleChild { + align, + sticky, + alone, + fr, + elem, + styles, + locator, + cell: CachedCell::new(), + }))); + } else { + self.output.push(Child::Multi(self.boxed(MultiChild { + align, + sticky, + alone, + elem, + styles, + locator, + cell: CachedCell::new(), + }))); + }; + + self.output.push(spacing(elem.below(styles))); + self.last_was_par = false; + } + + /// Collects a placed element into a [`PlacedChild`]. + fn place( + &mut self, + elem: &'a Packed<PlaceElem>, + styles: StyleChain<'a>, + ) -> SourceResult<()> { + let alignment = elem.alignment(styles); + let align_x = alignment.map_or(FixedAlignment::Center, |align| { + align.x().unwrap_or_default().resolve(styles) + }); + let align_y = alignment.map(|align| align.y().map(|y| y.resolve(styles))); + let scope = elem.scope(styles); + let float = elem.float(styles); + + match (float, align_y) { + (true, Smart::Custom(None | Some(FixedAlignment::Center))) => bail!( + elem.span(), + "vertical floating placement must be `auto`, `top`, or `bottom`" + ), + (false, Smart::Auto) => bail!( + elem.span(), + "automatic positioning is only available for floating placement"; + hint: "you can enable floating placement with `place(float: true, ..)`" + ), + _ => {} + } + + if !float && scope == PlacementScope::Parent { + bail!( + elem.span(), + "parent-scoped positioning is currently only available for floating placement"; + hint: "you can enable floating placement with `place(float: true, ..)`" + ); + } + + let locator = self.locator.next(&elem.span()); + let clearance = elem.clearance(styles); + let delta = Axes::new(elem.dx(styles), elem.dy(styles)).resolve(styles); + self.output.push(Child::Placed(self.boxed(PlacedChild { + align_x, + align_y, + scope, + float, + clearance, + delta, + elem, + styles, + locator, + alignment, + cell: CachedCell::new(), + }))); + + Ok(()) + } + + /// Wraps a value in a bump-allocated box to reduce its footprint in the + /// [`Child`] enum. + fn boxed<T>(&self, value: T) -> BumpBox<'a, T> { + BumpBox::new_in(value, self.bump) + } +} + +/// A prepared child in flow layout. +/// +/// The larger variants are bump-boxed to keep the enum size down. +#[derive(Debug)] +pub enum Child<'a> { + /// An introspection tag. + Tag(&'a Tag), + /// Relative spacing with a specific weakness level. + Rel(Rel<Abs>, u8), + /// Fractional spacing. + Fr(Fr), + /// An already layouted line of a paragraph. + Line(BumpBox<'a, LineChild>), + /// An unbreakable block. + Single(BumpBox<'a, SingleChild<'a>>), + /// A breakable block. + Multi(BumpBox<'a, MultiChild<'a>>), + /// An absolutely or floatingly placed element. + Placed(BumpBox<'a, PlacedChild<'a>>), + /// A place flush. + Flush, + /// An explicit column break. + Break(bool), +} + +/// A child that encapsulates a layouted line of a paragraph. +#[derive(Debug)] +pub struct LineChild { + pub frame: Frame, + pub align: Axes<FixedAlignment>, + pub need: Abs, +} + +/// A child that encapsulates a prepared unbreakable block. +#[derive(Debug)] +pub struct SingleChild<'a> { + pub align: Axes<FixedAlignment>, + pub sticky: bool, + pub alone: bool, + pub fr: Option<Fr>, + elem: &'a Packed<BlockElem>, + styles: StyleChain<'a>, + locator: Locator<'a>, + cell: CachedCell<SourceResult<Frame>>, +} + +impl SingleChild<'_> { + /// Build the child's frame given the region's base size. + pub fn layout(&self, engine: &mut Engine, region: Region) -> SourceResult<Frame> { + self.cell.get_or_init(region, |mut region| { + // Vertical expansion is only kept if this block is the only child. + region.expand.y &= self.alone; + layout_single_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + self.elem, + self.locator.track(), + self.styles, + region, + ) + }) + } +} + +/// The cached, internal implementation of [`SingleChild::layout`]. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn layout_single_impl( + routines: &Routines, + world: Tracked<dyn World + '_>, + introspector: Tracked<Introspector>, + traced: Tracked<Traced>, + sink: TrackedMut<Sink>, + route: Tracked<Route>, + elem: &Packed<BlockElem>, + locator: Tracked<Locator>, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + let link = LocatorLink::new(locator); + let locator = Locator::link(&link); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route), + }; + + layout_single_block(elem, &mut engine, locator, styles, region) + .map(|frame| frame.post_processed(styles)) +} + +/// A child that encapsulates a prepared breakable block. +#[derive(Debug)] +pub struct MultiChild<'a> { + pub align: Axes<FixedAlignment>, + pub sticky: bool, + alone: bool, + elem: &'a Packed<BlockElem>, + styles: StyleChain<'a>, + locator: Locator<'a>, + cell: CachedCell<SourceResult<Fragment>>, +} + +impl<'a> MultiChild<'a> { + /// Build the child's frames given regions. + pub fn layout<'b>( + &'b self, + engine: &mut Engine, + regions: Regions, + ) -> SourceResult<(Frame, Option<MultiSpill<'a, 'b>>)> { + let fragment = self.layout_full(engine, regions)?; + + // Extract the first frame. + let mut frames = fragment.into_iter(); + let frame = frames.next().unwrap(); + + // If there's more, return a `spill`. + let mut spill = None; + if frames.next().is_some() { + spill = Some(MultiSpill { + multi: self, + full: regions.full, + first: regions.size.y, + backlog: vec![], + min_backlog_len: regions.backlog.len(), + }); + } + + Ok((frame, spill)) + } + + /// The shared internal implementation of [`Self::layout`] and + /// [`MultiSpill::layout`]. + fn layout_full( + &self, + engine: &mut Engine, + regions: Regions, + ) -> SourceResult<Fragment> { + self.cell.get_or_init(regions, |mut regions| { + // Vertical expansion is only kept if this block is the only child. + regions.expand.y &= self.alone; + layout_multi_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + self.elem, + self.locator.track(), + self.styles, + regions, + ) + }) + } +} + +/// The cached, internal implementation of [`MultiChild::layout_full`]. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn layout_multi_impl( + routines: &Routines, + world: Tracked<dyn World + '_>, + introspector: Tracked<Introspector>, + traced: Tracked<Traced>, + sink: TrackedMut<Sink>, + route: Tracked<Route>, + elem: &Packed<BlockElem>, + locator: Tracked<Locator>, + styles: StyleChain, + regions: Regions, +) -> SourceResult<Fragment> { + let link = LocatorLink::new(locator); + let locator = Locator::link(&link); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route), + }; + + layout_multi_block(elem, &mut engine, locator, styles, regions).map(|mut fragment| { + for frame in &mut fragment { + frame.post_process(styles); + } + fragment + }) +} + +/// The spilled remains of a `MultiChild` that broke across two regions. +#[derive(Debug, Clone)] +pub struct MultiSpill<'a, 'b> { + multi: &'b MultiChild<'a>, + first: Abs, + full: Abs, + backlog: Vec<Abs>, + min_backlog_len: usize, +} + +impl MultiSpill<'_, '_> { + /// Build the spill's frames given regions. + pub fn layout( + mut self, + engine: &mut Engine, + regions: Regions, + ) -> SourceResult<(Frame, Option<Self>)> { + // The first region becomes unchangable and committed to our backlog. + self.backlog.push(regions.size.y); + + // The remaining regions are ephemeral and may be replaced. + let mut backlog: Vec<_> = + self.backlog.iter().chain(regions.backlog).copied().collect(); + + // Remove unnecessary backlog items to prevent it from growing + // unnecessarily, changing the region's hash. + while backlog.len() > self.min_backlog_len + && backlog.last().copied() == regions.last + { + backlog.pop(); + } + + // Build the pod with the merged regions. + let pod = Regions { + size: Size::new(regions.size.x, self.first), + expand: regions.expand, + full: self.full, + backlog: &backlog, + last: regions.last, + }; + + // Extract the not-yet-processed frames. + let mut frames = self + .multi + .layout_full(engine, pod)? + .into_iter() + .skip(self.backlog.len()); + + // Ensure that the backlog never shrinks, so that unwrapping below is at + // least fairly safe. Note that the whole region juggling here is + // fundamentally not ideal: It is a compatibility layer between the old + // (all regions provided upfront) & new (each region provided on-demand, + // like an iterator) layout model. This approach is not 100% correct, as + // in the old model later regions could have an effect on earlier + // frames, but it's the best we can do for now, until the multi + // layouters are refactored to the new model. + self.min_backlog_len = self.min_backlog_len.max(backlog.len()); + + // Save the first frame. + let frame = frames.next().unwrap(); + + // If there's more, return a `spill`. + let mut spill = None; + if frames.next().is_some() { + spill = Some(self); + } + + Ok((frame, spill)) + } + + /// The alignment of the breakable block. + pub fn align(&self) -> Axes<FixedAlignment> { + self.multi.align + } +} + +/// A child that encapsulates a prepared placed element. +#[derive(Debug)] +pub struct PlacedChild<'a> { + pub align_x: FixedAlignment, + pub align_y: Smart<Option<FixedAlignment>>, + pub scope: PlacementScope, + pub float: bool, + pub clearance: Abs, + pub delta: Axes<Rel<Abs>>, + elem: &'a Packed<PlaceElem>, + styles: StyleChain<'a>, + locator: Locator<'a>, + alignment: Smart<Alignment>, + cell: CachedCell<SourceResult<Frame>>, +} + +impl PlacedChild<'_> { + /// Build the child's frame given the region's base size. + pub fn layout(&self, engine: &mut Engine, base: Size) -> SourceResult<Frame> { + self.cell.get_or_init(base, |base| { + let align = self.alignment.unwrap_or_else(|| Alignment::CENTER); + let aligned = AlignElem::set_alignment(align).wrap(); + + let mut frame = crate::layout_frame( + engine, + &self.elem.body, + self.locator.relayout(), + self.styles.chain(&aligned), + Region::new(base, Axes::splat(false)), + )?; + + if self.float { + frame.set_parent(self.elem.location().unwrap()); + } + + Ok(frame.post_processed(self.styles)) + }) + } + + /// The element's location. + pub fn location(&self) -> Location { + self.elem.location().unwrap() + } +} + +/// Wraps a parameterized computation and caches its latest output. +/// +/// - When the computation is performed multiple times consecutively with the +/// same argument, reuses the cache. +/// - When the argument changes, the new output is cached. +#[derive(Clone)] +struct CachedCell<T>(RefCell<Option<(u128, T)>>); + +impl<T> CachedCell<T> { + /// Create an empty cached cell. + fn new() -> Self { + Self(RefCell::new(None)) + } + + /// Perform the computation `f` with caching. + fn get_or_init<F, I>(&self, input: I, f: F) -> T + where + I: Hash, + T: Clone, + F: FnOnce(I) -> T, + { + let input_hash = typst_utils::hash128(&input); + + let mut slot = self.0.borrow_mut(); + if let Some((hash, output)) = &*slot { + if *hash == input_hash { + return output.clone(); + } + } + + let output = f(input); + *slot = Some((input_hash, output.clone())); + output + } +} + +impl<T> Default for CachedCell<T> { + fn default() -> Self { + Self::new() + } +} + +impl<T> Debug for CachedCell<T> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.pad("CachedCell(..)") + } +} diff --git a/crates/typst-layout/src/flow/compose.rs b/crates/typst-layout/src/flow/compose.rs new file mode 100644 index 00000000..932ccc9a --- /dev/null +++ b/crates/typst-layout/src/flow/compose.rs @@ -0,0 +1,877 @@ +use std::num::NonZeroUsize; + +use typst_library::diag::SourceResult; +use typst_library::engine::Engine; +use typst_library::foundations::{Content, NativeElement, Packed, Resolve, Smart}; +use typst_library::introspection::{ + Counter, CounterDisplayElem, CounterState, CounterUpdate, Location, Locator, + SplitLocator, Tag, +}; +use typst_library::layout::{ + Abs, Axes, Dir, FixedAlignment, Fragment, Frame, FrameItem, OuterHAlignment, + PlacementScope, Point, Region, Regions, Rel, Size, +}; +use typst_library::model::{ + FootnoteElem, FootnoteEntry, LineNumberingScope, Numbering, ParLineMarker, +}; +use typst_syntax::Span; +use typst_utils::NonZeroExt; + +use super::{distribute, Config, FlowResult, LineNumberConfig, PlacedChild, Stop, Work}; + +/// Composes the contents of a single page/region. A region can have multiple +/// columns/subregions. +/// +/// The composer is primarily concerned with layout of out-of-flow insertions +/// (floats and footnotes). It does this in per-page and per-column loops that +/// rerun when a new float is added (since it affects the regions available to +/// the distributor). +/// +/// To lay out the in-flow contents of individual subregions, the composer +/// invokes [distribution](distribute). +pub fn compose( + engine: &mut Engine, + work: &mut Work, + config: &Config, + locator: Locator, + regions: Regions, +) -> SourceResult<Frame> { + Composer { + engine, + config, + page_base: regions.base(), + column: 0, + page_insertions: Insertions::default(), + column_insertions: Insertions::default(), + work, + footnote_spill: None, + footnote_queue: vec![], + } + .page(locator, regions) +} + +/// State for composition. +/// +/// Sadly, we need that many lifetimes because &mut references are invariant and +/// it would force the lifetimes of various things to be equal if they +/// shared a lifetime. +/// +/// The only interesting lifetimes are 'a and 'b. See [Work] for more details +/// about them. +pub struct Composer<'a, 'b, 'x, 'y> { + pub engine: &'x mut Engine<'y>, + pub work: &'x mut Work<'a, 'b>, + pub config: &'x Config<'x>, + column: usize, + page_base: Size, + page_insertions: Insertions<'a, 'b>, + column_insertions: Insertions<'a, 'b>, + // These are here because they have to survive relayout (we could lose the + // footnotes otherwise). For floats, we revisit them anyway, so it's okay to + // use `work.floats` directly. This is not super clean; probably there's a + // better way. + footnote_spill: Option<std::vec::IntoIter<Frame>>, + footnote_queue: Vec<Packed<FootnoteElem>>, +} + +impl<'a, 'b> Composer<'a, 'b, '_, '_> { + /// Lay out a container/page region, including container/page insertions. + fn page(mut self, locator: Locator, regions: Regions) -> SourceResult<Frame> { + // This loop can restart region layout when requested to do so by a + // `Stop`. This happens when there is a parent-scoped float. + let checkpoint = self.work.clone(); + let output = loop { + // Shrink the available space by the space used by page + // insertions. + let mut pod = regions; + pod.size.y -= self.page_insertions.height(); + + match self.page_contents(locator.relayout(), pod) { + Ok(frame) => break frame, + Err(Stop::Finish(_)) => unreachable!(), + Err(Stop::Relayout(PlacementScope::Column)) => unreachable!(), + Err(Stop::Relayout(PlacementScope::Parent)) => { + *self.work = checkpoint.clone(); + continue; + } + Err(Stop::Error(err)) => return Err(err), + }; + }; + drop(checkpoint); + + Ok(self.page_insertions.finalize(self.work, self.config, output)) + } + + /// Lay out the inner contents of a container/page. + fn page_contents(&mut self, locator: Locator, regions: Regions) -> FlowResult<Frame> { + // No point in create column regions, if there's just one! + if self.config.columns.count == 1 { + return self.column(locator, regions); + } + + // Create a backlog for multi-column layout. + let column_height = regions.size.y; + let backlog: Vec<_> = std::iter::once(&column_height) + .chain(regions.backlog) + .flat_map(|&h| std::iter::repeat(h).take(self.config.columns.count)) + .skip(1) + .collect(); + + // Subregions for column layout. + let mut inner = Regions { + size: Size::new(self.config.columns.width, column_height), + backlog: &backlog, + expand: Axes::new(true, regions.expand.y), + ..regions + }; + + // The size of the merged frame hosting multiple columns. + let size = Size::new( + regions.size.x, + if regions.expand.y { regions.size.y } else { Abs::zero() }, + ); + + let mut output = Frame::hard(size); + let mut offset = Abs::zero(); + let mut locator = locator.split(); + + // Lay out the columns and stitch them together. + for i in 0..self.config.columns.count { + self.column = i; + let frame = self.column(locator.next(&()), inner)?; + + if !regions.expand.y { + output.size_mut().y.set_max(frame.height()); + } + + let width = frame.width(); + let x = if self.config.columns.dir == Dir::LTR { + offset + } else { + regions.size.x - offset - width + }; + offset += width + self.config.columns.gutter; + + output.push_frame(Point::with_x(x), frame); + inner.next(); + } + + Ok(output) + } + + /// Lay out a column, including column insertions. + fn column(&mut self, locator: Locator, regions: Regions) -> FlowResult<Frame> { + // Reset column insertion when starting a new column. + self.column_insertions = Insertions::default(); + + // Process footnote spill. + if let Some(spill) = self.work.footnote_spill.take() { + self.footnote_spill(spill, regions.base())?; + } + + // This loop can restart column layout when requested to do so by a + // `Stop`. This happens when there is a column-scoped float. + let checkpoint = self.work.clone(); + let inner = loop { + // Shrink the available space by the space used by column + // insertions. + let mut pod = regions; + pod.size.y -= self.column_insertions.height(); + + match self.column_contents(pod) { + Ok(frame) => break frame, + Err(Stop::Finish(_)) => unreachable!(), + Err(Stop::Relayout(PlacementScope::Column)) => { + *self.work = checkpoint.clone(); + continue; + } + err => return err, + } + }; + drop(checkpoint); + + self.work.footnotes.extend(self.footnote_queue.drain(..)); + if let Some(spill) = self.footnote_spill.take() { + self.work.footnote_spill = Some(spill); + } + + let insertions = std::mem::take(&mut self.column_insertions); + let mut output = insertions.finalize(self.work, self.config, inner); + + // Lay out per-column line numbers. + if let Some(line_config) = &self.config.line_numbers { + layout_line_numbers( + self.engine, + self.config, + line_config, + locator, + self.column, + &mut output, + )?; + } + + Ok(output) + } + + /// Lay out the inner contents of a column. + fn column_contents(&mut self, regions: Regions) -> FlowResult<Frame> { + // Process pending footnotes. + for note in std::mem::take(&mut self.work.footnotes) { + self.footnote(note, &mut regions.clone(), Abs::zero(), false)?; + } + + // Process pending floats. + for placed in std::mem::take(&mut self.work.floats) { + self.float(placed, ®ions, false)?; + } + + distribute(self, regions) + } + + /// Lays out an item with floating placement. + /// + /// This is called from within [`distribute`]. When the float fits, this + /// returns an `Err(Stop::Relayout(..))`, which bubbles all the way through + /// distribution and is handled in [`Self::page`] or [`Self::column`] + /// (depending on `placed.scope`). + /// + /// When the float does not fit, it is queued into `work.floats`. The + /// value of `clearance` that between the float and flow content is needed + /// --- it is set if there are already distributed items. + pub fn float( + &mut self, + placed: &'b PlacedChild<'a>, + regions: &Regions, + clearance: bool, + ) -> FlowResult<()> { + // If the float is already processed, skip it. + let loc = placed.location(); + if self.skipped(loc) { + return Ok(()); + } + + // If there is already a queued float, queue this one as well. We + // don't want to disrupt the order. + if !self.work.floats.is_empty() { + self.work.floats.push(placed); + return Ok(()); + } + + // Determine the base size of the chosen scope. + let base = match placed.scope { + PlacementScope::Column => regions.base(), + PlacementScope::Parent => self.page_base, + }; + + // Lay out the placed element. + let frame = placed.layout(self.engine, base)?; + + // Determine the remaining space in the scope. This is exact for column + // placement, but only an approximation for page placement. + let remaining = match placed.scope { + PlacementScope::Column => regions.size.y, + PlacementScope::Parent => { + let remaining: Abs = regions + .iter() + .map(|size| size.y) + .take(self.config.columns.count - self.column) + .sum(); + remaining / self.config.columns.count as f64 + } + }; + + // We only require clearance if there is other content. + let clearance = if clearance { placed.clearance } else { Abs::zero() }; + let need = frame.height() + clearance; + + // If the float doesn't fit, queue it for the next region. + if !remaining.fits(need) && regions.may_progress() { + self.work.floats.push(placed); + return Ok(()); + } + + // Handle footnotes in the float. + self.footnotes(regions, &frame, need, false)?; + + // Determine the float's vertical alignment. We can unwrap the inner + // `Option` because `Custom(None)` is checked for during collection. + let align_y = placed.align_y.map(Option::unwrap).unwrap_or_else(|| { + // When the float's vertical midpoint would be above the middle of + // the page if it were layouted in-flow, we use top alignment. + // Otherwise, we use bottom alignment. + let used = base.y - remaining; + let half = need / 2.0; + let ratio = (used + half) / base.y; + if ratio <= 0.5 { + FixedAlignment::Start + } else { + FixedAlignment::End + } + }); + + // Select the insertion area where we'll put this float. + let area = match placed.scope { + PlacementScope::Column => &mut self.column_insertions, + PlacementScope::Parent => &mut self.page_insertions, + }; + + // Put the float there. + area.push_float(placed, frame, align_y); + area.skips.push(loc); + + // Trigger relayout. + Err(Stop::Relayout(placed.scope)) + } + + /// Lays out footnotes in the `frame` if this is the root flow and there are + /// any. The value of `breakable` indicates whether the element that + /// produced the frame is breakable. If not, the frame is treated as atomic. + pub fn footnotes( + &mut self, + regions: &Regions, + frame: &Frame, + flow_need: Abs, + breakable: bool, + ) -> FlowResult<()> { + // Footnotes are only supported at the root level. + if !self.config.root { + return Ok(()); + } + + // Search for footnotes. + let mut notes = vec![]; + for tag in &self.work.tags { + let Tag::Start(elem) = tag else { continue }; + let Some(note) = elem.to_packed::<FootnoteElem>() else { continue }; + notes.push((Abs::zero(), note.clone())); + } + find_in_frame_impl::<FootnoteElem>(&mut notes, frame, Abs::zero()); + if notes.is_empty() { + return Ok(()); + } + + let mut relayout = false; + let mut regions = *regions; + let mut migratable = !breakable && regions.may_progress(); + + for (y, elem) in notes { + // The amount of space used by the in-flow content that contains the + // footnote marker. For a breakable frame, it's the y position of + // the marker. For an unbreakable frame, it's the full height. + let flow_need = if breakable { y } else { flow_need }; + + // Process the footnote. + match self.footnote(elem, &mut regions, flow_need, migratable) { + // The footnote was already processed or queued. + Ok(()) => {} + // First handle more footnotes before relayouting. + Err(Stop::Relayout(_)) => relayout = true, + // Either of + // - A `Stop::Finish` indicating that the frame's origin element + // should migrate to uphold the footnote invariant. + // - A fatal error. + err => return err, + } + + // We only migrate the origin frame if the first footnote's first + // line didn't fit. + migratable = false; + } + + // If this is set, we laid out at least one footnote, so we need a + // relayout. + if relayout { + return Err(Stop::Relayout(PlacementScope::Column)); + } + + Ok(()) + } + + /// Handles a single footnote. + fn footnote( + &mut self, + elem: Packed<FootnoteElem>, + regions: &mut Regions, + flow_need: Abs, + migratable: bool, + ) -> FlowResult<()> { + // Ignore reference footnotes and already processed ones. + let loc = elem.location().unwrap(); + if elem.is_ref() || self.skipped(loc) { + return Ok(()); + } + + // If there is already a queued spill or footnote, queue this one as + // well. We don't want to disrupt the order. + let area = &mut self.column_insertions; + if self.footnote_spill.is_some() || !self.footnote_queue.is_empty() { + self.footnote_queue.push(elem); + return Ok(()); + } + + // If there weren't any footnotes so far, account for the footnote + // separator. + let mut separator = None; + let mut separator_need = Abs::zero(); + if area.footnotes.is_empty() { + let frame = + layout_footnote_separator(self.engine, self.config, regions.base())?; + separator_need += self.config.footnote.clearance + frame.height(); + separator = Some(frame); + } + + // Prepare regions for the footnote. + let mut pod = *regions; + pod.expand.y = false; + pod.size.y -= flow_need + separator_need + self.config.footnote.gap; + + // Layout the footnote entry. + let frames = layout_footnote(self.engine, self.config, &elem, pod)?.into_frames(); + + // Find nested footnotes in the entry. + let nested = find_in_frames::<FootnoteElem>(&frames); + + // Extract the first frame. + let mut iter = frames.into_iter(); + let first = iter.next().unwrap(); + let note_need = self.config.footnote.gap + first.height(); + + // If the first frame is empty, then none of its content fit. If + // possible, we then migrate the origin frame to the next region to + // uphold the footnote invariant (that marker and entry are on the same + // page). If not, we just queue the footnote for the next page. + if first.is_empty() { + if migratable { + return Err(Stop::Finish(false)); + } else { + self.footnote_queue.push(elem); + return Ok(()); + } + } + + // Save the separator. + if let Some(frame) = separator { + area.push_footnote_separator(self.config, frame); + regions.size.y -= separator_need; + } + + // Save the footnote's frame. + area.push_footnote(self.config, first); + area.skips.push(loc); + regions.size.y -= note_need; + + // Save the spill. + if !iter.as_slice().is_empty() { + self.footnote_spill = Some(iter); + } + + // Lay out nested footnotes. + for (_, note) in nested { + self.footnote(note, regions, flow_need, migratable)?; + } + + // Since we laid out a footnote, we need a relayout. + Err(Stop::Relayout(PlacementScope::Column)) + } + + /// Handles spillover from a footnote. + fn footnote_spill( + &mut self, + mut iter: std::vec::IntoIter<Frame>, + base: Size, + ) -> SourceResult<()> { + let area = &mut self.column_insertions; + + // Create and save the separator. + let separator = layout_footnote_separator(self.engine, self.config, base)?; + area.push_footnote_separator(self.config, separator); + + // Save the footnote's frame. + let frame = iter.next().unwrap(); + area.push_footnote(self.config, frame); + + // Save the spill. + if !iter.as_slice().is_empty() { + self.footnote_spill = Some(iter); + } + + Ok(()) + } + + /// Checks whether an insertion was already processed and doesn't need to be + /// handled again. + fn skipped(&self, loc: Location) -> bool { + self.work.skips.contains(&loc) + || self.page_insertions.skips.contains(&loc) + || self.column_insertions.skips.contains(&loc) + } + + /// The amount of width needed by insertions. + pub fn insertion_width(&self) -> Abs { + self.column_insertions.width.max(self.page_insertions.width) + } +} + +/// Lay out the footnote separator, typically a line. +fn layout_footnote_separator( + engine: &mut Engine, + config: &Config, + base: Size, +) -> SourceResult<Frame> { + crate::layout_frame( + engine, + &config.footnote.separator, + Locator::root(), + config.shared, + Region::new(base, Axes::new(config.footnote.expand, false)), + ) +} + +/// Lay out a footnote. +fn layout_footnote( + engine: &mut Engine, + config: &Config, + elem: &Packed<FootnoteElem>, + pod: Regions, +) -> SourceResult<Fragment> { + let loc = elem.location().unwrap(); + crate::layout_fragment( + engine, + &FootnoteEntry::new(elem.clone()).pack(), + Locator::synthesize(loc), + config.shared, + pod, + ) + .map(|mut fragment| { + for frame in &mut fragment { + frame.set_parent(loc); + } + fragment + }) +} + +/// An additive list of insertions. +#[derive(Default)] +struct Insertions<'a, 'b> { + top_floats: Vec<(&'b PlacedChild<'a>, Frame)>, + bottom_floats: Vec<(&'b PlacedChild<'a>, Frame)>, + footnotes: Vec<Frame>, + footnote_separator: Option<Frame>, + top_size: Abs, + bottom_size: Abs, + width: Abs, + skips: Vec<Location>, +} + +impl<'a, 'b> Insertions<'a, 'b> { + /// Add a float to the top or bottom area. + fn push_float( + &mut self, + placed: &'b PlacedChild<'a>, + frame: Frame, + align_y: FixedAlignment, + ) { + self.width.set_max(frame.width()); + + let amount = frame.height() + placed.clearance; + let pair = (placed, frame); + + if align_y == FixedAlignment::Start { + self.top_size += amount; + self.top_floats.push(pair); + } else { + self.bottom_size += amount; + self.bottom_floats.push(pair); + } + } + + /// Add a footnote to the bottom area. + fn push_footnote(&mut self, config: &Config, frame: Frame) { + self.width.set_max(frame.width()); + self.bottom_size += config.footnote.gap + frame.height(); + self.footnotes.push(frame); + } + + /// Add a footnote separator to the bottom area. + fn push_footnote_separator(&mut self, config: &Config, frame: Frame) { + self.width.set_max(frame.width()); + self.bottom_size += config.footnote.clearance + frame.height(); + self.footnote_separator = Some(frame); + } + + /// The combined height of the top and bottom area (includings clearances). + /// Subtracting this from the total region size yields the available space + /// for distribution. + fn height(&self) -> Abs { + self.top_size + self.bottom_size + } + + /// Produce a frame for the full region based on the `inner` frame produced + /// by distribution or column layout. + fn finalize(self, work: &mut Work, config: &Config, inner: Frame) -> Frame { + work.extend_skips(&self.skips); + + if self.top_floats.is_empty() + && self.bottom_floats.is_empty() + && self.footnote_separator.is_none() + && self.footnotes.is_empty() + { + return inner; + } + + let size = inner.size() + Size::with_y(self.height()); + + let mut output = Frame::soft(size); + let mut offset_top = Abs::zero(); + let mut offset_bottom = size.y - self.bottom_size; + + for (placed, frame) in self.top_floats { + let x = placed.align_x.position(size.x - frame.width()); + let y = offset_top; + let delta = placed.delta.zip_map(size, Rel::relative_to).to_point(); + offset_top += frame.height() + placed.clearance; + output.push_frame(Point::new(x, y) + delta, frame); + } + + output.push_frame(Point::with_y(self.top_size), inner); + + // We put floats first and then footnotes. This differs from what LaTeX + // does and is a little inconsistent w.r.t column vs page floats (page + // floats are below footnotes because footnotes are per column), but + // it's what most people (including myself) seem to intuitively expect. + // We experimented with the LaTeX ordering in 0.12.0-rc1, but folks were + // surprised and considered this strange. In LaTeX, it can be changed + // with `\usepackage[bottom]{footmisc}`. We could also consider adding + // configuration in the future. + for (placed, frame) in self.bottom_floats { + offset_bottom += placed.clearance; + let x = placed.align_x.position(size.x - frame.width()); + let y = offset_bottom; + let delta = placed.delta.zip_map(size, Rel::relative_to).to_point(); + offset_bottom += frame.height(); + output.push_frame(Point::new(x, y) + delta, frame); + } + + if let Some(frame) = self.footnote_separator { + offset_bottom += config.footnote.clearance; + let y = offset_bottom; + offset_bottom += frame.height(); + output.push_frame(Point::with_y(y), frame); + } + + for frame in self.footnotes { + offset_bottom += config.footnote.gap; + let y = offset_bottom; + offset_bottom += frame.height(); + output.push_frame(Point::with_y(y), frame); + } + + output + } +} + +/// Lay out the given collected lines' line numbers to an output frame. +/// +/// The numbers are placed either on the left margin (left border of the frame) +/// or on the right margin (right border). Before they are placed, a line number +/// counter reset is inserted if we're in the first column of the page being +/// currently laid out and the user requested for line numbers to be reset at +/// the start of every page. +fn layout_line_numbers( + engine: &mut Engine, + config: &Config, + line_config: &LineNumberConfig, + locator: Locator, + column: usize, + output: &mut Frame, +) -> SourceResult<()> { + let mut locator = locator.split(); + + // Reset page-scoped line numbers if currently at the first column. + if column == 0 && line_config.scope == LineNumberingScope::Page { + let reset = layout_line_number_reset(engine, config, &mut locator)?; + output.push_frame(Point::zero(), reset); + } + + // Find all line markers. + let mut lines = find_in_frame::<ParLineMarker>(output); + if lines.is_empty() { + return Ok(()); + } + + // Assume the line numbers aren't sorted by height. They must be sorted so + // we can deduplicate line numbers below based on vertical proximity. + lines.sort_by_key(|&(y, _)| y); + + // Used for horizontal alignment. + let mut max_number_width = Abs::zero(); + + // This is used to skip lines that are too close together. + let mut prev_bottom = None; + + // Buffer line number frames so we can align them horizontally later before + // placing, based on the width of the largest line number. + let mut line_numbers = vec![]; + + // Layout the lines. + for &(y, ref marker) in &lines { + if prev_bottom.is_some_and(|bottom| y < bottom) { + // Lines are too close together. Display as the same line number. + continue; + } + + // Layout the number and record its width in search of the maximium. + let frame = layout_line_number(engine, config, &mut locator, &marker.numbering)?; + + // Note that this line.y is larger than the previous due to sorting. + // Therefore, the check at the top of the loop ensures no line numbers + // will reasonably intersect with each other. We enforce a minimum + // spacing of 1pt between consecutive line numbers in case a zero-height + // frame is used. + prev_bottom = Some(y + frame.height().max(Abs::pt(1.0))); + max_number_width.set_max(frame.width()); + line_numbers.push((y, marker, frame)); + } + + for (y, marker, frame) in line_numbers { + // The last column will always place line numbers at the end + // margin. This should become configurable in the future. + let margin = { + let opposite = + config.columns.count >= 2 && column + 1 == config.columns.count; + if opposite { OuterHAlignment::End } else { marker.number_margin } + .resolve(config.shared) + }; + + // Determine how much space to leave between the column and the number. + let clearance = match marker.number_clearance { + Smart::Auto => line_config.default_clearance, + Smart::Custom(rel) => rel.resolve(config.shared), + }; + + // Compute the base X position. + let x = match margin { + // Move the number to the left of the left edge (at 0pt) by the maximum + // width and the clearance. + FixedAlignment::Start => -max_number_width - clearance, + // Move the number to the right edge and add clearance. + FixedAlignment::End => output.width() + clearance, + // Can't happen due to `OuterHAlignment`. + FixedAlignment::Center => unreachable!(), + }; + + // Determine how much to shift the number due to its alignment. + let shift = { + let align = marker + .number_align + .map(|align| align.resolve(config.shared)) + .unwrap_or_else(|| margin.inv()); + align.position(max_number_width - frame.width()) + }; + + // Compute the final position of the number and add it to the output. + let pos = Point::new(x + shift, y); + output.push_frame(pos, frame); + } + + Ok(()) +} + +/// Creates a frame that resets the line number counter. +fn layout_line_number_reset( + engine: &mut Engine, + config: &Config, + locator: &mut SplitLocator, +) -> SourceResult<Frame> { + let counter = Counter::of(ParLineMarker::elem()); + let update = CounterUpdate::Set(CounterState::init(false)); + let content = counter.update(Span::detached(), update); + crate::layout_frame( + engine, + &content, + locator.next(&()), + config.shared, + Region::new(Axes::splat(Abs::zero()), Axes::splat(false)), + ) +} + +/// Layout the line number associated with the given line marker. +/// +/// Produces a counter update and counter display with counter key +/// `ParLineMarker`. We use `ParLineMarker` as it is an element which is not +/// exposed to the user and we don't want to expose the line number counter at +/// the moment, given that its semantics are inconsistent with that of normal +/// counters (the counter is updated based on height and not on frame order / +/// layer). When we find a solution to this, we should switch to a counter on +/// `ParLine` instead, thus exposing the counter as `counter(par.line)` to the +/// user. +fn layout_line_number( + engine: &mut Engine, + config: &Config, + locator: &mut SplitLocator, + numbering: &Numbering, +) -> SourceResult<Frame> { + let counter = Counter::of(ParLineMarker::elem()); + let update = CounterUpdate::Step(NonZeroUsize::ONE); + let numbering = Smart::Custom(numbering.clone()); + + // Combine counter update and display into the content we'll layout. + let content = Content::sequence(vec![ + counter.clone().update(Span::detached(), update), + CounterDisplayElem::new(counter, numbering, false).pack(), + ]); + + // Layout the number. + let mut frame = crate::layout_frame( + engine, + &content, + locator.next(&()), + config.shared, + Region::new(Axes::splat(Abs::inf()), Axes::splat(false)), + )?; + + // Ensure the baseline of the line number aligns with the line's baseline. + frame.translate(Point::with_y(-frame.baseline())); + + Ok(frame) +} + +/// Collect all matching elements and their vertical positions in the frame. +/// +/// On each subframe we encounter, we add that subframe's position to `prev_y`, +/// until we reach a tag, at which point we add the tag's position and finish. +/// That gives us the absolute height of the tag from the start of the root +/// frame. +fn find_in_frame<T: NativeElement>(frame: &Frame) -> Vec<(Abs, Packed<T>)> { + let mut output = vec![]; + find_in_frame_impl(&mut output, frame, Abs::zero()); + output +} + +/// Collect all matching elements and their vertical positions in the frames. +fn find_in_frames<T: NativeElement>(frames: &[Frame]) -> Vec<(Abs, Packed<T>)> { + let mut output = vec![]; + for frame in frames { + find_in_frame_impl(&mut output, frame, Abs::zero()); + } + output +} + +fn find_in_frame_impl<T: NativeElement>( + output: &mut Vec<(Abs, Packed<T>)>, + frame: &Frame, + y_offset: Abs, +) { + for (pos, item) in frame.items() { + let y = y_offset + pos.y; + match item { + FrameItem::Group(group) => find_in_frame_impl(output, &group.frame, y), + FrameItem::Tag(Tag::Start(elem)) => { + if let Some(elem) = elem.to_packed::<T>() { + output.push((y, elem.clone())); + } + } + _ => {} + } + } +} diff --git a/crates/typst-layout/src/flow/distribute.rs b/crates/typst-layout/src/flow/distribute.rs new file mode 100644 index 00000000..1852f7ca --- /dev/null +++ b/crates/typst-layout/src/flow/distribute.rs @@ -0,0 +1,525 @@ +use typst_library::introspection::Tag; +use typst_library::layout::{ + Abs, Axes, FixedAlignment, Fr, Frame, FrameItem, Point, Region, Regions, Rel, Size, +}; +use typst_utils::Numeric; + +use super::{ + Child, Composer, FlowResult, LineChild, MultiChild, MultiSpill, PlacedChild, + SingleChild, Stop, Work, +}; + +/// Distributes as many children as fit from `composer.work` into the first +/// region and returns the resulting frame. +pub fn distribute(composer: &mut Composer, regions: Regions) -> FlowResult<Frame> { + let mut distributor = Distributor { + composer, + regions, + items: vec![], + sticky: None, + stickable: false, + }; + let init = distributor.snapshot(); + let forced = match distributor.run() { + Ok(()) => distributor.composer.work.done(), + Err(Stop::Finish(forced)) => forced, + Err(err) => return Err(err), + }; + let region = Region::new(regions.size, regions.expand); + distributor.finalize(region, init, forced) +} + +/// State for distribution. +/// +/// See [Composer] regarding lifetimes. +struct Distributor<'a, 'b, 'x, 'y, 'z> { + /// The composer that is used to handle insertions. + composer: &'z mut Composer<'a, 'b, 'x, 'y>, + /// Regions which are continously shrunk as new items are added. + regions: Regions<'z>, + /// Already laid out items, not yet aligned. + items: Vec<Item<'a, 'b>>, + /// A snapshot which can be restored to migrate a suffix of sticky blocks to + /// the next region. + sticky: Option<DistributionSnapshot<'a, 'b>>, + /// Whether there was at least one proper block. Otherwise, sticky blocks + /// are disabled (or else they'd keep being migrated). + stickable: bool, +} + +/// A snapshot of the distribution state. +struct DistributionSnapshot<'a, 'b> { + work: Work<'a, 'b>, + items: usize, +} + +/// A laid out item in a distribution. +enum Item<'a, 'b> { + /// An introspection tag. + Tag(&'a Tag), + /// Absolute spacing and its weakness level. + Abs(Abs, u8), + /// Fractional spacing or a fractional block. + Fr(Fr, Option<&'b SingleChild<'a>>), + /// A frame for a laid out line or block. + Frame(Frame, Axes<FixedAlignment>), + /// A frame for an absolutely (not floatingly) placed child. + Placed(Frame, &'b PlacedChild<'a>), +} + +impl Item<'_, '_> { + /// Whether this item should be migrated to the next region if the region + /// consists solely of such items. + fn migratable(&self) -> bool { + match self { + Self::Tag(_) => true, + Self::Frame(frame, _) => { + frame.size().is_zero() + && frame.items().all(|(_, item)| { + matches!(item, FrameItem::Link(_, _) | FrameItem::Tag(_)) + }) + } + Self::Placed(_, placed) => !placed.float, + _ => false, + } + } +} + +impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { + /// Distributes content into the region. + fn run(&mut self) -> FlowResult<()> { + // First, handle spill of a breakable block. + if let Some(spill) = self.composer.work.spill.take() { + self.multi_spill(spill)?; + } + + // If spill are taken care of, process children until no space is left + // or no children are left. + while let Some(child) = self.composer.work.head() { + self.child(child)?; + self.composer.work.advance(); + } + + Ok(()) + } + + /// Processes a single child. + /// + /// - Returns `Ok(())` if the child was successfully processed. + /// - Returns `Err(Stop::Finish)` if a region break should be triggered. + /// - Returns `Err(Stop::Relayout(_))` if the region needs to be relayouted + /// due to an insertion (float/footnote). + /// - Returns `Err(Stop::Error(_))` if there was a fatal error. + fn child(&mut self, child: &'b Child<'a>) -> FlowResult<()> { + match child { + Child::Tag(tag) => self.tag(tag), + Child::Rel(amount, weakness) => self.rel(*amount, *weakness), + Child::Fr(fr) => self.fr(*fr), + Child::Line(line) => self.line(line)?, + Child::Single(single) => self.single(single)?, + Child::Multi(multi) => self.multi(multi)?, + Child::Placed(placed) => self.placed(placed)?, + Child::Flush => self.flush()?, + Child::Break(weak) => self.break_(*weak)?, + } + Ok(()) + } + + /// Processes a tag. + fn tag(&mut self, tag: &'a Tag) { + self.composer.work.tags.push(tag); + } + + /// Generate items for pending tags. + fn flush_tags(&mut self) { + if !self.composer.work.tags.is_empty() { + let tags = &mut self.composer.work.tags; + self.items.extend(tags.iter().copied().map(Item::Tag)); + tags.clear(); + } + } + + /// Processes relative spacing. + fn rel(&mut self, amount: Rel<Abs>, weakness: u8) { + let amount = amount.relative_to(self.regions.base().y); + if weakness > 0 && !self.keep_spacing(amount, weakness) { + return; + } + + self.regions.size.y -= amount; + self.items.push(Item::Abs(amount, weakness)); + } + + /// Processes fractional spacing. + fn fr(&mut self, fr: Fr) { + self.trim_spacing(); + self.items.push(Item::Fr(fr, None)); + } + + /// Decides whether to keep weak spacing based on previous items. If there + /// is a preceding weak spacing, it might be patched in place. + fn keep_spacing(&mut self, amount: Abs, weakness: u8) -> bool { + for item in self.items.iter_mut().rev() { + match *item { + Item::Abs(prev_amount, prev_weakness @ 1..) => { + if weakness <= prev_weakness + && (weakness < prev_weakness || amount > prev_amount) + { + self.regions.size.y -= amount - prev_amount; + *item = Item::Abs(amount, weakness); + } + return false; + } + Item::Tag(_) | Item::Abs(..) | Item::Placed(..) => {} + Item::Fr(.., None) => return false, + Item::Frame(..) | Item::Fr(.., Some(_)) => return true, + } + } + false + } + + /// Trims trailing weak spacing from the items. + fn trim_spacing(&mut self) { + for (i, item) in self.items.iter().enumerate().rev() { + match *item { + Item::Abs(amount, 1..) => { + self.regions.size.y += amount; + self.items.remove(i); + break; + } + Item::Tag(_) | Item::Abs(..) | Item::Placed(..) => {} + Item::Frame(..) | Item::Fr(..) => break, + } + } + } + + /// The amount of trailing weak spacing. + fn weak_spacing(&mut self) -> Abs { + for item in self.items.iter().rev() { + match *item { + Item::Abs(amount, 1..) => return amount, + Item::Tag(_) | Item::Abs(..) | Item::Placed(..) => {} + Item::Frame(..) | Item::Fr(..) => break, + } + } + Abs::zero() + } + + /// Processes a line of a paragraph. + fn line(&mut self, line: &'b LineChild) -> FlowResult<()> { + // If the line doesn't fit and a followup region may improve things, + // finish the region. + if !self.regions.size.y.fits(line.frame.height()) && self.regions.may_progress() { + return Err(Stop::Finish(false)); + } + + // If the line's need, which includes its own height and that of + // following lines grouped by widow/orphan prevention, does not fit into + // the current region, but does fit into the next region, finish the + // region. + if !self.regions.size.y.fits(line.need) + && self + .regions + .iter() + .nth(1) + .is_some_and(|region| region.y.fits(line.need)) + { + return Err(Stop::Finish(false)); + } + + self.frame(line.frame.clone(), line.align, false, false) + } + + /// Processes an unbreakable block. + fn single(&mut self, single: &'b SingleChild<'a>) -> FlowResult<()> { + // Lay out the block. + let frame = single.layout( + self.composer.engine, + Region::new(self.regions.base(), self.regions.expand), + )?; + + // Handle fractionally sized blocks. + if let Some(fr) = single.fr { + self.composer.footnotes(&self.regions, &frame, Abs::zero(), false)?; + self.flush_tags(); + self.items.push(Item::Fr(fr, Some(single))); + return Ok(()); + } + + // If the block doesn't fit and a followup region may improve things, + // finish the region. + if !self.regions.size.y.fits(frame.height()) && self.regions.may_progress() { + return Err(Stop::Finish(false)); + } + + self.frame(frame, single.align, single.sticky, false) + } + + /// Processes a breakable block. + fn multi(&mut self, multi: &'b MultiChild<'a>) -> FlowResult<()> { + // Skip directly if the region is already (over)full. `line` and + // `single` implicitly do this through their `fits` checks. + if self.regions.is_full() { + return Err(Stop::Finish(false)); + } + + // Lay out the block. + let (frame, spill) = multi.layout(self.composer.engine, self.regions)?; + self.frame(frame, multi.align, multi.sticky, true)?; + + // If the block didn't fully fit into the current region, save it into + // the `spill` and finish the region. + if let Some(spill) = spill { + self.composer.work.spill = Some(spill); + self.composer.work.advance(); + return Err(Stop::Finish(false)); + } + + Ok(()) + } + + /// Processes spillover from a breakable block. + fn multi_spill(&mut self, spill: MultiSpill<'a, 'b>) -> FlowResult<()> { + // Skip directly if the region is already (over)full. + if self.regions.is_full() { + self.composer.work.spill = Some(spill); + return Err(Stop::Finish(false)); + } + + // Lay out the spilled remains. + let align = spill.align(); + let (frame, spill) = spill.layout(self.composer.engine, self.regions)?; + self.frame(frame, align, false, true)?; + + // If there's still more, save it into the `spill` and finish the + // region. + if let Some(spill) = spill { + self.composer.work.spill = Some(spill); + return Err(Stop::Finish(false)); + } + + Ok(()) + } + + /// Processes an in-flow frame, generated from a line or block. + fn frame( + &mut self, + frame: Frame, + align: Axes<FixedAlignment>, + sticky: bool, + breakable: bool, + ) -> FlowResult<()> { + if sticky { + // If the frame is sticky and we haven't remembered a preceding + // sticky element, make a checkpoint which we can restore should we + // end on this sticky element. + if self.stickable && self.sticky.is_none() { + self.sticky = Some(self.snapshot()); + } + } else if !frame.is_empty() { + // If the frame isn't sticky, we can forget a previous snapshot. + self.stickable = true; + self.sticky = None; + } + + // Handle footnotes. + self.composer + .footnotes(&self.regions, &frame, frame.height(), breakable)?; + + // Push an item for the frame. + self.regions.size.y -= frame.height(); + self.flush_tags(); + self.items.push(Item::Frame(frame, align)); + Ok(()) + } + + /// Processes an absolutely or floatingly placed child. + fn placed(&mut self, placed: &'b PlacedChild<'a>) -> FlowResult<()> { + if placed.float { + // If the element is floatingly placed, let the composer handle it. + // It might require relayout because the area available for + // distribution shrinks. We make the spacing occupied by weak + // spacing temporarily available again because it can collapse if it + // ends up at a break due to the float. + let weak_spacing = self.weak_spacing(); + self.regions.size.y += weak_spacing; + self.composer.float( + placed, + &self.regions, + self.items.iter().any(|item| matches!(item, Item::Frame(..))), + )?; + self.regions.size.y -= weak_spacing; + } else { + let frame = placed.layout(self.composer.engine, self.regions.base())?; + self.composer.footnotes(&self.regions, &frame, Abs::zero(), true)?; + self.flush_tags(); + self.items.push(Item::Placed(frame, placed)); + } + Ok(()) + } + + /// Processes a float flush. + fn flush(&mut self) -> FlowResult<()> { + // If there are still pending floats, finish the region instead of + // adding more content to it. + if !self.composer.work.floats.is_empty() { + return Err(Stop::Finish(false)); + } + Ok(()) + } + + /// Processes a column break. + fn break_(&mut self, weak: bool) -> FlowResult<()> { + // If there is a region to break into, break into it. + if (!weak || !self.items.is_empty()) + && (!self.regions.backlog.is_empty() || self.regions.last.is_some()) + { + self.composer.work.advance(); + return Err(Stop::Finish(true)); + } + Ok(()) + } + + /// Arranges the produced items into an output frame. + /// + /// This performs alignment and resolves fractional spacing and blocks. + fn finalize( + mut self, + region: Region, + init: DistributionSnapshot<'a, 'b>, + forced: bool, + ) -> FlowResult<Frame> { + if forced { + // If this is the very end of the flow, flush pending tags. + self.flush_tags(); + } else if !self.items.is_empty() && self.items.iter().all(Item::migratable) { + // Restore the initial state of all items are migratable. + self.restore(init); + } else { + // If we ended on a sticky block, but are not yet at the end of + // the flow, restore the saved checkpoint to move the sticky + // suffix to the next region. + if let Some(snapshot) = self.sticky.take() { + self.restore(snapshot) + } + } + + self.trim_spacing(); + + let mut frs = Fr::zero(); + let mut used = Size::zero(); + let mut has_fr_child = false; + + // Determine the amount of used space and the sum of fractionals. + for item in &self.items { + match item { + Item::Abs(v, _) => used.y += *v, + Item::Fr(v, child) => { + frs += *v; + has_fr_child |= child.is_some(); + } + Item::Frame(frame, _) => { + used.y += frame.height(); + used.x.set_max(frame.width()); + } + Item::Tag(_) | Item::Placed(..) => {} + } + } + + // When we have fractional spacing, occupy the remaining space with it. + let mut fr_space = Abs::zero(); + if frs.get() > 0.0 && region.size.y.is_finite() { + fr_space = region.size.y - used.y; + used.y = region.size.y; + } + + // Lay out fractionally sized blocks. + let mut fr_frames = vec![]; + if has_fr_child { + for item in &self.items { + let Item::Fr(v, Some(single)) = item else { continue }; + let length = v.share(frs, fr_space); + let pod = Region::new(Size::new(region.size.x, length), region.expand); + let frame = single.layout(self.composer.engine, pod)?; + used.x.set_max(frame.width()); + fr_frames.push(frame); + } + } + + // Also consider the width of insertions for alignment. + if !region.expand.x { + used.x.set_max(self.composer.insertion_width()); + } + + // Determine the region's size. + let size = region.expand.select(region.size, used.min(region.size)); + let free = size.y - used.y; + + let mut output = Frame::soft(size); + let mut ruler = FixedAlignment::Start; + let mut offset = Abs::zero(); + let mut fr_frames = fr_frames.into_iter(); + + // Position all items. + for item in self.items { + match item { + Item::Tag(tag) => { + let y = offset + ruler.position(free); + let pos = Point::with_y(y); + output.push(pos, FrameItem::Tag(tag.clone())); + } + Item::Abs(v, _) => { + offset += v; + } + Item::Fr(v, single) => { + let length = v.share(frs, fr_space); + if let Some(single) = single { + let frame = fr_frames.next().unwrap(); + let x = single.align.x.position(size.x - frame.width()); + let pos = Point::new(x, offset); + output.push_frame(pos, frame); + } + offset += length; + } + Item::Frame(frame, align) => { + ruler = ruler.max(align.y); + + let x = align.x.position(size.x - frame.width()); + let y = offset + ruler.position(free); + let pos = Point::new(x, y); + offset += frame.height(); + + output.push_frame(pos, frame); + } + Item::Placed(frame, placed) => { + let x = placed.align_x.position(size.x - frame.width()); + let y = match placed.align_y.unwrap_or_default() { + Some(align) => align.position(size.y - frame.height()), + _ => offset + ruler.position(free), + }; + + let pos = Point::new(x, y) + + placed.delta.zip_map(size, Rel::relative_to).to_point(); + + output.push_frame(pos, frame); + } + } + } + + Ok(output) + } + + /// Create a snapshot of the work and items. + fn snapshot(&self) -> DistributionSnapshot<'a, 'b> { + DistributionSnapshot { + work: self.composer.work.clone(), + items: self.items.len(), + } + } + + /// Restore a snapshot of the work and items. + fn restore(&mut self, snapshot: DistributionSnapshot<'a, 'b>) { + *self.composer.work = snapshot.work; + self.items.truncate(snapshot.items); + } +} diff --git a/crates/typst-layout/src/flow/mod.rs b/crates/typst-layout/src/flow/mod.rs new file mode 100644 index 00000000..7cbec59a --- /dev/null +++ b/crates/typst-layout/src/flow/mod.rs @@ -0,0 +1,381 @@ +//! Layout of content into a [`Frame`] or [`Fragment`]. + +mod block; +mod collect; +mod compose; +mod distribute; + +pub(crate) use self::block::unbreakable_pod; + +use std::collections::HashSet; +use std::num::NonZeroUsize; +use std::rc::Rc; + +use bumpalo::Bump; +use comemo::{Track, Tracked, TrackedMut}; +use ecow::EcoVec; +use typst_library::diag::{bail, At, SourceDiagnostic, SourceResult}; +use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::foundations::{Content, Packed, Resolve, StyleChain}; +use typst_library::introspection::{ + Introspector, Location, Locator, LocatorLink, SplitLocator, Tag, +}; +use typst_library::layout::{ + Abs, ColumnsElem, Dir, Em, Fragment, Frame, PageElem, PlacementScope, Region, + Regions, Rel, Size, +}; +use typst_library::model::{FootnoteElem, FootnoteEntry, LineNumberingScope, ParLine}; +use typst_library::routines::{Arenas, Pair, RealizationKind, Routines}; +use typst_library::text::TextElem; +use typst_library::World; +use typst_utils::{NonZeroExt, Numeric}; + +use self::block::{layout_multi_block, layout_single_block}; +use self::collect::{ + collect, Child, LineChild, MultiChild, MultiSpill, PlacedChild, SingleChild, +}; +use self::compose::{compose, Composer}; +use self::distribute::distribute; + +/// Lays out content into a single region, producing a single frame. +pub fn layout_frame( + engine: &mut Engine, + content: &Content, + locator: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + layout_fragment(engine, content, locator, styles, region.into()) + .map(Fragment::into_frame) +} + +/// Lays out content into multiple regions. +/// +/// When laying out into just one region, prefer [`layout_frame`]. +pub fn layout_fragment( + engine: &mut Engine, + content: &Content, + locator: Locator, + styles: StyleChain, + regions: Regions, +) -> SourceResult<Fragment> { + layout_fragment_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + content, + locator.track(), + styles, + regions, + NonZeroUsize::ONE, + Rel::zero(), + ) +} + +/// Layout the columns. +/// +/// This is different from just laying out into column-sized regions as the +/// columns can interact due to parent-scoped placed elements. +#[typst_macros::time(span = elem.span())] +pub fn layout_columns( + elem: &Packed<ColumnsElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + regions: Regions, +) -> SourceResult<Fragment> { + layout_fragment_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + &elem.body, + locator.track(), + styles, + regions, + elem.count(styles), + elem.gutter(styles), + ) +} + +/// The cached, internal implementation of [`layout_fragment`]. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn layout_fragment_impl( + routines: &Routines, + world: Tracked<dyn World + '_>, + introspector: Tracked<Introspector>, + traced: Tracked<Traced>, + sink: TrackedMut<Sink>, + route: Tracked<Route>, + content: &Content, + locator: Tracked<Locator>, + styles: StyleChain, + regions: Regions, + columns: NonZeroUsize, + column_gutter: Rel<Abs>, +) -> SourceResult<Fragment> { + if !regions.size.x.is_finite() && regions.expand.x { + bail!(content.span(), "cannot expand into infinite width"); + } + if !regions.size.y.is_finite() && regions.expand.y { + bail!(content.span(), "cannot expand into infinite height"); + } + + let link = LocatorLink::new(locator); + let mut locator = Locator::link(&link).split(); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route), + }; + + engine.route.check_layout_depth().at(content.span())?; + + let arenas = Arenas::default(); + let children = (engine.routines.realize)( + RealizationKind::Container, + &mut engine, + &mut locator, + &arenas, + content, + styles, + )?; + + layout_flow( + &mut engine, + &children, + &mut locator, + styles, + regions, + columns, + column_gutter, + false, + ) +} + +/// Lays out realized content into regions, potentially with columns. +#[allow(clippy::too_many_arguments)] +pub(crate) fn layout_flow( + engine: &mut Engine, + children: &[Pair], + locator: &mut SplitLocator, + shared: StyleChain, + mut regions: Regions, + columns: NonZeroUsize, + column_gutter: Rel<Abs>, + root: bool, +) -> SourceResult<Fragment> { + // Prepare configuration that is shared across the whole flow. + let config = Config { + root, + shared, + columns: { + let mut count = columns.get(); + if !regions.size.x.is_finite() { + count = 1; + } + + let gutter = column_gutter.relative_to(regions.base().x); + let width = (regions.size.x - gutter * (count - 1) as f64) / count as f64; + let dir = TextElem::dir_in(shared); + ColumnConfig { count, width, gutter, dir } + }, + footnote: FootnoteConfig { + separator: FootnoteEntry::separator_in(shared), + clearance: FootnoteEntry::clearance_in(shared), + gap: FootnoteEntry::gap_in(shared), + expand: regions.expand.x, + }, + line_numbers: root.then(|| LineNumberConfig { + scope: ParLine::numbering_scope_in(shared), + default_clearance: { + let width = if PageElem::flipped_in(shared) { + PageElem::height_in(shared) + } else { + PageElem::width_in(shared) + }; + (0.026 * width.unwrap_or_default()) + .clamp(Em::new(0.75).resolve(shared), Em::new(2.5).resolve(shared)) + }, + }), + }; + + // Collect the elements into pre-processed children. These are much easier + // to handle than the raw elements. + let bump = Bump::new(); + let children = collect( + engine, + &bump, + children, + locator.next(&()), + Size::new(config.columns.width, regions.full), + regions.expand.x, + )?; + + let mut work = Work::new(&children); + let mut finished = vec![]; + + // This loop runs once per region produced by the flow layout. + loop { + let frame = compose(engine, &mut work, &config, locator.next(&()), regions)?; + finished.push(frame); + + // Terminate the loop when everything is processed, though draining the + // backlog if necessary. + if work.done() && (!regions.expand.y || regions.backlog.is_empty()) { + break; + } + + regions.next(); + } + + Ok(Fragment::frames(finished)) +} + +/// The work that is left to do by flow layout. +/// +/// The lifetimes 'a and 'b are used across flow layout: +/// - 'a is that of the content coming out of realization +/// - 'b is that of the collected/prepared children +#[derive(Clone)] +struct Work<'a, 'b> { + /// Children that we haven't processed yet. This slice shrinks over time. + children: &'b [Child<'a>], + /// Leftovers from a breakable block. + spill: Option<MultiSpill<'a, 'b>>, + /// Queued floats that didn't fit in previous regions. + floats: EcoVec<&'b PlacedChild<'a>>, + /// Queued footnotes that didn't fit in previous regions. + footnotes: EcoVec<Packed<FootnoteElem>>, + /// Spilled frames of a footnote that didn't fully fit. Similar to `spill`. + footnote_spill: Option<std::vec::IntoIter<Frame>>, + /// Queued tags that will be attached to the next frame. + tags: EcoVec<&'a Tag>, + /// Identifies floats and footnotes that can be skipped if visited because + /// they were already handled and incorporated as column or page level + /// insertions. + skips: Rc<HashSet<Location>>, +} + +impl<'a, 'b> Work<'a, 'b> { + /// Create the initial work state from a list of children. + fn new(children: &'b [Child<'a>]) -> Self { + Self { + children, + spill: None, + floats: EcoVec::new(), + footnotes: EcoVec::new(), + footnote_spill: None, + tags: EcoVec::new(), + skips: Rc::new(HashSet::new()), + } + } + + /// Get the first unprocessed child, from the start of the slice. + fn head(&self) -> Option<&'b Child<'a>> { + self.children.first() + } + + /// Mark the `head()` child as processed, advancing the slice by one. + fn advance(&mut self) { + self.children = &self.children[1..]; + } + + /// Whether all work is done. This means we can terminate flow layout. + fn done(&self) -> bool { + self.children.is_empty() + && self.spill.is_none() + && self.floats.is_empty() + && self.footnote_spill.is_none() + && self.footnotes.is_empty() + } + + /// Add skipped floats and footnotes from the insertion areas to the skip + /// set. + fn extend_skips(&mut self, skips: &[Location]) { + if !skips.is_empty() { + Rc::make_mut(&mut self.skips).extend(skips.iter().copied()); + } + } +} + +/// Shared configuration for the whole flow. +struct Config<'x> { + /// Whether this is the root flow, which can host footnotes and line + /// numbers. + root: bool, + /// The styles shared by the whole flow. This is used for footnotes and line + /// numbers. + shared: StyleChain<'x>, + /// Settings for columns. + columns: ColumnConfig, + /// Settings for footnotes. + footnote: FootnoteConfig, + /// Settings for line numbers. + line_numbers: Option<LineNumberConfig>, +} + +/// Configuration of footnotes. +struct FootnoteConfig { + /// The separator between flow content and footnotes. Typically a line. + separator: Content, + /// The amount of space left above the separator. + clearance: Abs, + /// The gap between footnote entries. + gap: Abs, + /// Whether horizontal expansion is enabled for footnotes. + expand: bool, +} + +/// Configuration of columns. +struct ColumnConfig { + /// The number of columns. + count: usize, + /// The width of each column. + width: Abs, + /// The amount of space between columns. + gutter: Abs, + /// The horizontal direction in which columns progress. Defined by + /// `text.dir`. + dir: Dir, +} + +/// Configuration of line numbers. +struct LineNumberConfig { + /// Where line numbers are reset. + scope: LineNumberingScope, + /// The default clearance for `auto`. + default_clearance: Abs, +} + +/// The result type for flow layout. +/// +/// The `Err(_)` variant incorporate control flow events for finishing and +/// relayouting regions. +type FlowResult<T> = Result<T, Stop>; + +/// A control flow event during flow layout. +enum Stop { + /// Indicates that the current subregion should be finished. Can be caused + /// by a lack of space (`false`) or an explicit column break (`true`). + Finish(bool), + /// Indicates that the given scope should be relayouted. + Relayout(PlacementScope), + /// A fatal error. + Error(EcoVec<SourceDiagnostic>), +} + +impl From<EcoVec<SourceDiagnostic>> for Stop { + fn from(error: EcoVec<SourceDiagnostic>) -> Self { + Stop::Error(error) + } +} diff --git a/crates/typst-layout/src/grid/cells.rs b/crates/typst-layout/src/grid/cells.rs new file mode 100644 index 00000000..175e2183 --- /dev/null +++ b/crates/typst-layout/src/grid/cells.rs @@ -0,0 +1,1349 @@ +use std::num::NonZeroUsize; +use std::sync::Arc; + +use ecow::eco_format; +use typst_library::diag::{bail, At, Hint, HintedStrResult, HintedString, SourceResult}; +use typst_library::engine::Engine; +use typst_library::foundations::{Content, Smart, StyleChain}; +use typst_library::introspection::Locator; +use typst_library::layout::{ + Abs, Alignment, Axes, Celled, Fragment, Length, Regions, Rel, ResolvedCelled, Sides, + Sizing, +}; +use typst_library::visualize::{Paint, Stroke}; +use typst_syntax::Span; +use typst_utils::NonZeroExt; + +use super::{Footer, Header, Line, Repeatable}; + +/// Used for cell-like elements which are aware of their final properties in +/// the table, and may have property overrides. +pub trait ResolvableCell { + /// Resolves the cell's fields, given its coordinates and default grid-wide + /// fill, align, inset and stroke properties, plus the expected value of + /// the `breakable` field. + /// Returns a final Cell. + #[allow(clippy::too_many_arguments)] + fn resolve_cell<'a>( + self, + x: usize, + y: usize, + fill: &Option<Paint>, + align: Smart<Alignment>, + inset: Sides<Option<Rel<Length>>>, + stroke: Sides<Option<Option<Arc<Stroke<Abs>>>>>, + breakable: bool, + locator: Locator<'a>, + styles: StyleChain, + ) -> Cell<'a>; + + /// Returns this cell's column override. + fn x(&self, styles: StyleChain) -> Smart<usize>; + + /// Returns this cell's row override. + fn y(&self, styles: StyleChain) -> Smart<usize>; + + /// The amount of columns spanned by this cell. + fn colspan(&self, styles: StyleChain) -> NonZeroUsize; + + /// The amount of rows spanned by this cell. + fn rowspan(&self, styles: StyleChain) -> NonZeroUsize; + + /// The cell's span, for errors. + fn span(&self) -> Span; +} + +/// A grid item, possibly affected by automatic cell positioning. Can be either +/// a line or a cell. +pub enum ResolvableGridItem<T: ResolvableCell> { + /// A horizontal line in the grid. + HLine { + /// The row above which the horizontal line is drawn. + y: Smart<usize>, + start: usize, + end: Option<NonZeroUsize>, + stroke: Option<Arc<Stroke<Abs>>>, + /// The span of the corresponding line element. + span: Span, + /// The line's position. "before" here means on top of row `y`, while + /// "after" means below it. + position: LinePosition, + }, + /// A vertical line in the grid. + VLine { + /// The column before which the vertical line is drawn. + x: Smart<usize>, + start: usize, + end: Option<NonZeroUsize>, + stroke: Option<Arc<Stroke<Abs>>>, + /// The span of the corresponding line element. + span: Span, + /// The line's position. "before" here means to the left of column `x`, + /// while "after" means to its right (both considering LTR). + position: LinePosition, + }, + /// A cell in the grid. + Cell(T), +} + +/// Represents a cell in CellGrid, to be laid out by GridLayouter. +pub struct Cell<'a> { + /// The cell's body. + pub body: Content, + /// The cell's locator. + pub locator: Locator<'a>, + /// The cell's fill. + pub fill: Option<Paint>, + /// The amount of columns spanned by the cell. + pub colspan: NonZeroUsize, + /// The amount of rows spanned by the cell. + pub rowspan: NonZeroUsize, + /// The cell's stroke. + /// + /// We use an Arc to avoid unnecessary space usage when all sides are the + /// same, or when the strokes come from a common source. + pub stroke: Sides<Option<Arc<Stroke<Abs>>>>, + /// Which stroke sides were explicitly overridden by the cell, over the + /// grid's global stroke setting. + /// + /// This is used to define whether or not this cell's stroke sides should + /// have priority over adjacent cells' stroke sides, if those don't + /// override their own stroke properties (and thus have less priority when + /// defining with which stroke to draw grid lines around this cell). + pub stroke_overridden: Sides<bool>, + /// Whether rows spanned by this cell can be placed in different pages. + /// By default, a cell spanning only fixed-size rows is unbreakable, while + /// a cell spanning at least one `auto`-sized row is breakable. + pub breakable: bool, +} + +impl<'a> Cell<'a> { + /// Create a simple cell given its body and its locator. + pub fn new(body: Content, locator: Locator<'a>) -> Self { + Self { + body, + locator, + fill: None, + colspan: NonZeroUsize::ONE, + rowspan: NonZeroUsize::ONE, + stroke: Sides::splat(None), + stroke_overridden: Sides::splat(false), + breakable: true, + } + } + + /// Layout the cell into the given regions. + /// + /// The `disambiguator` indicates which instance of this cell this should be + /// layouted as. For normal cells, it is always `0`, but for headers and + /// footers, it indicates the index of the header/footer among all. See the + /// [`Locator`] docs for more details on the concepts behind this. + pub fn layout( + &self, + engine: &mut Engine, + disambiguator: usize, + styles: StyleChain, + regions: Regions, + ) -> SourceResult<Fragment> { + let mut locator = self.locator.relayout(); + if disambiguator > 0 { + locator = locator.split().next_inner(disambiguator as u128); + } + crate::layout_fragment(engine, &self.body, locator, styles, regions) + } +} + +/// Indicates whether the line should be drawn before or after the track with +/// its index. This is mostly only relevant when gutter is used, since, then, +/// the position after a track is not the same as before the next +/// non-gutter track. +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum LinePosition { + /// The line should be drawn before its track (e.g. hline on top of a row). + Before, + /// The line should be drawn after its track (e.g. hline below a row). + After, +} + +/// A grid entry. +pub enum Entry<'a> { + /// An entry which holds a cell. + Cell(Cell<'a>), + /// An entry which is merged with another cell. + Merged { + /// The index of the cell this entry is merged with. + parent: usize, + }, +} + +impl<'a> Entry<'a> { + /// Obtains the cell inside this entry, if this is not a merged cell. + fn as_cell(&self) -> Option<&Cell<'a>> { + match self { + Self::Cell(cell) => Some(cell), + Self::Merged { .. } => None, + } + } +} + +/// Any grid child, which can be either a header or an item. +pub enum ResolvableGridChild<T: ResolvableCell, I> { + Header { repeat: bool, span: Span, items: I }, + Footer { repeat: bool, span: Span, items: I }, + Item(ResolvableGridItem<T>), +} + +/// A grid of cells, including the columns, rows, and cell data. +pub struct CellGrid<'a> { + /// The grid cells. + pub entries: Vec<Entry<'a>>, + /// The column tracks including gutter tracks. + pub cols: Vec<Sizing>, + /// The row tracks including gutter tracks. + pub rows: Vec<Sizing>, + /// The vertical lines before each column, or on the end border. + /// Gutter columns are not included. + /// Contains up to 'cols_without_gutter.len() + 1' vectors of lines. + pub vlines: Vec<Vec<Line>>, + /// The horizontal lines on top of each row, or on the bottom border. + /// Gutter rows are not included. + /// Contains up to 'rows_without_gutter.len() + 1' vectors of lines. + pub hlines: Vec<Vec<Line>>, + /// The repeatable header of this grid. + pub header: Option<Repeatable<Header>>, + /// The repeatable footer of this grid. + pub footer: Option<Repeatable<Footer>>, + /// Whether this grid has gutters. + pub has_gutter: bool, +} + +impl<'a> CellGrid<'a> { + /// Generates the cell grid, given the tracks and cells. + pub fn new( + tracks: Axes<&[Sizing]>, + gutter: Axes<&[Sizing]>, + cells: impl IntoIterator<Item = Cell<'a>>, + ) -> Self { + let entries = cells.into_iter().map(Entry::Cell).collect(); + Self::new_internal(tracks, gutter, vec![], vec![], None, None, entries) + } + + /// Resolves and positions all cells in the grid before creating it. + /// Allows them to keep track of their final properties and positions + /// and adjust their fields accordingly. + /// Cells must implement Clone as they will be owned. Additionally, they + /// must implement Default in order to fill positions in the grid which + /// weren't explicitly specified by the user with empty cells. + #[allow(clippy::too_many_arguments)] + pub fn resolve<T, C, I>( + tracks: Axes<&[Sizing]>, + gutter: Axes<&[Sizing]>, + locator: Locator<'a>, + children: C, + fill: &Celled<Option<Paint>>, + align: &Celled<Smart<Alignment>>, + inset: &Celled<Sides<Option<Rel<Length>>>>, + stroke: &ResolvedCelled<Sides<Option<Option<Arc<Stroke>>>>>, + engine: &mut Engine, + styles: StyleChain, + span: Span, + ) -> SourceResult<Self> + where + T: ResolvableCell + Default, + I: Iterator<Item = ResolvableGridItem<T>>, + C: IntoIterator<Item = ResolvableGridChild<T, I>>, + C::IntoIter: ExactSizeIterator, + { + let mut locator = locator.split(); + + // Number of content columns: Always at least one. + let c = tracks.x.len().max(1); + + // Lists of lines. + // Horizontal lines are only pushed later to be able to check for row + // validity, since the amount of rows isn't known until all items were + // analyzed in the for loop below. + // We keep their spans so we can report errors later. + // The additional boolean indicates whether the hline had an automatic + // 'y' index, and is used to change the index of hlines at the top of a + // header or footer. + let mut pending_hlines: Vec<(Span, Line, bool)> = vec![]; + + // For consistency, only push vertical lines later as well. + let mut pending_vlines: Vec<(Span, Line)> = vec![]; + let has_gutter = gutter.any(|tracks| !tracks.is_empty()); + + let mut header: Option<Header> = None; + let mut repeat_header = false; + + // Stores where the footer is supposed to end, its span, and the + // actual footer structure. + let mut footer: Option<(usize, Span, Footer)> = None; + let mut repeat_footer = false; + + // Resolves the breakability of a cell. Cells that span at least one + // auto-sized row or gutter are considered breakable. + let resolve_breakable = |y, rowspan| { + let auto = Sizing::Auto; + let zero = Sizing::Rel(Rel::zero()); + tracks + .y + .iter() + .chain(std::iter::repeat(tracks.y.last().unwrap_or(&auto))) + .skip(y) + .take(rowspan) + .any(|row| row == &Sizing::Auto) + || gutter + .y + .iter() + .chain(std::iter::repeat(gutter.y.last().unwrap_or(&zero))) + .skip(y) + .take(rowspan - 1) + .any(|row_gutter| row_gutter == &Sizing::Auto) + }; + + // We can't just use the cell's index in the 'cells' vector to + // determine its automatic position, since cells could have arbitrary + // positions, so the position of a cell in 'cells' can differ from its + // final position in 'resolved_cells' (see below). + // Therefore, we use a counter, 'auto_index', to determine the position + // of the next cell with (x: auto, y: auto). It is only stepped when + // a cell with (x: auto, y: auto), usually the vast majority, is found. + let mut auto_index: usize = 0; + + // We have to rebuild the grid to account for arbitrary positions. + // Create at least 'children.len()' positions, since there could be at + // least 'children.len()' cells (if no explicit lines were specified), + // even though some of them might be placed in arbitrary positions and + // thus cause the grid to expand. + // Additionally, make sure we allocate up to the next multiple of 'c', + // since each row will have 'c' cells, even if the last few cells + // weren't explicitly specified by the user. + // We apply '% c' twice so that the amount of cells potentially missing + // is zero when 'children.len()' is already a multiple of 'c' (thus + // 'children.len() % c' would be zero). + let children = children.into_iter(); + let Some(child_count) = children.len().checked_add((c - children.len() % c) % c) + else { + bail!(span, "too many cells or lines were given") + }; + let mut resolved_cells: Vec<Option<Entry>> = Vec::with_capacity(child_count); + for child in children { + let mut is_header = false; + let mut is_footer = false; + let mut child_start = usize::MAX; + let mut child_end = 0; + let mut child_span = Span::detached(); + let mut start_new_row = false; + let mut first_index_of_top_hlines = usize::MAX; + let mut first_index_of_non_top_hlines = usize::MAX; + + let (header_footer_items, simple_item) = match child { + ResolvableGridChild::Header { repeat, span, items, .. } => { + if header.is_some() { + bail!(span, "cannot have more than one header"); + } + + is_header = true; + child_span = span; + repeat_header = repeat; + + // If any cell in the header is automatically positioned, + // have it skip to the next row. This is to avoid having a + // header after a partially filled row just add cells to + // that row instead of starting a new one. + // FIXME: Revise this approach when headers can start from + // arbitrary rows. + start_new_row = true; + + // Any hlines at the top of the header will start at this + // index. + first_index_of_top_hlines = pending_hlines.len(); + + (Some(items), None) + } + ResolvableGridChild::Footer { repeat, span, items, .. } => { + if footer.is_some() { + bail!(span, "cannot have more than one footer"); + } + + is_footer = true; + child_span = span; + repeat_footer = repeat; + + // If any cell in the footer is automatically positioned, + // have it skip to the next row. This is to avoid having a + // footer after a partially filled row just add cells to + // that row instead of starting a new one. + start_new_row = true; + + // Any hlines at the top of the footer will start at this + // index. + first_index_of_top_hlines = pending_hlines.len(); + + (Some(items), None) + } + ResolvableGridChild::Item(item) => (None, Some(item)), + }; + + let items = header_footer_items + .into_iter() + .flatten() + .chain(simple_item.into_iter()); + for item in items { + let cell = match item { + ResolvableGridItem::HLine { + y, + start, + end, + stroke, + span, + position, + } => { + let has_auto_y = y.is_auto(); + let y = y.unwrap_or_else(|| { + // Avoid placing the hline inside consecutive + // rowspans occupying all columns, as it'd just + // disappear, at least when there's no column + // gutter. + skip_auto_index_through_fully_merged_rows( + &resolved_cells, + &mut auto_index, + c, + ); + + // When no 'y' is specified for the hline, we place + // it under the latest automatically positioned + // cell. + // The current value of the auto index is always + // the index of the latest automatically positioned + // cell placed plus one (that's what we do in + // 'resolve_cell_position'), so we subtract 1 to + // get that cell's index, and place the hline below + // its row. The exception is when the auto_index is + // 0, meaning no automatically positioned cell was + // placed yet. In that case, we place the hline at + // the top of the table. + // + // Exceptionally, the hline will be placed before + // the minimum auto index if the current auto index + // from previous iterations is smaller than the + // minimum it should have for the current grid + // child. Effectively, this means that a hline at + // the start of a header will always appear above + // that header's first row. Similarly for footers. + auto_index + .checked_sub(1) + .map_or(0, |last_auto_index| last_auto_index / c + 1) + }); + if end.is_some_and(|end| end.get() < start) { + bail!(span, "line cannot end before it starts"); + } + let line = Line { index: y, start, end, stroke, position }; + + // Since the amount of rows is dynamic, delay placing + // hlines until after all cells were placed so we can + // properly verify if they are valid. Note that we + // can't place hlines even if we already know they + // would be in a valid row, since it's possible that we + // pushed pending hlines in the same row as this one in + // previous iterations, and we need to ensure that + // hlines from previous iterations are pushed to the + // final vector of hlines first - the order of hlines + // must be kept, as this matters when determining which + // one "wins" in case of conflict. Pushing the current + // hline before we push pending hlines later would + // change their order! + pending_hlines.push((span, line, has_auto_y)); + continue; + } + ResolvableGridItem::VLine { + x, + start, + end, + stroke, + span, + position, + } => { + let x = x.unwrap_or_else(|| { + // When no 'x' is specified for the vline, we place + // it after the latest automatically positioned + // cell. + // The current value of the auto index is always + // the index of the latest automatically positioned + // cell placed plus one (that's what we do in + // 'resolve_cell_position'), so we subtract 1 to + // get that cell's index, and place the vline after + // its column. The exception is when the auto_index + // is 0, meaning no automatically positioned cell + // was placed yet. In that case, we place the vline + // to the left of the table. + // + // Exceptionally, a vline is also placed to the + // left of the table if we should start a new row + // for the next automatically positioned cell. + // For example, this means that a vline at + // the beginning of a header will be placed to its + // left rather than after the previous + // automatically positioned cell. Same for footers. + auto_index + .checked_sub(1) + .filter(|_| !start_new_row) + .map_or(0, |last_auto_index| last_auto_index % c + 1) + }); + if end.is_some_and(|end| end.get() < start) { + bail!(span, "line cannot end before it starts"); + } + let line = Line { index: x, start, end, stroke, position }; + + // For consistency with hlines, we only push vlines to + // the final vector of vlines after processing every + // cell. + pending_vlines.push((span, line)); + continue; + } + ResolvableGridItem::Cell(cell) => cell, + }; + let cell_span = cell.span(); + let colspan = cell.colspan(styles).get(); + let rowspan = cell.rowspan(styles).get(); + // Let's calculate the cell's final position based on its + // requested position. + let resolved_index = { + let cell_x = cell.x(styles); + let cell_y = cell.y(styles); + resolve_cell_position( + cell_x, + cell_y, + colspan, + rowspan, + &resolved_cells, + &mut auto_index, + &mut start_new_row, + c, + ) + .at(cell_span)? + }; + let x = resolved_index % c; + let y = resolved_index / c; + + if colspan > c - x { + bail!( + cell_span, + "cell's colspan would cause it to exceed the available column(s)"; + hint: "try placing the cell in another position or reducing its colspan" + ) + } + + let Some(largest_index) = c + .checked_mul(rowspan - 1) + .and_then(|full_rowspan_offset| { + resolved_index.checked_add(full_rowspan_offset) + }) + .and_then(|last_row_pos| last_row_pos.checked_add(colspan - 1)) + else { + bail!( + cell_span, + "cell would span an exceedingly large position"; + hint: "try reducing the cell's rowspan or colspan" + ) + }; + + // Let's resolve the cell so it can determine its own fields + // based on its final position. + let cell = cell.resolve_cell( + x, + y, + &fill.resolve(engine, styles, x, y)?, + align.resolve(engine, styles, x, y)?, + inset.resolve(engine, styles, x, y)?, + stroke.resolve(engine, styles, x, y)?, + resolve_breakable(y, rowspan), + locator.next(&cell_span), + styles, + ); + + if largest_index >= resolved_cells.len() { + // Ensure the length of the vector of resolved cells is + // always a multiple of 'c' by pushing full rows every + // time. Here, we add enough absent positions (later + // converted to empty cells) to ensure the last row in the + // new vector length is completely filled. This is + // necessary so that those positions, even if not + // explicitly used at the end, are eventually susceptible + // to show rules and receive grid styling, as they will be + // resolved as empty cells in a second loop below. + let Some(new_len) = largest_index + .checked_add(1) + .and_then(|new_len| new_len.checked_add((c - new_len % c) % c)) + else { + bail!(cell_span, "cell position too large") + }; + + // Here, the cell needs to be placed in a position which + // doesn't exist yet in the grid (out of bounds). We will + // add enough absent positions for this to be possible. + // They must be absent as no cells actually occupy them + // (they can be overridden later); however, if no cells + // occupy them as we finish building the grid, then such + // positions will be replaced by empty cells. + resolved_cells.resize_with(new_len, || None); + } + + // The vector is large enough to contain the cell, so we can + // just index it directly to access the position it will be + // placed in. However, we still need to ensure we won't try to + // place a cell where there already is one. + let slot = &mut resolved_cells[resolved_index]; + if slot.is_some() { + bail!( + cell_span, + "attempted to place a second cell at column {x}, row {y}"; + hint: "try specifying your cells in a different order" + ); + } + + *slot = Some(Entry::Cell(cell)); + + // Now, if the cell spans more than one row or column, we fill + // the spanned positions in the grid with Entry::Merged + // pointing to the original cell as its parent. + for rowspan_offset in 0..rowspan { + let spanned_y = y + rowspan_offset; + let first_row_index = resolved_index + c * rowspan_offset; + for (colspan_offset, slot) in resolved_cells[first_row_index..] + [..colspan] + .iter_mut() + .enumerate() + { + let spanned_x = x + colspan_offset; + if spanned_x == x && spanned_y == y { + // This is the parent cell. + continue; + } + if slot.is_some() { + bail!( + cell_span, + "cell would span a previously placed cell at column {spanned_x}, row {spanned_y}"; + hint: "try specifying your cells in a different order or reducing the cell's rowspan or colspan" + ) + } + *slot = Some(Entry::Merged { parent: resolved_index }); + } + } + + if is_header || is_footer { + // Ensure each cell in a header or footer is fully + // contained within it. + child_start = child_start.min(y); + child_end = child_end.max(y + rowspan); + + if start_new_row && child_start <= auto_index.div_ceil(c) { + // No need to start a new row as we already include + // the row of the next automatically positioned cell in + // the header or footer. + start_new_row = false; + } + + if !start_new_row { + // From now on, upcoming hlines won't be at the top of + // the child, as the first automatically positioned + // cell was placed. + first_index_of_non_top_hlines = + first_index_of_non_top_hlines.min(pending_hlines.len()); + } + } + } + + if (is_header || is_footer) && child_start == usize::MAX { + // Empty header/footer: consider the header/footer to be + // at the next empty row after the latest auto index. + auto_index = find_next_empty_row(&resolved_cells, auto_index, c); + child_start = auto_index.div_ceil(c); + child_end = child_start + 1; + + if resolved_cells.len() <= c * child_start { + // Ensure the automatically chosen row actually exists. + resolved_cells.resize_with(c * (child_start + 1), || None); + } + } + + if is_header { + if child_start != 0 { + bail!( + child_span, + "header must start at the first row"; + hint: "remove any rows before the header" + ); + } + + header = Some(Header { + // Later on, we have to correct this number in case there + // is gutter. But only once all cells have been analyzed + // and the header has fully expanded in the fixup loop + // below. + end: child_end, + }); + } + + if is_footer { + // Only check if the footer is at the end later, once we know + // the final amount of rows. + footer = Some(( + child_end, + child_span, + Footer { + // Later on, we have to correct this number in case there + // is gutter, but only once all cells have been analyzed + // and the header's and footer's exact boundaries are + // known. That is because the gutter row immediately + // before the footer might not be included as part of + // the footer if it is contained within the header. + start: child_start, + }, + )); + } + + if is_header || is_footer { + let amount_hlines = pending_hlines.len(); + for (_, top_hline, has_auto_y) in pending_hlines + .get_mut( + first_index_of_top_hlines + ..first_index_of_non_top_hlines.min(amount_hlines), + ) + .unwrap_or(&mut []) + { + if *has_auto_y { + // Move this hline to the top of the child, as it was + // placed before the first automatically positioned cell + // and had an automatic index. + top_hline.index = child_start; + } + } + + // Next automatically positioned cell goes under this header. + // FIXME: Consider only doing this if the header has any fully + // automatically positioned cells. Otherwise, + // `resolve_cell_position` should be smart enough to skip + // upcoming headers. + // Additionally, consider that cells with just an 'x' override + // could end up going too far back and making previous + // non-header rows into header rows (maybe they should be + // placed at the first row that is fully empty or something). + // Nothing we can do when both 'x' and 'y' were overridden, of + // course. + // None of the above are concerns for now, as headers must + // start at the first row. + auto_index = auto_index.max(c * child_end); + } + } + + // If the user specified cells occupying less rows than the given rows, + // we shall expand the grid so that it has at least the given amount of + // rows. + let Some(expected_total_cells) = c.checked_mul(tracks.y.len()) else { + bail!(span, "too many rows were specified"); + }; + let missing_cells = expected_total_cells.saturating_sub(resolved_cells.len()); + + // Fixup phase (final step in cell grid generation): + // 1. Replace absent entries by resolved empty cells, and produce a + // vector of 'Entry' from 'Option<Entry>'. + // 2. Add enough empty cells to the end of the grid such that it has at + // least the given amount of rows. + // 3. If any cells were added to the header's rows after the header's + // creation, ensure the header expands enough to accommodate them + // across all of their spanned rows. Same for the footer. + // 4. If any cells before the footer try to span it, error. + let resolved_cells = resolved_cells + .into_iter() + .chain(std::iter::repeat_with(|| None).take(missing_cells)) + .enumerate() + .map(|(i, cell)| { + if let Some(cell) = cell { + if let Some(parent_cell) = cell.as_cell() { + if let Some(header) = &mut header + { + let y = i / c; + if y < header.end { + // Ensure the header expands enough such that + // all cells inside it, even those added later, + // are fully contained within the header. + // FIXME: check if start < y < end when start can + // be != 0. + // FIXME: when start can be != 0, decide what + // happens when a cell after the header placed + // above it tries to span the header (either + // error or expand upwards). + header.end = header.end.max(y + parent_cell.rowspan.get()); + } + } + + if let Some((end, footer_span, footer)) = &mut footer { + let x = i % c; + let y = i / c; + let cell_end = y + parent_cell.rowspan.get(); + if y < footer.start && cell_end > footer.start { + // Don't allow a cell before the footer to span + // it. Surely, we could move the footer to + // start at where this cell starts, so this is + // more of a design choice, as it's unlikely + // for the user to intentionally include a cell + // before the footer spanning it but not + // being repeated with it. + bail!( + *footer_span, + "footer would conflict with a cell placed before it at column {x} row {y}"; + hint: "try reducing that cell's rowspan or moving the footer" + ); + } + if y >= footer.start && y < *end { + // Expand the footer to include all rows + // spanned by this cell, as it is inside the + // footer. + *end = (*end).max(cell_end); + } + } + } + + Ok(cell) + } else { + let x = i % c; + let y = i / c; + + // Ensure all absent entries are affected by show rules and + // grid styling by turning them into resolved empty cells. + let new_cell = T::default().resolve_cell( + x, + y, + &fill.resolve(engine, styles, x, y)?, + align.resolve(engine, styles, x, y)?, + inset.resolve(engine, styles, x, y)?, + stroke.resolve(engine, styles, x, y)?, + resolve_breakable(y, 1), + locator.next(&()), + styles, + ); + Ok(Entry::Cell(new_cell)) + } + }) + .collect::<SourceResult<Vec<Entry>>>()?; + + // Populate the final lists of lines. + // For each line type (horizontal or vertical), we keep a vector for + // every group of lines with the same index. + let mut vlines: Vec<Vec<Line>> = vec![]; + let mut hlines: Vec<Vec<Line>> = vec![]; + let row_amount = resolved_cells.len().div_ceil(c); + + for (line_span, line, _) in pending_hlines { + let y = line.index; + if y > row_amount { + bail!(line_span, "cannot place horizontal line at invalid row {y}"); + } + if y == row_amount && line.position == LinePosition::After { + bail!( + line_span, + "cannot place horizontal line at the 'bottom' position of the bottom border (y = {y})"; + hint: "set the line's position to 'top' or place it at a smaller 'y' index" + ); + } + let line = if line.position == LinePosition::After + && (!has_gutter || y + 1 == row_amount) + { + // Just place the line on top of the next row if + // there's no gutter and the line should be placed + // after the one with given index. + // + // Note that placing after the last row is also the same as + // just placing on the grid's bottom border, even with + // gutter. + Line { + index: y + 1, + position: LinePosition::Before, + ..line + } + } else { + line + }; + let y = line.index; + + if hlines.len() <= y { + hlines.resize_with(y + 1, Vec::new); + } + hlines[y].push(line); + } + + for (line_span, line) in pending_vlines { + let x = line.index; + if x > c { + bail!(line_span, "cannot place vertical line at invalid column {x}"); + } + if x == c && line.position == LinePosition::After { + bail!( + line_span, + "cannot place vertical line at the 'end' position of the end border (x = {c})"; + hint: "set the line's position to 'start' or place it at a smaller 'x' index" + ); + } + let line = + if line.position == LinePosition::After && (!has_gutter || x + 1 == c) { + // Just place the line before the next column if + // there's no gutter and the line should be placed + // after the one with given index. + // + // Note that placing after the last column is also the + // same as just placing on the grid's end border, even + // with gutter. + Line { + index: x + 1, + position: LinePosition::Before, + ..line + } + } else { + line + }; + let x = line.index; + + if vlines.len() <= x { + vlines.resize_with(x + 1, Vec::new); + } + vlines[x].push(line); + } + + let header = header + .map(|mut header| { + // Repeat the gutter below a header (hence why we don't + // subtract 1 from the gutter case). + // Don't do this if there are no rows under the header. + if has_gutter { + // - 'header.end' is always 'last y + 1'. The header stops + // before that row. + // - Therefore, '2 * header.end' will be 2 * (last y + 1), + // which is the adjusted index of the row before which the + // header stops, meaning it will still stop right before it + // even with gutter thanks to the multiplication below. + // - This means that it will span all rows up to + // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates + // to the index of the gutter row right below the header, + // which is what we want (that gutter spacing should be + // repeated across pages to maintain uniformity). + header.end *= 2; + + // If the header occupies the entire grid, ensure we don't + // include an extra gutter row when it doesn't exist, since + // the last row of the header is at the very bottom, + // therefore '2 * last y + 1' is not a valid index. + let row_amount = (2 * row_amount).saturating_sub(1); + header.end = header.end.min(row_amount); + } + header + }) + .map(|header| { + if repeat_header { + Repeatable::Repeated(header) + } else { + Repeatable::NotRepeated(header) + } + }); + + let footer = footer + .map(|(footer_end, footer_span, mut footer)| { + if footer_end != row_amount { + bail!(footer_span, "footer must end at the last row"); + } + + let header_end = + header.as_ref().map(Repeatable::unwrap).map(|header| header.end); + + if has_gutter { + // Convert the footer's start index to post-gutter coordinates. + footer.start *= 2; + + // Include the gutter right before the footer, unless there is + // none, or the gutter is already included in the header (no + // rows between the header and the footer). + if header_end.map_or(true, |header_end| header_end != footer.start) { + footer.start = footer.start.saturating_sub(1); + } + } + + if header_end.is_some_and(|header_end| header_end > footer.start) { + bail!(footer_span, "header and footer must not have common rows"); + } + + Ok(footer) + }) + .transpose()? + .map(|footer| { + if repeat_footer { + Repeatable::Repeated(footer) + } else { + Repeatable::NotRepeated(footer) + } + }); + + Ok(Self::new_internal( + tracks, + gutter, + vlines, + hlines, + header, + footer, + resolved_cells, + )) + } + + /// Generates the cell grid, given the tracks and resolved entries. + pub fn new_internal( + tracks: Axes<&[Sizing]>, + gutter: Axes<&[Sizing]>, + vlines: Vec<Vec<Line>>, + hlines: Vec<Vec<Line>>, + header: Option<Repeatable<Header>>, + footer: Option<Repeatable<Footer>>, + entries: Vec<Entry<'a>>, + ) -> Self { + let mut cols = vec![]; + let mut rows = vec![]; + + // Number of content columns: Always at least one. + let c = tracks.x.len().max(1); + + // Number of content rows: At least as many as given, but also at least + // as many as needed to place each item. + let r = { + let len = entries.len(); + let given = tracks.y.len(); + let needed = len / c + (len % c).clamp(0, 1); + given.max(needed) + }; + + let has_gutter = gutter.any(|tracks| !tracks.is_empty()); + let auto = Sizing::Auto; + let zero = Sizing::Rel(Rel::zero()); + let get_or = |tracks: &[_], idx, default| { + tracks.get(idx).or(tracks.last()).copied().unwrap_or(default) + }; + + // Collect content and gutter columns. + for x in 0..c { + cols.push(get_or(tracks.x, x, auto)); + if has_gutter { + cols.push(get_or(gutter.x, x, zero)); + } + } + + // Collect content and gutter rows. + for y in 0..r { + rows.push(get_or(tracks.y, y, auto)); + if has_gutter { + rows.push(get_or(gutter.y, y, zero)); + } + } + + // Remove superfluous gutter tracks. + if has_gutter { + cols.pop(); + rows.pop(); + } + + Self { + cols, + rows, + entries, + vlines, + hlines, + header, + footer, + has_gutter, + } + } + + /// Get the grid entry in column `x` and row `y`. + /// + /// Returns `None` if it's a gutter cell. + #[track_caller] + pub fn entry(&self, x: usize, y: usize) -> Option<&Entry<'a>> { + assert!(x < self.cols.len()); + assert!(y < self.rows.len()); + + if self.has_gutter { + // Even columns and rows are children, odd ones are gutter. + if x % 2 == 0 && y % 2 == 0 { + let c = 1 + self.cols.len() / 2; + self.entries.get((y / 2) * c + x / 2) + } else { + None + } + } else { + let c = self.cols.len(); + self.entries.get(y * c + x) + } + } + + /// Get the content of the cell in column `x` and row `y`. + /// + /// Returns `None` if it's a gutter cell or merged position. + #[track_caller] + pub fn cell(&self, x: usize, y: usize) -> Option<&Cell<'a>> { + self.entry(x, y).and_then(Entry::as_cell) + } + + /// Returns the position of the parent cell of the grid entry at the given + /// position. It is guaranteed to have a non-gutter, non-merged cell at + /// the returned position, due to how the grid is built. + /// - If the entry at the given position is a cell, returns the given + /// position. + /// - If it is a merged cell, returns the parent cell's position. + /// - If it is a gutter cell, returns None. + #[track_caller] + pub fn parent_cell_position(&self, x: usize, y: usize) -> Option<Axes<usize>> { + self.entry(x, y).map(|entry| match entry { + Entry::Cell(_) => Axes::new(x, y), + Entry::Merged { parent } => { + let c = if self.has_gutter { + 1 + self.cols.len() / 2 + } else { + self.cols.len() + }; + let factor = if self.has_gutter { 2 } else { 1 }; + Axes::new(factor * (*parent % c), factor * (*parent / c)) + } + }) + } + + /// Returns the position of the actual parent cell of a merged position, + /// even if the given position is gutter, in which case we return the + /// parent of the nearest adjacent content cell which could possibly span + /// the given gutter position. If the given position is not a gutter cell, + /// then this function will return the same as `parent_cell_position` would. + /// If the given position is a gutter cell, but no cell spans it, returns + /// `None`. + /// + /// This is useful for lines. A line needs to check if a cell next to it + /// has a stroke override - even at a gutter position there could be a + /// stroke override, since a cell could be merged with two cells at both + /// ends of the gutter cell (e.g. to its left and to its right), and thus + /// that cell would impose a stroke under the gutter. This function allows + /// getting the position of that cell (which spans the given gutter + /// position, if it is gutter), if it exists; otherwise returns None (it's + /// gutter and no cell spans it). + #[track_caller] + pub fn effective_parent_cell_position( + &self, + x: usize, + y: usize, + ) -> Option<Axes<usize>> { + if self.has_gutter { + // If (x, y) is a gutter cell, we skip it (skip a gutter column and + // row) to the nearest adjacent content cell, in the direction + // which merged cells grow toward (increasing x and increasing y), + // such that we can verify if that adjacent cell is merged with the + // gutter cell by checking if its parent would come before (x, y). + // Otherwise, no cell is merged with this gutter cell, and we + // return None. + self.parent_cell_position(x + x % 2, y + y % 2) + .filter(|&parent| parent.x <= x && parent.y <= y) + } else { + self.parent_cell_position(x, y) + } + } + + /// Checks if the track with the given index is gutter. + /// Does not check if the index is a valid track. + #[inline] + pub fn is_gutter_track(&self, index: usize) -> bool { + self.has_gutter && index % 2 == 1 + } + + /// Returns the effective colspan of a cell, considering the gutters it + /// might span if the grid has gutters. + #[inline] + pub fn effective_colspan_of_cell(&self, cell: &Cell) -> usize { + if self.has_gutter { + 2 * cell.colspan.get() - 1 + } else { + cell.colspan.get() + } + } + + /// Returns the effective rowspan of a cell, considering the gutters it + /// might span if the grid has gutters. + #[inline] + pub fn effective_rowspan_of_cell(&self, cell: &Cell) -> usize { + if self.has_gutter { + 2 * cell.rowspan.get() - 1 + } else { + cell.rowspan.get() + } + } +} + +/// Given a cell's requested x and y, the vector with the resolved cell +/// positions, the `auto_index` counter (determines the position of the next +/// `(auto, auto)` cell) and the amount of columns in the grid, returns the +/// final index of this cell in the vector of resolved cells. +/// +/// The `start_new_row` parameter is used to ensure that, if this cell is +/// fully automatically positioned, it should start a new, empty row. This is +/// useful for headers and footers, which must start at their own rows, without +/// interference from previous cells. +#[allow(clippy::too_many_arguments)] +fn resolve_cell_position( + cell_x: Smart<usize>, + cell_y: Smart<usize>, + colspan: usize, + rowspan: usize, + resolved_cells: &[Option<Entry>], + auto_index: &mut usize, + start_new_row: &mut bool, + columns: usize, +) -> HintedStrResult<usize> { + // Translates a (x, y) position to the equivalent index in the final cell vector. + // Errors if the position would be too large. + let cell_index = |x, y: usize| { + y.checked_mul(columns) + .and_then(|row_index| row_index.checked_add(x)) + .ok_or_else(|| HintedString::from(eco_format!("cell position too large"))) + }; + match (cell_x, cell_y) { + // Fully automatic cell positioning. The cell did not + // request a coordinate. + (Smart::Auto, Smart::Auto) => { + // Let's find the first available position starting from the + // automatic position counter, searching in row-major order. + let mut resolved_index = *auto_index; + if *start_new_row { + resolved_index = + find_next_empty_row(resolved_cells, resolved_index, columns); + + // Next cell won't have to start a new row if we just did that, + // in principle. + *start_new_row = false; + } else { + while let Some(Some(_)) = resolved_cells.get(resolved_index) { + // Skip any non-absent cell positions (`Some(None)`) to + // determine where this cell will be placed. An out of + // bounds position (thus `None`) is also a valid new + // position (only requires expanding the vector). + resolved_index += 1; + } + } + + // Ensure the next cell with automatic position will be + // placed after this one (maybe not immediately after). + // + // The calculation below also affects the position of the upcoming + // automatically-positioned lines. + *auto_index = if colspan == columns { + // The cell occupies all columns, so no cells can be placed + // after it until all of its rows have been spanned. + resolved_index + colspan * rowspan + } else { + // The next cell will have to be placed at least after its + // spanned columns. + resolved_index + colspan + }; + + Ok(resolved_index) + } + // Cell has chosen at least its column. + (Smart::Custom(cell_x), cell_y) => { + if cell_x >= columns { + return Err(HintedString::from(eco_format!( + "cell could not be placed at invalid column {cell_x}" + ))); + } + if let Smart::Custom(cell_y) = cell_y { + // Cell has chosen its exact position. + cell_index(cell_x, cell_y) + } else { + // Cell has only chosen its column. + // Let's find the first row which has that column available. + let mut resolved_y = 0; + while let Some(Some(_)) = + resolved_cells.get(cell_index(cell_x, resolved_y)?) + { + // Try each row until either we reach an absent position + // (`Some(None)`) or an out of bounds position (`None`), + // in which case we'd create a new row to place this cell in. + resolved_y += 1; + } + cell_index(cell_x, resolved_y) + } + } + // Cell has only chosen its row, not its column. + (Smart::Auto, Smart::Custom(cell_y)) => { + // Let's find the first column which has that row available. + let first_row_pos = cell_index(0, cell_y)?; + let last_row_pos = first_row_pos + .checked_add(columns) + .ok_or_else(|| eco_format!("cell position too large"))?; + + (first_row_pos..last_row_pos) + .find(|possible_index| { + // Much like in the previous cases, we skip any occupied + // positions until we either reach an absent position + // (`Some(None)`) or an out of bounds position (`None`), + // in which case we can just expand the vector enough to + // place this cell. In either case, we found an available + // position. + !matches!(resolved_cells.get(*possible_index), Some(Some(_))) + }) + .ok_or_else(|| { + eco_format!( + "cell could not be placed in row {cell_y} because it was full" + ) + }) + .hint("try specifying your cells in a different order") + } + } +} + +/// Computes the index of the first cell in the next empty row in the grid, +/// starting with the given initial index. +fn find_next_empty_row( + resolved_cells: &[Option<Entry>], + initial_index: usize, + columns: usize, +) -> usize { + let mut resolved_index = initial_index.next_multiple_of(columns); + while resolved_cells + .get(resolved_index..resolved_index + columns) + .is_some_and(|row| row.iter().any(Option::is_some)) + { + // Skip non-empty rows. + resolved_index += columns; + } + + resolved_index +} + +/// Fully merged rows under the cell of latest auto index indicate rowspans +/// occupying all columns, so we skip the auto index until the shortest rowspan +/// ends, such that, in the resulting row, we will be able to place an +/// automatically positioned cell - and, in particular, hlines under it. The +/// idea is that an auto hline will be placed after the shortest such rowspan. +/// Otherwise, the hline would just be placed under the first row of those +/// rowspans and disappear (except at the presence of column gutter). +fn skip_auto_index_through_fully_merged_rows( + resolved_cells: &[Option<Entry>], + auto_index: &mut usize, + columns: usize, +) { + // If the auto index isn't currently at the start of a row, that means + // there's still at least one auto position left in the row, ignoring + // cells with manual positions, so we wouldn't have a problem in placing + // further cells or, in this case, hlines here. + if *auto_index % columns == 0 { + while resolved_cells + .get(*auto_index..*auto_index + columns) + .is_some_and(|row| { + row.iter().all(|entry| matches!(entry, Some(Entry::Merged { .. }))) + }) + { + *auto_index += columns; + } + } +} diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs new file mode 100644 index 00000000..7c94617d --- /dev/null +++ b/crates/typst-layout/src/grid/layouter.rs @@ -0,0 +1,1582 @@ +use std::fmt::Debug; + +use typst_library::diag::{bail, SourceResult}; +use typst_library::engine::Engine; +use typst_library::foundations::{Resolve, StyleChain}; +use typst_library::layout::{ + Abs, Axes, Dir, Fr, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Rel, + Size, Sizing, +}; +use typst_library::text::TextElem; +use typst_library::visualize::Geometry; +use typst_syntax::Span; +use typst_utils::{MaybeReverseIter, Numeric}; + +use super::{ + generate_line_segments, hline_stroke_at_column, vline_stroke_at_row, Cell, CellGrid, + LinePosition, LineSegment, Repeatable, Rowspan, UnbreakableRowGroup, +}; + +/// Performs grid layout. +pub struct GridLayouter<'a> { + /// The grid of cells. + pub(super) grid: &'a CellGrid<'a>, + /// The regions to layout children into. + pub(super) regions: Regions<'a>, + /// The inherited styles. + pub(super) styles: StyleChain<'a>, + /// Resolved column sizes. + pub(super) rcols: Vec<Abs>, + /// The sum of `rcols`. + pub(super) width: Abs, + /// Resolve row sizes, by region. + pub(super) rrows: Vec<Vec<RowPiece>>, + /// Rows in the current region. + pub(super) lrows: Vec<Row>, + /// The amount of unbreakable rows remaining to be laid out in the + /// current unbreakable row group. While this is positive, no region breaks + /// should occur. + pub(super) unbreakable_rows_left: usize, + /// Rowspans not yet laid out because not all of their spanned rows were + /// laid out yet. + pub(super) rowspans: Vec<Rowspan>, + /// The initial size of the current region before we started subtracting. + pub(super) initial: Size, + /// Frames for finished regions. + pub(super) finished: Vec<Frame>, + /// Whether this is an RTL grid. + pub(super) is_rtl: bool, + /// The simulated header height. + /// This field is reset in `layout_header` and properly updated by + /// `layout_auto_row` and `layout_relative_row`, and should not be read + /// before all header rows are fully laid out. It is usually fine because + /// header rows themselves are unbreakable, and unbreakable rows do not + /// need to read this field at all. + pub(super) header_height: Abs, + /// The simulated footer height for this region. + /// The simulation occurs before any rows are laid out for a region. + pub(super) footer_height: Abs, + /// The span of the grid element. + pub(super) span: Span, +} + +/// Details about a resulting row piece. +#[derive(Debug)] +pub struct RowPiece { + /// The height of the segment. + pub height: Abs, + /// The index of the row. + pub y: usize, +} + +/// Produced by initial row layout, auto and relative rows are already finished, +/// fractional rows not yet. +pub(super) enum Row { + /// Finished row frame of auto or relative row with y index. + /// The last parameter indicates whether or not this is the last region + /// where this row is laid out, and it can only be false when a row uses + /// `layout_multi_row`, which in turn is only used by breakable auto rows. + Frame(Frame, usize, bool), + /// Fractional row with y index and disambiguator. + Fr(Fr, usize, usize), +} + +impl Row { + /// Returns the `y` index of this row. + fn index(&self) -> usize { + match self { + Self::Frame(_, y, _) => *y, + Self::Fr(_, y, _) => *y, + } + } +} + +impl<'a> GridLayouter<'a> { + /// Create a new grid layouter. + /// + /// This prepares grid layout by unifying content and gutter tracks. + pub fn new( + grid: &'a CellGrid<'a>, + regions: Regions<'a>, + styles: StyleChain<'a>, + span: Span, + ) -> Self { + // We use these regions for auto row measurement. Since at that moment, + // columns are already sized, we can enable horizontal expansion. + let mut regions = regions; + regions.expand = Axes::new(true, false); + + Self { + grid, + regions, + styles, + rcols: vec![Abs::zero(); grid.cols.len()], + width: Abs::zero(), + rrows: vec![], + lrows: vec![], + unbreakable_rows_left: 0, + rowspans: vec![], + initial: regions.size, + finished: vec![], + is_rtl: TextElem::dir_in(styles) == Dir::RTL, + header_height: Abs::zero(), + footer_height: Abs::zero(), + span, + } + } + + /// Determines the columns sizes and then layouts the grid row-by-row. + pub fn layout(mut self, engine: &mut Engine) -> SourceResult<Fragment> { + self.measure_columns(engine)?; + + if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + // Ensure rows in the first region will be aware of the possible + // presence of the footer. + self.prepare_footer(footer, engine, 0)?; + if matches!(self.grid.header, None | Some(Repeatable::NotRepeated(_))) { + // No repeatable header, so we won't subtract it later. + self.regions.size.y -= self.footer_height; + } + } + + for y in 0..self.grid.rows.len() { + if let Some(Repeatable::Repeated(header)) = &self.grid.header { + if y < header.end { + if y == 0 { + self.layout_header(header, engine, 0)?; + self.regions.size.y -= self.footer_height; + } + // Skip header rows during normal layout. + continue; + } + } + + if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + if y >= footer.start { + if y == footer.start { + self.layout_footer(footer, engine, self.finished.len())?; + } + continue; + } + } + + self.layout_row(y, engine, 0)?; + } + + self.finish_region(engine, true)?; + + // Layout any missing rowspans. + // There are only two possibilities for rowspans not yet laid out + // (usually, a rowspan is laid out as soon as its last row, or any row + // after it, is laid out): + // 1. The rowspan was fully empty and only spanned fully empty auto + // rows, which were all prevented from being laid out. Those rowspans + // are ignored by 'layout_rowspan', and are not of any concern. + // + // 2. The rowspan's last row was an auto row at the last region which + // was not laid out, and no other rows were laid out after it. Those + // might still need to be laid out, so we check for them. + for rowspan in std::mem::take(&mut self.rowspans) { + self.layout_rowspan(rowspan, None, engine)?; + } + + self.render_fills_strokes() + } + + /// Layout the given row. + pub(super) fn layout_row( + &mut self, + y: usize, + engine: &mut Engine, + disambiguator: usize, + ) -> SourceResult<()> { + // Skip to next region if current one is full, but only for content + // rows, not for gutter rows, and only if we aren't laying out an + // unbreakable group of rows. + let is_content_row = !self.grid.is_gutter_track(y); + if self.unbreakable_rows_left == 0 && self.regions.is_full() && is_content_row { + self.finish_region(engine, false)?; + } + + if is_content_row { + // Gutter rows have no rowspans or possibly unbreakable cells. + self.check_for_rowspans(disambiguator, y); + self.check_for_unbreakable_rows(y, engine)?; + } + + // Don't layout gutter rows at the top of a region. + if is_content_row || !self.lrows.is_empty() { + match self.grid.rows[y] { + Sizing::Auto => self.layout_auto_row(engine, disambiguator, y)?, + Sizing::Rel(v) => { + self.layout_relative_row(engine, disambiguator, v, y)? + } + Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y, disambiguator)), + } + } + + self.unbreakable_rows_left = self.unbreakable_rows_left.saturating_sub(1); + + Ok(()) + } + + /// Add lines and backgrounds. + fn render_fills_strokes(mut self) -> SourceResult<Fragment> { + let mut finished = std::mem::take(&mut self.finished); + let frame_amount = finished.len(); + for ((frame_index, frame), rows) in + finished.iter_mut().enumerate().zip(&self.rrows) + { + if self.rcols.is_empty() || rows.is_empty() { + continue; + } + + // Render grid lines. + // We collect lines into a vector before rendering so we can sort + // them based on thickness, such that the lines with largest + // thickness are drawn on top; and also so we can prepend all of + // them at once in the frame, as calling prepend() for each line, + // and thus pushing all frame items forward each time, would result + // in quadratic complexity. + let mut lines = vec![]; + + // Which line position to look for in the list of lines for a + // track, such that placing lines with those positions will + // correspond to placing them before the given track index. + // + // If the index represents a gutter track, this means the list of + // lines will actually correspond to the list of lines in the + // previous index, so we must look for lines positioned after the + // previous index, and not before, to determine which lines should + // be placed before gutter. + // + // Note that the maximum index is always an odd number when + // there's gutter, so we must check for it to ensure we don't give + // it the same treatment as a line before a gutter track. + let expected_line_position = |index, is_max_index: bool| { + if self.grid.is_gutter_track(index) && !is_max_index { + LinePosition::After + } else { + LinePosition::Before + } + }; + + // Render vertical lines. + // Render them first so horizontal lines have priority later. + for (x, dx) in points(self.rcols.iter().copied()).enumerate() { + let dx = if self.is_rtl { self.width - dx } else { dx }; + let is_end_border = x == self.grid.cols.len(); + let expected_vline_position = expected_line_position(x, is_end_border); + + let vlines_at_column = self + .grid + .vlines + .get(if !self.grid.has_gutter { + x + } else if is_end_border { + // The end border has its own vector of lines, but + // dividing it by 2 and flooring would give us the + // vector of lines with the index of the last column. + // Add 1 so we get the border's lines. + x / 2 + 1 + } else { + // If x is a gutter column, this will round down to the + // index of the previous content column, which is + // intentional - the only lines which can appear before + // a gutter column are lines for the previous column + // marked with "LinePosition::After". Therefore, we get + // the previous column's lines. Worry not, as + // 'generate_line_segments' will correctly filter lines + // based on their LinePosition for us. + // + // If x is a content column, this will correctly return + // its index before applying gutters, so nothing + // special here (lines with "LinePosition::After" would + // then be ignored for this column, as we are drawing + // lines before it, not after). + x / 2 + }) + .into_iter() + .flatten() + .filter(|line| line.position == expected_vline_position); + + let tracks = rows.iter().map(|row| (row.y, row.height)); + + // Determine all different line segments we have to draw in + // this column, and convert them to points and shapes. + // + // Even a single, uniform line might generate more than one + // segment, if it happens to cross a colspan (over which it + // must not be drawn). + let segments = generate_line_segments( + self.grid, + tracks, + x, + vlines_at_column, + vline_stroke_at_row, + ) + .map(|segment| { + let LineSegment { stroke, offset: dy, length, priority } = segment; + let stroke = (*stroke).clone().unwrap_or_default(); + let thickness = stroke.thickness; + let half = thickness / 2.0; + let target = Point::with_y(length + thickness); + let vline = Geometry::Line(target).stroked(stroke); + ( + thickness, + priority, + Point::new(dx, dy - half), + FrameItem::Shape(vline, self.span), + ) + }); + + lines.extend(segments); + } + + // Render horizontal lines. + // They are rendered second as they default to appearing on top. + // First, calculate their offsets from the top of the frame. + let hline_offsets = points(rows.iter().map(|piece| piece.height)); + + // Additionally, determine their indices (the indices of the + // rows they are drawn on top of). In principle, this will + // correspond to the rows' indices directly, except for the + // last hline index, which must be (amount of rows) in order to + // draw the table's bottom border. + let hline_indices = rows + .iter() + .map(|piece| piece.y) + .chain(std::iter::once(self.grid.rows.len())); + + // Converts a row to the corresponding index in the vector of + // hlines. + let hline_index_of_row = |y: usize| { + if !self.grid.has_gutter { + y + } else if y == self.grid.rows.len() { + y / 2 + 1 + } else { + // Check the vlines loop for an explanation regarding + // these index operations. + y / 2 + } + }; + + let get_hlines_at = |y| { + self.grid + .hlines + .get(hline_index_of_row(y)) + .map(Vec::as_slice) + .unwrap_or(&[]) + }; + + let mut prev_y = None; + for (y, dy) in hline_indices.zip(hline_offsets) { + // Position of lines below the row index in the previous iteration. + let expected_prev_line_position = prev_y + .map(|prev_y| { + expected_line_position( + prev_y + 1, + prev_y + 1 == self.grid.rows.len(), + ) + }) + .unwrap_or(LinePosition::Before); + + // FIXME: In the future, directly specify in 'self.rrows' when + // we place a repeated header rather than its original rows. + // That would let us remove most of those verbose checks, both + // in 'lines.rs' and here. Those checks also aren't fully + // accurate either, since they will also trigger when some rows + // have been removed between the header and what's below it. + let is_under_repeated_header = self + .grid + .header + .as_ref() + .and_then(Repeatable::as_repeated) + .zip(prev_y) + .is_some_and(|(header, prev_y)| { + // Note: 'y == header.end' would mean we're right below + // the NON-REPEATED header, so that case should return + // false. + prev_y < header.end && y > header.end + }); + + // If some grid rows were omitted between the previous resolved + // row and the current one, we ensure lines below the previous + // row don't "disappear" and are considered, albeit with less + // priority. However, don't do this when we're below a header, + // as it must have more priority instead of less, so it is + // chained later instead of before. The exception is when the + // last row in the header is removed, in which case we append + // both the lines under the row above us and also (later) the + // lines under the header's (removed) last row. + let prev_lines = prev_y + .filter(|prev_y| { + prev_y + 1 != y + && (!is_under_repeated_header + || self + .grid + .header + .as_ref() + .and_then(Repeatable::as_repeated) + .is_some_and(|header| prev_y + 1 != header.end)) + }) + .map(|prev_y| get_hlines_at(prev_y + 1)) + .unwrap_or(&[]); + + let expected_hline_position = + expected_line_position(y, y == self.grid.rows.len()); + + let hlines_at_y = get_hlines_at(y) + .iter() + .filter(|line| line.position == expected_hline_position); + + let top_border_hlines = if prev_y.is_none() && y != 0 { + // For lines at the top of the region, give priority to + // the lines at the top border. + get_hlines_at(0) + } else { + &[] + }; + + let mut expected_header_line_position = LinePosition::Before; + let header_hlines = if let Some((Repeatable::Repeated(header), prev_y)) = + self.grid.header.as_ref().zip(prev_y) + { + if is_under_repeated_header + && (!self.grid.has_gutter + || matches!( + self.grid.rows[prev_y], + Sizing::Rel(length) if length.is_zero() + )) + { + // For lines below a header, give priority to the + // lines originally below the header rather than + // the lines of what's below the repeated header. + // However, no need to do that when we're laying + // out the header for the first time, since the + // lines being normally laid out then will be + // precisely the lines below the header. + // + // Additionally, we don't repeat lines above the row + // below the header when gutter is enabled, since, in + // that case, there will be a gutter row between header + // and content, so no lines should overlap. The + // exception is when the gutter at the end of the + // header has a size of zero, which happens when only + // column-gutter is specified, for example. In that + // case, we still repeat the line under the gutter. + expected_header_line_position = expected_line_position( + header.end, + header.end == self.grid.rows.len(), + ); + get_hlines_at(header.end) + } else { + &[] + } + } else { + &[] + }; + + // The effective hlines to be considered at this row index are + // chained in order of increasing priority: + // 1. Lines from the row right above us, if needed; + // 2. Lines from the current row (usually, only those are + // present); + // 3. Lines from the top border (above the top cells, hence + // 'before' position only); + // 4. Lines from the header above us, if present. + let hlines_at_row = + prev_lines + .iter() + .filter(|line| line.position == expected_prev_line_position) + .chain(hlines_at_y) + .chain( + top_border_hlines + .iter() + .filter(|line| line.position == LinePosition::Before), + ) + .chain(header_hlines.iter().filter(|line| { + line.position == expected_header_line_position + })); + + let tracks = self.rcols.iter().copied().enumerate(); + + // Normally, given an hline above row y, the row above it is + // 'y - 1' (if y > 0). However, sometimes that's not true, for + // example if 'y - 1' is in a previous region, or if 'y - 1' + // was an empty auto row which was removed. Therefore, we tell + // the hlines at this index which row is actually above them in + // the laid out region so they can include that row's bottom + // strokes in the folding process. + let local_top_y = prev_y; + + // When we're in the last region, the bottom border stroke + // doesn't necessarily gain priority like it does in previous + // regions. + let in_last_region = frame_index + 1 == frame_amount; + + // Determine all different line segments we have to draw in + // this row, and convert them to points and shapes. + let segments = generate_line_segments( + self.grid, + tracks, + y, + hlines_at_row, + |grid, y, x, stroke| { + hline_stroke_at_column( + grid, + rows, + local_top_y, + in_last_region, + y, + x, + stroke, + ) + }, + ) + .map(|segment| { + let LineSegment { stroke, offset: dx, length, priority } = segment; + let stroke = (*stroke).clone().unwrap_or_default(); + let thickness = stroke.thickness; + let half = thickness / 2.0; + let dx = if self.is_rtl { self.width - dx - length } else { dx }; + let target = Point::with_x(length + thickness); + let hline = Geometry::Line(target).stroked(stroke); + ( + thickness, + priority, + Point::new(dx - half, dy), + FrameItem::Shape(hline, self.span), + ) + }); + + // Draw later (after we sort all lines below.) + lines.extend(segments); + + prev_y = Some(y); + } + + // Sort by increasing thickness, so that we draw larger strokes + // on top. When the thickness is the same, sort by priority. + // + // Sorting by thickness avoids layering problems where a smaller + // hline appears "inside" a larger vline. When both have the same + // size, hlines are drawn on top (since the sort is stable, and + // they are pushed later). + lines.sort_by_key(|(thickness, priority, ..)| (*thickness, *priority)); + + // Render cell backgrounds. + // We collect them into a vector so they can all be prepended at + // once to the frame, together with lines. + let mut fills = vec![]; + + // Reverse with RTL so that later columns start first. + let mut dx = Abs::zero(); + for (x, &col) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { + let mut dy = Abs::zero(); + for row in rows { + // We want to only draw the fill starting at the parent + // positions of cells. However, sometimes the parent + // position is absent from the current region, either + // because the first few rows of a rowspan were empty auto + // rows and thus removed from layout, or because the parent + // cell was in a previous region (in which case we'd want + // to draw its fill again, in the current region). + // Therefore, we first analyze the parent position to see + // if the current row would be the first row spanned by the + // parent cell in this region. If so, this means we have to + // start drawing the cell's fill here. If not, we ignore + // the position `(x, row.y)`, as its fill will already have + // been rendered before. + // + // Note: In the case of gutter rows, we have to check the + // row below before discarding them fully, because a + // gutter row might be the first row spanned by a rowspan + // in this region (e.g. if the first row was empty and + // therefore removed), so its fill could start in that + // gutter row. That's why we use + // 'effective_parent_cell_position'. + let parent = self + .grid + .effective_parent_cell_position(x, row.y) + .filter(|parent| { + // Ensure this is the first column spanned by the + // cell before drawing its fill, otherwise we + // already rendered its fill in a previous + // iteration of the outer loop (and/or this is a + // gutter column, which we ignore). + // + // Additionally, we should only draw the fill when + // this row is the local parent Y for this cell, + // that is, the first row spanned by the cell's + // parent in this region, because if the parent + // cell's fill was already drawn in a previous + // region, we must render it again in later regions + // spanned by that cell. Note that said condition + // always holds when the current cell has a rowspan + // of 1 and we're not currently at a gutter row. + parent.x == x + && (parent.y == row.y + || rows + .iter() + .find(|row| row.y >= parent.y) + .is_some_and(|first_spanned_row| { + first_spanned_row.y == row.y + })) + }); + + if let Some(parent) = parent { + let cell = self.grid.cell(parent.x, parent.y).unwrap(); + let fill = cell.fill.clone(); + if let Some(fill) = fill { + let rowspan = self.grid.effective_rowspan_of_cell(cell); + let height = if rowspan == 1 { + row.height + } else { + rows.iter() + .filter(|row| { + (parent.y..parent.y + rowspan).contains(&row.y) + }) + .map(|row| row.height) + .sum() + }; + let width = self.cell_spanned_width(cell, x); + // In the grid, cell colspans expand to the right, + // so we're at the leftmost (lowest 'x') column + // spanned by the cell. However, in RTL, cells + // expand to the left. Therefore, without the + // offset below, cell fills would start at the + // rightmost visual position of a cell and extend + // over to unrelated columns to the right in RTL. + // We avoid this by ensuring the fill starts at the + // very left of the cell, even with colspan > 1. + let offset = + if self.is_rtl { -width + col } else { Abs::zero() }; + let pos = Point::new(dx + offset, dy); + let size = Size::new(width, height); + let rect = Geometry::Rect(size).filled(fill); + fills.push((pos, FrameItem::Shape(rect, self.span))); + } + } + dy += row.height; + } + dx += col; + } + + // Now we render each fill and stroke by prepending to the frame, + // such that both appear below cell contents. Fills come first so + // that they appear below lines. + frame.prepend_multiple( + fills + .into_iter() + .chain(lines.into_iter().map(|(_, _, point, shape)| (point, shape))), + ); + } + + Ok(Fragment::frames(finished)) + } + + /// Determine all column sizes. + fn measure_columns(&mut self, engine: &mut Engine) -> SourceResult<()> { + // Sum of sizes of resolved relative tracks. + let mut rel = Abs::zero(); + + // Sum of fractions of all fractional tracks. + let mut fr = Fr::zero(); + + // Resolve the size of all relative columns and compute the sum of all + // fractional tracks. + for (&col, rcol) in self.grid.cols.iter().zip(&mut self.rcols) { + match col { + Sizing::Auto => {} + Sizing::Rel(v) => { + let resolved = + v.resolve(self.styles).relative_to(self.regions.base().x); + *rcol = resolved; + rel += resolved; + } + Sizing::Fr(v) => fr += v, + } + } + + // Size that is not used by fixed-size columns. + let available = self.regions.size.x - rel; + if available >= Abs::zero() { + // Determine size of auto columns. + let (auto, count) = self.measure_auto_columns(engine, available)?; + + // If there is remaining space, distribute it to fractional columns, + // otherwise shrink auto columns. + let remaining = available - auto; + if remaining >= Abs::zero() { + self.grow_fractional_columns(remaining, fr); + } else { + self.shrink_auto_columns(available, count); + } + } + + // Sum up the resolved column sizes once here. + self.width = self.rcols.iter().sum(); + + Ok(()) + } + + /// Total width spanned by the cell (among resolved columns). + /// Includes spanned gutter columns. + pub(super) fn cell_spanned_width(&self, cell: &Cell, x: usize) -> Abs { + let colspan = self.grid.effective_colspan_of_cell(cell); + self.rcols.iter().skip(x).take(colspan).sum() + } + + /// Measure the size that is available to auto columns. + fn measure_auto_columns( + &mut self, + engine: &mut Engine, + available: Abs, + ) -> SourceResult<(Abs, usize)> { + let mut auto = Abs::zero(); + let mut count = 0; + let all_frac_cols = self + .grid + .cols + .iter() + .enumerate() + .filter(|(_, col)| col.is_fractional()) + .map(|(x, _)| x) + .collect::<Vec<_>>(); + + // Determine size of auto columns by laying out all cells in those + // columns, measuring them and finding the largest one. + for (x, &col) in self.grid.cols.iter().enumerate() { + if col != Sizing::Auto { + continue; + } + + let mut resolved = Abs::zero(); + for y in 0..self.grid.rows.len() { + // We get the parent cell in case this is a merged position. + let Some(parent) = self.grid.parent_cell_position(x, y) else { + continue; + }; + if parent.y != y { + // Don't check the width of rowspans more than once. + continue; + } + let cell = self.grid.cell(parent.x, parent.y).unwrap(); + let colspan = self.grid.effective_colspan_of_cell(cell); + if colspan > 1 { + let last_spanned_auto_col = self + .grid + .cols + .iter() + .enumerate() + .skip(parent.x) + .take(colspan) + .rev() + .find(|(_, col)| **col == Sizing::Auto) + .map(|(x, _)| x); + + if last_spanned_auto_col != Some(x) { + // A colspan only affects the size of the last spanned + // auto column. + continue; + } + } + + if colspan > 1 + && self.regions.size.x.is_finite() + && !all_frac_cols.is_empty() + && all_frac_cols + .iter() + .all(|x| (parent.x..parent.x + colspan).contains(x)) + { + // Additionally, as a heuristic, a colspan won't affect the + // size of auto columns if it already spans all fractional + // columns, since those would already expand to provide all + // remaining available after auto column sizing to that + // cell. However, this heuristic is only valid in finite + // regions (pages without 'auto' width), since otherwise + // the fractional columns don't expand at all. + continue; + } + + // Sum the heights of spanned rows to find the expected + // available height for the cell, unless it spans a fractional + // or auto column. + let rowspan = self.grid.effective_rowspan_of_cell(cell); + let height = self + .grid + .rows + .iter() + .skip(y) + .take(rowspan) + .try_fold(Abs::zero(), |acc, col| { + // For relative rows, we can already resolve the correct + // base and for auto and fr we could only guess anyway. + match col { + Sizing::Rel(v) => Some( + acc + v + .resolve(self.styles) + .relative_to(self.regions.base().y), + ), + _ => None, + } + }) + .unwrap_or_else(|| self.regions.base().y); + + // Don't expand this auto column more than the cell actually + // needs. To do this, we check how much the other, previously + // resolved columns provide to the cell in terms of width + // (if it is a colspan), and subtract this from its expected + // width when comparing with other cells in this column. Note + // that, since this is the last auto column spanned by this + // cell, all other auto columns will already have been resolved + // and will be considered. + // Only fractional columns will be excluded from this + // calculation, which can lead to auto columns being expanded + // unnecessarily when cells span both a fractional column and + // an auto column. One mitigation for this is the heuristic + // used above to not expand the last auto column spanned by a + // cell if it spans all fractional columns in a finite region. + let already_covered_width = self.cell_spanned_width(cell, parent.x); + + let size = Size::new(available, height); + let pod = Region::new(size, Axes::splat(false)); + let frame = cell.layout(engine, 0, self.styles, pod.into())?.into_frame(); + resolved.set_max(frame.width() - already_covered_width); + } + + self.rcols[x] = resolved; + auto += resolved; + count += 1; + } + + Ok((auto, count)) + } + + /// Distribute remaining space to fractional columns. + fn grow_fractional_columns(&mut self, remaining: Abs, fr: Fr) { + if fr.is_zero() { + return; + } + + for (&col, rcol) in self.grid.cols.iter().zip(&mut self.rcols) { + if let Sizing::Fr(v) = col { + *rcol = v.share(fr, remaining); + } + } + } + + /// Redistribute space to auto columns so that each gets a fair share. + fn shrink_auto_columns(&mut self, available: Abs, count: usize) { + let mut last; + let mut fair = -Abs::inf(); + let mut redistribute = available; + let mut overlarge = count; + let mut changed = true; + + // Iteratively remove columns that don't need to be shrunk. + while changed && overlarge > 0 { + changed = false; + last = fair; + fair = redistribute / (overlarge as f64); + + for (&col, &rcol) in self.grid.cols.iter().zip(&self.rcols) { + // Remove an auto column if it is not overlarge (rcol <= fair), + // but also hasn't already been removed (rcol > last). + if col == Sizing::Auto && rcol <= fair && rcol > last { + redistribute -= rcol; + overlarge -= 1; + changed = true; + } + } + } + + // Redistribute space fairly among overlarge columns. + for (&col, rcol) in self.grid.cols.iter().zip(&mut self.rcols) { + if col == Sizing::Auto && *rcol > fair { + *rcol = fair; + } + } + } + + /// Layout a row with automatic height. Such a row may break across multiple + /// regions. + fn layout_auto_row( + &mut self, + engine: &mut Engine, + disambiguator: usize, + y: usize, + ) -> SourceResult<()> { + // Determine the size for each region of the row. If the first region + // ends up empty for some column, skip the region and remeasure. + let mut resolved = match self.measure_auto_row( + engine, + disambiguator, + y, + true, + self.unbreakable_rows_left, + None, + )? { + Some(resolved) => resolved, + None => { + self.finish_region(engine, false)?; + self.measure_auto_row( + engine, + disambiguator, + y, + false, + self.unbreakable_rows_left, + None, + )? + .unwrap() + } + }; + + // Nothing to layout. + if resolved.is_empty() { + return Ok(()); + } + + // Layout into a single region. + if let &[first] = resolved.as_slice() { + let frame = self.layout_single_row(engine, disambiguator, first, y)?; + self.push_row(frame, y, true); + + if self + .grid + .header + .as_ref() + .and_then(Repeatable::as_repeated) + .is_some_and(|header| y < header.end) + { + // Add to header height. + self.header_height += first; + } + + return Ok(()); + } + + // Expand all but the last region. + // Skip the first region if the space is eaten up by an fr row. + let len = resolved.len(); + for ((i, region), target) in self + .regions + .iter() + .enumerate() + .zip(&mut resolved[..len - 1]) + .skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize) + { + // Subtract header and footer heights from the region height when + // it's not the first. + target.set_max( + region.y + - if i > 0 { + self.header_height + self.footer_height + } else { + Abs::zero() + }, + ); + } + + // Layout into multiple regions. + let fragment = self.layout_multi_row(engine, disambiguator, &resolved, y)?; + let len = fragment.len(); + for (i, frame) in fragment.into_iter().enumerate() { + self.push_row(frame, y, i + 1 == len); + if i + 1 < len { + self.finish_region(engine, false)?; + } + } + + Ok(()) + } + + /// Measure the regions sizes of an auto row. The option is always `Some(_)` + /// if `can_skip` is false. + /// If `unbreakable_rows_left` is positive, this function shall only return + /// a single frame. Useful when an unbreakable rowspan crosses this auto + /// row. + /// The `row_group_data` option is used within the unbreakable row group + /// simulator to predict the height of the auto row if previous rows in the + /// group were placed in the same region. + pub(super) fn measure_auto_row( + &self, + engine: &mut Engine, + disambiguator: usize, + y: usize, + can_skip: bool, + unbreakable_rows_left: usize, + row_group_data: Option<&UnbreakableRowGroup>, + ) -> SourceResult<Option<Vec<Abs>>> { + let breakable = unbreakable_rows_left == 0; + let mut resolved: Vec<Abs> = vec![]; + let mut pending_rowspans: Vec<(usize, usize, Vec<Abs>)> = vec![]; + + for x in 0..self.rcols.len() { + // Get the parent cell in case this is a merged position. + let Some(parent) = self.grid.parent_cell_position(x, y) else { + // Skip gutter columns. + continue; + }; + if parent.x != x { + // Only check the height of a colspan once. + continue; + } + // The parent cell is never a gutter or merged position. + let cell = self.grid.cell(parent.x, parent.y).unwrap(); + let rowspan = self.grid.effective_rowspan_of_cell(cell); + + if rowspan > 1 { + let last_spanned_auto_row = self + .grid + .rows + .iter() + .enumerate() + .skip(parent.y) + .take(rowspan) + .rev() + .find(|(_, &row)| row == Sizing::Auto) + .map(|(y, _)| y); + + if last_spanned_auto_row != Some(y) { + // A rowspan should only affect the height of its last + // spanned auto row. + continue; + } + } + + let measurement_data = self.prepare_auto_row_cell_measurement( + parent, + cell, + breakable, + row_group_data, + ); + let size = Axes::new(measurement_data.width, measurement_data.height); + let backlog = + measurement_data.backlog.unwrap_or(&measurement_data.custom_backlog); + + let pod = if !breakable { + // Force cell to fit into a single region when the row is + // unbreakable, even when it is a breakable rowspan, as a best + // effort. + let mut pod: Regions = Region::new(size, self.regions.expand).into(); + pod.full = measurement_data.full; + + if measurement_data.frames_in_previous_regions > 0 { + // Best effort to conciliate a breakable rowspan which + // started at a previous region going through an + // unbreakable auto row. Ensure it goes through previously + // laid out regions, but stops at this one when measuring. + pod.backlog = backlog; + } + + pod + } else { + // This row is breakable, so measure the cell normally, with + // the initial height and backlog determined previously. + let mut pod = self.regions; + pod.size = size; + pod.backlog = backlog; + pod.full = measurement_data.full; + pod.last = measurement_data.last; + + pod + }; + + let frames = + cell.layout(engine, disambiguator, self.styles, pod)?.into_frames(); + + // Skip the first region if one cell in it is empty. Then, + // remeasure. + if let Some([first, rest @ ..]) = + frames.get(measurement_data.frames_in_previous_regions..) + { + if can_skip + && breakable + && first.is_empty() + && rest.iter().any(|frame| !frame.is_empty()) + { + return Ok(None); + } + } + + // Skip frames from previous regions if applicable. + let mut sizes = frames + .iter() + .skip(measurement_data.frames_in_previous_regions) + .map(|frame| frame.height()) + .collect::<Vec<_>>(); + + // Don't expand this row more than the cell needs. + // To figure out how much height the cell needs, we must first + // subtract, from the cell's expected height, the already resolved + // heights of its spanned rows. Note that this is the last spanned + // auto row, so all previous auto rows were already resolved, as + // well as fractional rows in previous regions. + // Additionally, we subtract the heights of fixed-size rows which + // weren't laid out yet, since those heights won't change in + // principle. + // Upcoming fractional rows are ignored. + // Upcoming gutter rows might be removed, so we need to simulate + // them. + if rowspan > 1 { + let should_simulate = self.prepare_rowspan_sizes( + y, + &mut sizes, + cell, + parent.y, + rowspan, + unbreakable_rows_left, + &measurement_data, + ); + + if should_simulate { + // Rowspan spans gutter and is breakable. We'll need to + // run a simulation to predict how much this auto row needs + // to expand so that the rowspan's contents fit into the + // table. + pending_rowspans.push((parent.y, rowspan, sizes)); + continue; + } + } + + let mut sizes = sizes.into_iter(); + + for (target, size) in resolved.iter_mut().zip(&mut sizes) { + target.set_max(size); + } + + // New heights are maximal by virtue of being new. Note that + // this extend only uses the rest of the sizes iterator. + resolved.extend(sizes); + } + + // Simulate the upcoming regions in order to predict how much we need + // to expand this auto row for rowspans which span gutter. + if !pending_rowspans.is_empty() { + self.simulate_and_measure_rowspans_in_auto_row( + y, + &mut resolved, + &pending_rowspans, + unbreakable_rows_left, + row_group_data, + disambiguator, + engine, + )?; + } + + debug_assert!(breakable || resolved.len() <= 1); + + Ok(Some(resolved)) + } + + /// Layout a row with relative height. Such a row cannot break across + /// multiple regions, but it may force a region break. + fn layout_relative_row( + &mut self, + engine: &mut Engine, + disambiguator: usize, + v: Rel<Length>, + y: usize, + ) -> SourceResult<()> { + let resolved = v.resolve(self.styles).relative_to(self.regions.base().y); + let frame = self.layout_single_row(engine, disambiguator, resolved, y)?; + + if self + .grid + .header + .as_ref() + .and_then(Repeatable::as_repeated) + .is_some_and(|header| y < header.end) + { + // Add to header height. + self.header_height += resolved; + } + + // Skip to fitting region, but only if we aren't part of an unbreakable + // row group. We use 'in_last_with_offset' so our 'in_last' call + // properly considers that a header and a footer would be added on each + // region break. + let height = frame.height(); + while self.unbreakable_rows_left == 0 + && !self.regions.size.y.fits(height) + && !in_last_with_offset(self.regions, self.header_height + self.footer_height) + { + self.finish_region(engine, false)?; + + // Don't skip multiple regions for gutter and don't push a row. + if self.grid.is_gutter_track(y) { + return Ok(()); + } + } + + self.push_row(frame, y, true); + + Ok(()) + } + + /// Layout a row with fixed height and return its frame. + fn layout_single_row( + &mut self, + engine: &mut Engine, + disambiguator: usize, + height: Abs, + y: usize, + ) -> SourceResult<Frame> { + if !self.width.is_finite() { + bail!(self.span, "cannot create grid with infinite width"); + } + + if !height.is_finite() { + bail!(self.span, "cannot create grid with infinite height"); + } + + let mut output = Frame::soft(Size::new(self.width, height)); + let mut pos = Point::zero(); + + // Reverse the column order when using RTL. + for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { + if let Some(cell) = self.grid.cell(x, y) { + // Rowspans have a separate layout step + if cell.rowspan.get() == 1 { + let width = self.cell_spanned_width(cell, x); + let size = Size::new(width, height); + let mut pod: Regions = Region::new(size, Axes::splat(true)).into(); + if self.grid.rows[y] == Sizing::Auto + && self.unbreakable_rows_left == 0 + { + // Cells at breakable auto rows have lengths relative + // to the entire page, unlike cells in unbreakable auto + // rows. + pod.full = self.regions.full; + } + let frame = cell + .layout(engine, disambiguator, self.styles, pod)? + .into_frame(); + let mut pos = pos; + if self.is_rtl { + // In the grid, cell colspans expand to the right, + // so we're at the leftmost (lowest 'x') column + // spanned by the cell. However, in RTL, cells + // expand to the left. Therefore, without the + // offset below, the cell's contents would be laid out + // starting at its rightmost visual position and extend + // over to unrelated cells to its right in RTL. + // We avoid this by ensuring the rendered cell starts at + // the very left of the cell, even with colspan > 1. + let offset = -width + rcol; + pos.x += offset; + } + output.push_frame(pos, frame); + } + } + + pos.x += rcol; + } + + Ok(output) + } + + /// Layout a row spanning multiple regions. + fn layout_multi_row( + &mut self, + engine: &mut Engine, + disambiguator: usize, + heights: &[Abs], + y: usize, + ) -> SourceResult<Fragment> { + // Prepare frames. + let mut outputs: Vec<_> = heights + .iter() + .map(|&h| Frame::soft(Size::new(self.width, h))) + .collect(); + + // Prepare regions. + let size = Size::new(self.width, heights[0]); + let mut pod: Regions = Region::new(size, Axes::splat(true)).into(); + pod.full = self.regions.full; + pod.backlog = &heights[1..]; + + // Layout the row. + let mut pos = Point::zero(); + for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { + if let Some(cell) = self.grid.cell(x, y) { + // Rowspans have a separate layout step + if cell.rowspan.get() == 1 { + let width = self.cell_spanned_width(cell, x); + pod.size.x = width; + + // Push the layouted frames into the individual output frames. + let fragment = + cell.layout(engine, disambiguator, self.styles, pod)?; + for (output, frame) in outputs.iter_mut().zip(fragment) { + let mut pos = pos; + if self.is_rtl { + let offset = -width + rcol; + pos.x += offset; + } + output.push_frame(pos, frame); + } + } + } + + pos.x += rcol; + } + + Ok(Fragment::frames(outputs)) + } + + /// Push a row frame into the current region. + /// The `is_last` parameter must be `true` if this is the last frame which + /// will be pushed for this particular row. It can be `false` for rows + /// spanning multiple regions. + fn push_row(&mut self, frame: Frame, y: usize, is_last: bool) { + self.regions.size.y -= frame.height(); + self.lrows.push(Row::Frame(frame, y, is_last)); + } + + /// Finish rows for one region. + pub(super) fn finish_region( + &mut self, + engine: &mut Engine, + last: bool, + ) -> SourceResult<()> { + if self + .lrows + .last() + .is_some_and(|row| self.grid.is_gutter_track(row.index())) + { + // Remove the last row in the region if it is a gutter row. + self.lrows.pop().unwrap(); + } + + // If no rows other than the footer have been laid out so far, and + // there are rows beside the footer, then don't lay it out at all. + // This check doesn't apply, and is thus overridden, when there is a + // header. + let mut footer_would_be_orphan = self.lrows.is_empty() + && !in_last_with_offset( + self.regions, + self.header_height + self.footer_height, + ) + && self + .grid + .footer + .as_ref() + .and_then(Repeatable::as_repeated) + .is_some_and(|footer| footer.start != 0); + + if let Some(Repeatable::Repeated(header)) = &self.grid.header { + if self.grid.rows.len() > header.end + && self + .grid + .footer + .as_ref() + .and_then(Repeatable::as_repeated) + .map_or(true, |footer| footer.start != header.end) + && self.lrows.last().is_some_and(|row| row.index() < header.end) + && !in_last_with_offset( + self.regions, + self.header_height + self.footer_height, + ) + { + // Header and footer would be alone in this region, but there are more + // rows beyond the header and the footer. Push an empty region. + self.lrows.clear(); + footer_would_be_orphan = true; + } + } + + let mut laid_out_footer_start = None; + if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + // Don't layout the footer if it would be alone with the header in + // the page, and don't layout it twice. + if !footer_would_be_orphan + && self.lrows.iter().all(|row| row.index() < footer.start) + { + laid_out_footer_start = Some(footer.start); + self.layout_footer(footer, engine, self.finished.len())?; + } + } + + // Determine the height of existing rows in the region. + let mut used = Abs::zero(); + let mut fr = Fr::zero(); + for row in &self.lrows { + match row { + Row::Frame(frame, _, _) => used += frame.height(), + Row::Fr(v, _, _) => fr += *v, + } + } + + // Determine the size of the grid in this region, expanding fully if + // there are fr rows. + let mut size = Size::new(self.width, used).min(self.initial); + if fr.get() > 0.0 && self.initial.y.is_finite() { + size.y = self.initial.y; + } + + // The frame for the region. + let mut output = Frame::soft(size); + let mut pos = Point::zero(); + let mut rrows = vec![]; + let current_region = self.finished.len(); + + // Place finished rows and layout fractional rows. + for row in std::mem::take(&mut self.lrows) { + let (frame, y, is_last) = match row { + Row::Frame(frame, y, is_last) => (frame, y, is_last), + Row::Fr(v, y, disambiguator) => { + let remaining = self.regions.full - used; + let height = v.share(fr, remaining); + (self.layout_single_row(engine, disambiguator, height, y)?, y, true) + } + }; + + let height = frame.height(); + + // Ensure rowspans which span this row will have enough space to + // be laid out over it later. + for rowspan in self + .rowspans + .iter_mut() + .filter(|rowspan| (rowspan.y..rowspan.y + rowspan.rowspan).contains(&y)) + .filter(|rowspan| { + rowspan.max_resolved_row.map_or(true, |max_row| y > max_row) + }) + { + // If the first region wasn't defined yet, it will have the + // initial value of usize::MAX, so we can set it to the current + // region's index. + if rowspan.first_region > current_region { + rowspan.first_region = current_region; + // The rowspan starts at this region, precisely at this + // row. In other regions, it will start at dy = 0. + rowspan.dy = pos.y; + // When we layout the rowspan later, the full size of the + // pod must be equal to the full size of the first region + // it appears in. + rowspan.region_full = self.regions.full; + } + let amount_missing_heights = (current_region + 1) + .saturating_sub(rowspan.heights.len() + rowspan.first_region); + + // Ensure the vector of heights is long enough such that the + // last height is the one for the current region. + rowspan + .heights + .extend(std::iter::repeat(Abs::zero()).take(amount_missing_heights)); + + // Ensure that, in this region, the rowspan will span at least + // this row. + *rowspan.heights.last_mut().unwrap() += height; + + if is_last { + // Do not extend the rowspan through this row again, even + // if it is repeated in a future region. + rowspan.max_resolved_row = Some(y); + } + } + + // We use a for loop over indices to avoid borrow checking + // problems (we need to mutate the rowspans vector, so we can't + // have an iterator actively borrowing it). We keep a separate + // 'i' variable so we can step the counter back after removing + // a rowspan (see explanation below). + let mut i = 0; + while let Some(rowspan) = self.rowspans.get(i) { + // Layout any rowspans which end at this row, but only if this is + // this row's last frame (to avoid having the rowspan stop being + // laid out at the first frame of the row). + // Any rowspans ending before this row are laid out even + // on this row's first frame. + if laid_out_footer_start.map_or(true, |footer_start| { + // If this is a footer row, then only lay out this rowspan + // if the rowspan is contained within the footer. + y < footer_start || rowspan.y >= footer_start + }) && (rowspan.y + rowspan.rowspan < y + 1 + || rowspan.y + rowspan.rowspan == y + 1 && is_last) + { + // Rowspan ends at this or an earlier row, so we take + // it from the rowspans vector and lay it out. + // It's safe to pass the current region as a possible + // region for the rowspan to be laid out in, even if + // the rowspan's last row was at an earlier region, + // because the rowspan won't have an entry for this + // region in its 'heights' vector if it doesn't span + // any rows in this region. + // + // Here we don't advance the index counter ('i') because + // a new element we haven't checked yet in this loop + // will take the index of the now removed element, so + // we have to check the same index again in the next + // iteration. + let rowspan = self.rowspans.remove(i); + self.layout_rowspan(rowspan, Some((&mut output, &rrows)), engine)?; + } else { + i += 1; + } + } + + output.push_frame(pos, frame); + rrows.push(RowPiece { height, y }); + pos.y += height; + } + + self.finish_region_internal(output, rrows); + + if !last { + let disambiguator = self.finished.len(); + if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + self.prepare_footer(footer, engine, disambiguator)?; + } + + if let Some(Repeatable::Repeated(header)) = &self.grid.header { + // Add a header to the new region. + self.layout_header(header, engine, disambiguator)?; + } + + // Ensure rows don't try to overrun the footer. + self.regions.size.y -= self.footer_height; + } + + Ok(()) + } + + /// Advances to the next region, registering the finished output and + /// resolved rows for the current region in the appropriate vectors. + pub(super) fn finish_region_internal( + &mut self, + output: Frame, + resolved_rows: Vec<RowPiece>, + ) { + self.finished.push(output); + self.rrows.push(resolved_rows); + self.regions.next(); + self.initial = self.regions.size; + } +} + +/// Turn an iterator of extents into an iterator of offsets before, in between, +/// and after the extents, e.g. [10mm, 5mm] -> [0mm, 10mm, 15mm]. +pub(super) fn points( + extents: impl IntoIterator<Item = Abs>, +) -> impl Iterator<Item = Abs> { + let mut offset = Abs::zero(); + std::iter::once(Abs::zero()).chain(extents).map(move |extent| { + offset += extent; + offset + }) +} + +/// Checks if the first region of a sequence of regions is the last usable +/// region, assuming that the last region will always be occupied by some +/// specific offset height, even after calling `.next()`, due to some +/// additional logic which adds content automatically on each region turn (in +/// our case, headers). +pub(super) fn in_last_with_offset(regions: Regions<'_>, offset: Abs) -> bool { + regions.backlog.is_empty() + && regions.last.map_or(true, |height| regions.size.y + offset == height) +} diff --git a/crates/typst-layout/src/grid/lines.rs b/crates/typst-layout/src/grid/lines.rs new file mode 100644 index 00000000..3e89612a --- /dev/null +++ b/crates/typst-layout/src/grid/lines.rs @@ -0,0 +1,1548 @@ +use std::num::NonZeroUsize; +use std::sync::Arc; + +use typst_library::foundations::{AlternativeFold, Fold}; +use typst_library::layout::Abs; +use typst_library::visualize::Stroke; + +use super::{CellGrid, LinePosition, Repeatable, RowPiece}; + +/// Represents an explicit grid line (horizontal or vertical) specified by the +/// user. +pub struct Line { + /// The index of the track after this line. This will be the index of the + /// row a horizontal line is above of, or of the column right after a + /// vertical line. + /// + /// Must be within `0..=tracks.len()` (where `tracks` is either `grid.cols` + /// or `grid.rows`, ignoring gutter tracks, as appropriate). + pub index: usize, + /// The index of the track at which this line starts being drawn. + /// This is the first column a horizontal line appears in, or the first row + /// a vertical line appears in. + /// + /// Must be within `0..tracks.len()` minus gutter tracks. + pub start: usize, + /// The index after the last track through which the line is drawn. + /// Thus, the line is drawn through tracks `start..end` (note that `end` is + /// exclusive). + /// + /// Must be within `1..=tracks.len()` minus gutter tracks. + /// `None` indicates the line should go all the way to the end. + pub end: Option<NonZeroUsize>, + /// The line's stroke. This is `None` when the line is explicitly used to + /// override a previously specified line. + pub stroke: Option<Arc<Stroke<Abs>>>, + /// The line's position in relation to the track with its index. + pub position: LinePosition, +} + +/// Indicates which priority a particular grid line segment should have, based +/// on the highest priority configuration that defined the segment's stroke. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum StrokePriority { + /// The stroke of the segment was derived solely from the grid's global + /// stroke setting, so it should have the lowest priority. + GridStroke = 0, + /// The segment's stroke was derived (even if partially) from a cell's + /// stroke override, so it should have priority over non-overridden cell + /// strokes and be drawn on top of them (when they have the same + /// thickness). + CellStroke = 1, + /// The segment's stroke was derived from a user's explicitly placed line + /// (hline or vline), and thus should have maximum priority, drawn on top + /// of any cell strokes (when they have the same thickness). + ExplicitLine = 2, +} + +/// Data for a particular line segment in the grid as generated by +/// `generate_line_segments`. +#[derive(Debug, PartialEq, Eq)] +pub struct LineSegment { + /// The stroke with which to draw this segment. + pub stroke: Arc<Stroke<Abs>>, + /// The offset of this segment since the beginning of its axis. + /// For a vertical line segment, this is the offset since the top of the + /// table in the current page; for a horizontal line segment, this is the + /// offset since the start border of the table. + pub offset: Abs, + /// The length of this segment. + pub length: Abs, + /// The segment's drawing priority, indicating on top of which other + /// segments this one should be drawn. + pub priority: StrokePriority, +} + +/// Generates the segments of lines that should be drawn alongside a certain +/// axis in the grid, going through the given tracks (orthogonal to the lines). +/// Each returned segment contains its stroke, its offset from the start, and +/// its length. +/// +/// Accepts, as parameters, the index of the lines that should be produced +/// (for example, the column at which vertical lines will be drawn); a list of +/// user-specified lines with the same index (the `lines` parameter); whether +/// the given index corresponds to the maximum index for the line's axis; and a +/// function which returns the final stroke that should be used for each track +/// the line goes through, alongside the priority of the returned stroke (its +/// parameters are the grid, the index of the line to be drawn, the number of +/// the track to draw at and the stroke of the user hline/vline override at +/// this index to fold with, if any). Contiguous segments with the same stroke +/// and priority are joined together automatically. +/// +/// The function should return `None` for positions at which the line would +/// otherwise cross a merged cell (for example, a vline could cross a colspan), +/// in which case a new segment should be drawn after the merged cell(s), even +/// if it would have the same stroke as the previous one. +/// +/// Regarding priority, the function should return a priority of ExplicitLine +/// when the user-defined line's stroke at the current position isn't None +/// (note that it is passed by parameter to the function). When it is None, the +/// function should return a priority of CellStroke if the stroke returned was +/// given or affected by a per-cell override of the grid's global stroke. +/// When that isn't the case, the returned stroke was entirely provided by the +/// grid's global stroke, and thus a priority of GridStroke should be returned. +/// +/// Note that we assume that the tracks are sorted according to ascending +/// number, and they must be iterable over pairs of (number, size). For +/// vertical lines, for instance, `tracks` would describe the rows in the +/// current region, as pairs (row index, row height). +pub fn generate_line_segments<'grid, F, I, L>( + grid: &'grid CellGrid, + tracks: I, + index: usize, + lines: L, + line_stroke_at_track: F, +) -> impl Iterator<Item = LineSegment> + 'grid +where + F: Fn( + &CellGrid, + usize, + usize, + Option<Option<Arc<Stroke<Abs>>>>, + ) -> Option<(Arc<Stroke<Abs>>, StrokePriority)> + + 'grid, + I: IntoIterator<Item = (usize, Abs)>, + I::IntoIter: 'grid, + L: IntoIterator<Item = &'grid Line>, + L::IntoIter: Clone + 'grid, +{ + // The segment currently being drawn. + // + // It is extended for each consecutive track through which the line would + // be drawn with the same stroke and priority. + // + // Starts as None to force us to create a new segment as soon as we find + // the first track through which we should draw. + let mut current_segment: Option<LineSegment> = None; + + // How far from the start (before the first track) have we gone so far. + // Used to determine the positions at which to draw each segment. + let mut offset = Abs::zero(); + + // How much to multiply line indices by to account for gutter. + let gutter_factor = if grid.has_gutter { 2 } else { 1 }; + + // Create an iterator of line segments, which will go through each track, + // from start to finish, to create line segments and extend them until they + // are interrupted and thus yielded through the iterator. We then repeat + // the process, picking up from the track after the one at which we had + // an interruption, until we have gone through all tracks. + // + // When going through each track, we check if the current segment would be + // interrupted, either because, at this track, we hit a merged cell over + // which we shouldn't draw, or because the line would have a different + // stroke or priority at this point (so we have to start a new segment). If + // so, the current segment is yielded and its variable is either set to + // 'None' (if no segment should be drawn at the point of interruption, + // meaning we might have to create a new segment later) or to the new + // segment (if we're starting to draw a segment with a different stroke or + // priority than before). + // Otherwise (if the current segment should span the current track), it is + // simply extended (or a new one is created, if it is 'None'), and no value + // is yielded for the current track, since the segment isn't yet complete + // (the next tracks might extend it further before it is interrupted and + // yielded). That is, we yield each segment only when it is interrupted, + // since then we will know its final length for sure. + // + // After the loop is done (and thus we went through all tracks), we + // interrupt the current segment one last time, to ensure the final segment + // is always interrupted and yielded, if it wasn't interrupted earlier. + let mut tracks = tracks.into_iter(); + let lines = lines.into_iter(); + std::iter::from_fn(move || { + // Each time this closure runs, we advance the track iterator as much + // as possible before returning because the current segment was + // interrupted. The for loop is resumed from where it stopped at the + // next call due to that, ensuring we go through all tracks and then + // stop. + for (track, size) in &mut tracks { + // Get the expected line stroke at this track by folding the + // strokes of each user-specified line (with priority to the + // user-specified line specified last). + let mut line_strokes = lines + .clone() + .filter(|line| { + line.end + .map(|end| { + // Subtract 1 from end index so we stop at the last + // cell before it (don't cross one extra gutter). + let end = if grid.has_gutter { + 2 * end.get() - 1 + } else { + end.get() + }; + (gutter_factor * line.start..end).contains(&track) + }) + .unwrap_or_else(|| track >= gutter_factor * line.start) + }) + .map(|line| line.stroke.clone()); + + // Distinguish between unspecified stroke (None, if no lines + // were matched above) and specified stroke of None (Some(None), + // if some lines were matched and the one specified last had a + // stroke of None) by conditionally folding after 'next()'. + let line_stroke = line_strokes.next().map(|first_stroke| { + line_strokes.fold(first_stroke, |acc, line_stroke| line_stroke.fold(acc)) + }); + + // The function shall determine if it is appropriate to draw + // the line at this position or not (i.e. whether or not it + // would cross a merged cell), and, if so, the final stroke it + // should have (because cells near this position could have + // stroke overrides, which have priority and should be folded + // with the stroke obtained above). + // + // If we are currently already drawing a segment and the function + // indicates we should, at this track, draw some other segment + // (with a different stroke or priority), or even no segment at + // all, we interrupt and yield the current segment (which was drawn + // up to the previous track) by returning it wrapped in 'Some()' + // (which indicates, in the context of 'std::iter::from_fn', that + // our iterator isn't over yet, and this should be its next value). + if let Some((stroke, priority)) = + line_stroke_at_track(grid, index, track, line_stroke) + { + // We should draw at this position. Let's check if we were + // already drawing in the previous position. + if let Some(current_segment) = &mut current_segment { + // We are currently building a segment. Let's check if + // we should extend it to this track as well. + if current_segment.stroke == stroke + && current_segment.priority == priority + { + // Extend the current segment so it covers at least + // this track as well, since we should use the same + // stroke as in the previous one when a line goes + // through this track, with the same priority. + current_segment.length += size; + } else { + // We got a different stroke or priority now, so create + // a new segment with the new stroke and spanning the + // current track. Yield the old segment, as it was + // interrupted and is thus complete. + let new_segment = + LineSegment { stroke, offset, length: size, priority }; + let old_segment = std::mem::replace(current_segment, new_segment); + offset += size; + return Some(old_segment); + } + } else { + // We should draw here, but there is no segment + // currently being drawn, either because the last + // position had a merged cell, had a stroke + // of 'None', or because this is the first track. + // Create a new segment to draw. We start spanning this + // track. + current_segment = + Some(LineSegment { stroke, offset, length: size, priority }); + } + } else if let Some(old_segment) = Option::take(&mut current_segment) { + // We shouldn't draw here (stroke of None), so we yield the + // current segment, as it was interrupted. + offset += size; + return Some(old_segment); + } + // Either the current segment is None (meaning we didn't start + // drawing a segment yet since the last yielded one), so we keep + // searching for a track where we should draw one; or the current + // segment is Some but wasn't interrupted at this track, so we keep + // looping through the following tracks until it is interrupted, + // or we reach the end. + offset += size; + } + + // Reached the end of all tracks, so we interrupt and finish + // the current segment. Note that, on future calls to this + // closure, the current segment will necessarily be 'None', + // so the iterator will necessarily end (that is, we will return None) + // after this. + // + // Note: Fully-qualified notation because rust-analyzer is confused. + Option::take(&mut current_segment) + }) +} + +/// Returns the correct stroke with which to draw a vline right before column +/// `x` when going through row `y`, given the stroke of the user-specified line +/// at this position, if any (note that a stroke of `None` is unspecified, +/// while `Some(None)` means specified to remove any stroke at this position). +/// Also returns the stroke's drawing priority, which depends on its source. +/// +/// If the vline would go through a colspan, returns None (shouldn't be drawn). +/// If the one (when at the border) or two (otherwise) cells to the left and +/// right of the vline have right and left stroke overrides, respectively, +/// then the cells' stroke overrides are folded together with the vline's +/// stroke (with priority to the vline's stroke, followed by the right cell's +/// stroke, and, finally, the left cell's) and returned. If only one of the two +/// cells around the vline (if there are two) has an override, that cell's +/// stroke is given priority when folding. If, however, the cells around the +/// vline at this row do not have any stroke overrides, then the vline's own +/// stroke, as defined by user-specified lines (if any), is returned. +/// +/// The priority associated with the returned stroke follows the rules +/// described in the docs for `generate_line_segment`. +pub fn vline_stroke_at_row( + grid: &CellGrid, + x: usize, + y: usize, + stroke: Option<Option<Arc<Stroke<Abs>>>>, +) -> Option<(Arc<Stroke<Abs>>, StrokePriority)> { + // When the vline isn't at the border, we need to check if a colspan would + // be present between columns 'x' and 'x-1' at row 'y', and thus overlap + // with the line. + // To do so, we analyze the cell right after this vline. If it is merged + // with a cell before this line (parent.x < x) which is at this row or + // above it (parent.y <= y, which is checked by + // 'effective_parent_cell_position'), this means it would overlap with the + // vline, so the vline must not be drawn at this row. + if x != 0 && x != grid.cols.len() { + // Use 'effective_parent_cell_position' to skip the gutters, if x or y + // represent gutter tracks. + // We would then analyze the cell one column after (if at a gutter + // column), and/or one row below (if at a gutter row), in order to + // check if it would be merged with a cell before the vline. + if let Some(parent) = grid.effective_parent_cell_position(x, y) { + if parent.x < x { + // There is a colspan cell going through this vline's position, + // so don't draw it here. + return None; + } + } + } + + let (left_cell_stroke, left_cell_prioritized) = x + .checked_sub(1) + .and_then(|left_x| { + // Let's find the parent cell of the position before us, in order + // to take its right stroke, even with gutter before us. + grid.effective_parent_cell_position(left_x, y) + }) + .map(|parent| { + let left_cell = grid.cell(parent.x, parent.y).unwrap(); + (left_cell.stroke.right.clone(), left_cell.stroke_overridden.right) + }) + .unwrap_or((None, false)); + + let (right_cell_stroke, right_cell_prioritized) = if x < grid.cols.len() { + // Let's find the parent cell of the position after us, in order + // to take its left stroke, even with gutter after us. + grid.effective_parent_cell_position(x, y) + .map(|parent| { + let right_cell = grid.cell(parent.x, parent.y).unwrap(); + (right_cell.stroke.left.clone(), right_cell.stroke_overridden.left) + }) + .unwrap_or((None, false)) + } else { + (None, false) + }; + + let priority = if stroke.is_some() { + StrokePriority::ExplicitLine + } else if left_cell_prioritized || right_cell_prioritized { + StrokePriority::CellStroke + } else { + StrokePriority::GridStroke + }; + + let (prioritized_cell_stroke, deprioritized_cell_stroke) = + if left_cell_prioritized && !right_cell_prioritized { + (left_cell_stroke, right_cell_stroke) + } else { + // When both cells' strokes have the same priority, we default to + // prioritizing the right cell's left stroke. + (right_cell_stroke, left_cell_stroke) + }; + + // When both cells specify a stroke for this line segment, fold + // both strokes, with priority to either the one prioritized cell, + // or to the right cell's left stroke in case of a tie. But when one of + // them doesn't specify a stroke, the other cell's stroke should be used + // instead, regardless of priority (hence the usage of 'fold_or'). + let cell_stroke = prioritized_cell_stroke.fold_or(deprioritized_cell_stroke); + + // Fold the line stroke and folded cell strokes, if possible. + // Give priority to the explicit line stroke. + // Otherwise, use whichever of the two isn't 'none' or unspecified. + let final_stroke = stroke.fold_or(Some(cell_stroke)).flatten(); + + final_stroke.zip(Some(priority)) +} + +/// Returns the correct stroke with which to draw a hline on top of row `y` +/// when going through column `x`, given the stroke of the user-specified line +/// at this position, if any (note that a stroke of `None` is unspecified, +/// while `Some(None)` means specified to remove any stroke at this position). +/// Also returns the stroke's drawing priority, which depends on its source. +/// +/// The `local_top_y` parameter indicates which row is effectively on top of +/// this hline at the current region. This is `None` if the hline is above the +/// first row in the region, for instance. The `in_last_region` parameter +/// indicates whether this is the last region of the table. If not and this is +/// a line at the bottom border, the bottom border's line gains priority. +/// +/// If the one (when at the border) or two (otherwise) cells above and below +/// the hline have bottom and top stroke overrides, respectively, then the +/// cells' stroke overrides are folded together with the hline's stroke (with +/// priority to hline's stroke, followed by the bottom cell's stroke, and, +/// finally, the top cell's) and returned. If only one of the two cells around +/// the vline (if there are two) has an override, that cell's stroke is given +/// priority when folding. If, however, the cells around the hline at this +/// column do not have any stroke overrides, then the hline's own stroke, as +/// defined by user-specified lines (if any), is directly returned. +/// +/// The priority associated with the returned stroke follows the rules +/// described in the docs for `generate_line_segment`. +/// +/// The rows argument is needed to know which rows are effectively present in +/// the current region, in order to avoid unnecessary hline splitting when a +/// rowspan's previous rows are either in a previous region or empty (and thus +/// wouldn't overlap with the hline, since its first row in the current region +/// is below the hline). +/// +/// This function assumes columns are sorted by increasing `x`, and rows are +/// sorted by increasing `y`. +pub fn hline_stroke_at_column( + grid: &CellGrid, + rows: &[RowPiece], + local_top_y: Option<usize>, + in_last_region: bool, + y: usize, + x: usize, + stroke: Option<Option<Arc<Stroke<Abs>>>>, +) -> Option<(Arc<Stroke<Abs>>, StrokePriority)> { + // When the hline isn't at the border, we need to check if a rowspan + // would be present between rows 'y' and 'y-1' at column 'x', and thus + // overlap with the line. + // To do so, we analyze the cell right below this hline. If it is + // merged with a cell above this line (parent.y < y) which is at this + // column or before it (parent.x <= x, which is checked by + // 'effective_parent_cell_position'), this means it would overlap with the + // hline, so the hline must not be drawn at this column. + if y != 0 && y != grid.rows.len() { + // Use 'effective_parent_cell_position' to skip the gutters, if x or y + // represent gutter tracks. + // We would then analyze the cell one column after (if at a gutter + // column), and/or one row below (if at a gutter row), in order to + // check if it would be merged with a cell before the hline. + if let Some(parent) = grid.effective_parent_cell_position(x, y) { + if parent.y < y { + // Get the first 'y' spanned by the possible rowspan in this region. + // The 'parent.y' row and any other spanned rows above 'y' could be + // missing from this region, which could have lead the check above + // to be triggered, even though there is no spanned row above the + // hline in the final layout of this region, and thus no overlap + // with the hline, allowing it to be drawn regardless of the + // theoretical presence of a rowspan going across its position. + let local_parent_y = rows + .iter() + .find(|row| row.y >= parent.y) + .map(|row| row.y) + .unwrap_or(y); + + if local_parent_y < y { + // There is a rowspan cell going through this hline's + // position, so don't draw it here. + return None; + } + } + } + } + + // When the hline is at the top of the region and this isn't the first + // region, fold with the top stroke of the topmost cell at this column, + // that is, the top border. + let use_top_border_stroke = local_top_y.is_none() && y != 0; + let (top_cell_stroke, top_cell_prioritized) = local_top_y + .or(use_top_border_stroke.then_some(0)) + .and_then(|top_y| { + // Let's find the parent cell of the position above us, in order + // to take its bottom stroke, even when we're below gutter. + grid.effective_parent_cell_position(x, top_y) + }) + .map(|parent| { + let top_cell = grid.cell(parent.x, parent.y).unwrap(); + if use_top_border_stroke { + (top_cell.stroke.top.clone(), top_cell.stroke_overridden.top) + } else { + (top_cell.stroke.bottom.clone(), top_cell.stroke_overridden.bottom) + } + }) + .unwrap_or((None, false)); + + // Use the bottom border stroke with priority if we're not in the last + // region, we have the last index, and (as a failsafe) we don't have the + // last row of cells above us. + let use_bottom_border_stroke = !in_last_region + && local_top_y.map_or(true, |top_y| top_y + 1 != grid.rows.len()) + && y == grid.rows.len(); + let bottom_y = + if use_bottom_border_stroke { grid.rows.len().saturating_sub(1) } else { y }; + let (bottom_cell_stroke, bottom_cell_prioritized) = if bottom_y < grid.rows.len() { + // Let's find the parent cell of the position below us, in order + // to take its top stroke, even when we're above gutter. + grid.effective_parent_cell_position(x, bottom_y) + .map(|parent| { + let bottom_cell = grid.cell(parent.x, parent.y).unwrap(); + if use_bottom_border_stroke { + ( + bottom_cell.stroke.bottom.clone(), + bottom_cell.stroke_overridden.bottom, + ) + } else { + (bottom_cell.stroke.top.clone(), bottom_cell.stroke_overridden.top) + } + }) + .unwrap_or((None, false)) + } else { + // No cell below the bottom border. + (None, false) + }; + + let priority = if stroke.is_some() { + StrokePriority::ExplicitLine + } else if top_cell_prioritized || bottom_cell_prioritized { + StrokePriority::CellStroke + } else { + StrokePriority::GridStroke + }; + + // Top border stroke and header stroke are generally prioritized, unless + // they don't have explicit hline overrides and one or more user-provided + // hlines would appear at the same position, which then are prioritized. + let top_stroke_comes_from_header = grid + .header + .as_ref() + .and_then(Repeatable::as_repeated) + .zip(local_top_y) + .is_some_and(|(header, local_top_y)| { + // Ensure the row above us is a repeated header. + // FIXME: Make this check more robust when headers at arbitrary + // positions are added. + local_top_y < header.end && y > header.end + }); + + // Prioritize the footer's top stroke as well where applicable. + let bottom_stroke_comes_from_footer = grid + .footer + .as_ref() + .and_then(Repeatable::as_repeated) + .is_some_and(|footer| { + // Ensure the row below us is a repeated footer. + // FIXME: Make this check more robust when footers at arbitrary + // positions are added. + local_top_y.unwrap_or(0) + 1 < footer.start && y >= footer.start + }); + + let (prioritized_cell_stroke, deprioritized_cell_stroke) = + if !use_bottom_border_stroke + && !bottom_stroke_comes_from_footer + && (use_top_border_stroke + || top_stroke_comes_from_header + || top_cell_prioritized && !bottom_cell_prioritized) + { + // Top border must always be prioritized, even if it did not + // request for that explicitly. + (top_cell_stroke, bottom_cell_stroke) + } else { + // When both cells' strokes have the same priority, we default to + // prioritizing the bottom cell's top stroke. + // Additionally, the bottom border cell's stroke always has + // priority. Same for stroke above footers. + (bottom_cell_stroke, top_cell_stroke) + }; + + // When both cells specify a stroke for this line segment, fold + // both strokes, with priority to either the one prioritized cell, + // or to the bottom cell's top stroke in case of a tie. But when one of + // them doesn't specify a stroke, the other cell's stroke should be used + // instead, regardless of priority (hence the usage of 'fold_or'). + let cell_stroke = prioritized_cell_stroke.fold_or(deprioritized_cell_stroke); + + // Fold the line stroke and folded cell strokes, if possible. + // Give priority to the explicit line stroke. + // Otherwise, use whichever of the two isn't 'none' or unspecified. + let final_stroke = stroke.fold_or(Some(cell_stroke)).flatten(); + + final_stroke.zip(Some(priority)) +} + +#[cfg(test)] +mod test { + use typst_library::foundations::Content; + use typst_library::introspection::Locator; + use typst_library::layout::{Axes, Sides, Sizing}; + use typst_utils::NonZeroExt; + + use super::super::cells::Entry; + use super::super::Cell; + use super::*; + + fn sample_cell() -> Cell<'static> { + Cell { + body: Content::default(), + locator: Locator::root(), + fill: None, + colspan: NonZeroUsize::ONE, + rowspan: NonZeroUsize::ONE, + stroke: Sides::splat(Some(Arc::new(Stroke::default()))), + stroke_overridden: Sides::splat(false), + breakable: true, + } + } + + fn cell_with_colspan_rowspan(colspan: usize, rowspan: usize) -> Cell<'static> { + Cell { + body: Content::default(), + locator: Locator::root(), + fill: None, + colspan: NonZeroUsize::try_from(colspan).unwrap(), + rowspan: NonZeroUsize::try_from(rowspan).unwrap(), + stroke: Sides::splat(Some(Arc::new(Stroke::default()))), + stroke_overridden: Sides::splat(false), + breakable: true, + } + } + + fn sample_grid_for_vlines(gutters: bool) -> CellGrid<'static> { + const COLS: usize = 4; + const ROWS: usize = 6; + let entries = vec![ + // row 0 + Entry::Cell(sample_cell()), + Entry::Cell(sample_cell()), + Entry::Cell(cell_with_colspan_rowspan(2, 1)), + Entry::Merged { parent: 2 }, + // row 1 + Entry::Cell(sample_cell()), + Entry::Cell(cell_with_colspan_rowspan(3, 1)), + Entry::Merged { parent: 5 }, + Entry::Merged { parent: 5 }, + // row 2 + Entry::Merged { parent: 4 }, + Entry::Cell(sample_cell()), + Entry::Cell(cell_with_colspan_rowspan(2, 1)), + Entry::Merged { parent: 10 }, + // row 3 + Entry::Cell(sample_cell()), + Entry::Cell(cell_with_colspan_rowspan(3, 2)), + Entry::Merged { parent: 13 }, + Entry::Merged { parent: 13 }, + // row 4 + Entry::Cell(sample_cell()), + Entry::Merged { parent: 13 }, + Entry::Merged { parent: 13 }, + Entry::Merged { parent: 13 }, + // row 5 + Entry::Cell(sample_cell()), + Entry::Cell(sample_cell()), + Entry::Cell(cell_with_colspan_rowspan(2, 1)), + Entry::Merged { parent: 22 }, + ]; + CellGrid::new_internal( + Axes::with_x(&[Sizing::Auto; COLS]), + if gutters { + Axes::new(&[Sizing::Auto; COLS - 1], &[Sizing::Auto; ROWS - 1]) + } else { + Axes::default() + }, + vec![], + vec![], + None, + None, + entries, + ) + } + + #[test] + fn test_vline_splitting_without_gutter() { + let stroke = Arc::new(Stroke::default()); + let grid = sample_grid_for_vlines(false); + let rows = &[ + RowPiece { height: Abs::pt(1.0), y: 0 }, + RowPiece { height: Abs::pt(2.0), y: 1 }, + RowPiece { height: Abs::pt(4.0), y: 2 }, + RowPiece { height: Abs::pt(8.0), y: 3 }, + RowPiece { height: Abs::pt(16.0), y: 4 }, + RowPiece { height: Abs::pt(32.0), y: 5 }, + ]; + let expected_vline_splits = &[ + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.), + priority: StrokePriority::GridStroke, + }], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.), + priority: StrokePriority::GridStroke, + }], + // interrupted a few times by colspans + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2.), + length: Abs::pt(4.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16.), + length: Abs::pt(32.), + priority: StrokePriority::GridStroke, + }, + ], + // interrupted every time by colspans + vec![], + vec![LineSegment { + stroke, + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.), + priority: StrokePriority::GridStroke, + }], + ]; + for (x, expected_splits) in expected_vline_splits.iter().enumerate() { + let tracks = rows.iter().map(|row| (row.y, row.height)); + assert_eq!( + expected_splits, + &generate_line_segments(&grid, tracks, x, &[], vline_stroke_at_row) + .collect::<Vec<_>>(), + ); + } + } + + #[test] + fn test_vline_splitting_with_gutter_and_per_cell_stroke() { + let stroke = Arc::new(Stroke::default()); + let grid = sample_grid_for_vlines(true); + let rows = &[ + RowPiece { height: Abs::pt(1.0), y: 0 }, + RowPiece { height: Abs::pt(2.0), y: 1 }, + RowPiece { height: Abs::pt(4.0), y: 2 }, + RowPiece { height: Abs::pt(8.0), y: 3 }, + RowPiece { height: Abs::pt(16.0), y: 4 }, + RowPiece { height: Abs::pt(32.0), y: 5 }, + RowPiece { height: Abs::pt(64.0), y: 6 }, + RowPiece { height: Abs::pt(128.0), y: 7 }, + RowPiece { height: Abs::pt(256.0), y: 8 }, + RowPiece { height: Abs::pt(512.0), y: 9 }, + RowPiece { height: Abs::pt(1024.0), y: 10 }, + ]; + // Stroke is per-cell so we skip gutter + let expected_vline_splits = &[ + // left border + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1.), + priority: StrokePriority::GridStroke, + }, + // Covers the rowspan between (original) rows 1 and 2 + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2.), + length: Abs::pt(4. + 8. + 16.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.), + length: Abs::pt(64.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128.), + length: Abs::pt(256.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512., + ), + length: Abs::pt(1024.), + priority: StrokePriority::GridStroke, + }, + ], + // gutter line below + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1.), + priority: StrokePriority::GridStroke, + }, + // Covers the rowspan between (original) rows 1 and 2 + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2.), + length: Abs::pt(4. + 8. + 16.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.), + length: Abs::pt(64.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128.), + length: Abs::pt(256.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512., + ), + length: Abs::pt(1024.), + priority: StrokePriority::GridStroke, + }, + ], + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2.), + length: Abs::pt(4.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8.), + length: Abs::pt(16.), + priority: StrokePriority::GridStroke, + }, + // Covers the rowspan between (original) rows 3 and 4 + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.), + length: Abs::pt(64. + 128. + 256.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512., + ), + length: Abs::pt(1024.), + priority: StrokePriority::GridStroke, + }, + ], + // gutter line below + // the two lines below are interrupted multiple times by colspans + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8.), + length: Abs::pt(16.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512., + ), + length: Abs::pt(1024.), + priority: StrokePriority::GridStroke, + }, + ], + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8.), + length: Abs::pt(16.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512., + ), + length: Abs::pt(1024.), + priority: StrokePriority::GridStroke, + }, + ], + // gutter line below + // the two lines below can only cross certain gutter rows, because + // all non-gutter cells in the following column are merged with + // cells from the previous column. + vec![], + vec![], + // right border + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2.), + length: Abs::pt(4.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8.), + length: Abs::pt(16.), + priority: StrokePriority::GridStroke, + }, + // Covers the rowspan between (original) rows 3 and 4 + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.), + length: Abs::pt(64. + 128. + 256.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512., + ), + length: Abs::pt(1024.), + priority: StrokePriority::GridStroke, + }, + ], + ]; + for (x, expected_splits) in expected_vline_splits.iter().enumerate() { + let tracks = rows.iter().map(|row| (row.y, row.height)); + assert_eq!( + expected_splits, + &generate_line_segments(&grid, tracks, x, &[], vline_stroke_at_row) + .collect::<Vec<_>>(), + ); + } + } + + #[test] + fn test_vline_splitting_with_gutter_and_explicit_vlines() { + let stroke = Arc::new(Stroke::default()); + let grid = sample_grid_for_vlines(true); + let rows = &[ + RowPiece { height: Abs::pt(1.0), y: 0 }, + RowPiece { height: Abs::pt(2.0), y: 1 }, + RowPiece { height: Abs::pt(4.0), y: 2 }, + RowPiece { height: Abs::pt(8.0), y: 3 }, + RowPiece { height: Abs::pt(16.0), y: 4 }, + RowPiece { height: Abs::pt(32.0), y: 5 }, + RowPiece { height: Abs::pt(64.0), y: 6 }, + RowPiece { height: Abs::pt(128.0), y: 7 }, + RowPiece { height: Abs::pt(256.0), y: 8 }, + RowPiece { height: Abs::pt(512.0), y: 9 }, + RowPiece { height: Abs::pt(1024.0), y: 10 }, + ]; + let expected_vline_splits = &[ + // left border + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024., + ), + priority: StrokePriority::ExplicitLine, + }], + // gutter line below + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024., + ), + priority: StrokePriority::ExplicitLine, + }], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024., + ), + priority: StrokePriority::ExplicitLine, + }], + // gutter line below + // the two lines below are interrupted multiple times by colspans + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4.), + length: Abs::pt(8. + 16. + 32.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.), + length: Abs::pt(512. + 1024.), + priority: StrokePriority::ExplicitLine, + }, + ], + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4.), + length: Abs::pt(8. + 16. + 32.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.), + length: Abs::pt(512. + 1024.), + priority: StrokePriority::ExplicitLine, + }, + ], + // gutter line below + // the two lines below can only cross certain gutter rows, because + // all non-gutter cells in the following column are merged with + // cells from the previous column. + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1.), + length: Abs::pt(2.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4.), + length: Abs::pt(8.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16.), + length: Abs::pt(32.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.), + length: Abs::pt(512.), + priority: StrokePriority::ExplicitLine, + }, + ], + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1.), + length: Abs::pt(2.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4.), + length: Abs::pt(8.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16.), + length: Abs::pt(32.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.), + length: Abs::pt(512.), + priority: StrokePriority::ExplicitLine, + }, + ], + // right border + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024., + ), + priority: StrokePriority::ExplicitLine, + }], + ]; + for (x, expected_splits) in expected_vline_splits.iter().enumerate() { + let tracks = rows.iter().map(|row| (row.y, row.height)); + assert_eq!( + expected_splits, + &generate_line_segments( + &grid, + tracks, + x, + &[ + Line { + index: x, + start: 0, + end: None, + stroke: Some(stroke.clone()), + position: LinePosition::Before + }, + Line { + index: x, + start: 0, + end: None, + stroke: Some(stroke.clone()), + position: LinePosition::After + }, + ], + vline_stroke_at_row + ) + .collect::<Vec<_>>(), + ); + } + } + + fn sample_grid_for_hlines(gutters: bool) -> CellGrid<'static> { + const COLS: usize = 4; + const ROWS: usize = 9; + let entries = vec![ + // row 0 + Entry::Cell(cell_with_colspan_rowspan(1, 2)), + Entry::Cell(sample_cell()), + Entry::Cell(cell_with_colspan_rowspan(2, 2)), + Entry::Merged { parent: 2 }, + // row 1 + Entry::Merged { parent: 0 }, + Entry::Cell(sample_cell()), + Entry::Merged { parent: 2 }, + Entry::Merged { parent: 2 }, + // row 2 + Entry::Cell(sample_cell()), + Entry::Cell(sample_cell()), + Entry::Cell(sample_cell()), + Entry::Cell(sample_cell()), + // row 3 + Entry::Cell(cell_with_colspan_rowspan(4, 2)), + Entry::Merged { parent: 12 }, + Entry::Merged { parent: 12 }, + Entry::Merged { parent: 12 }, + // row 4 + Entry::Merged { parent: 12 }, + Entry::Merged { parent: 12 }, + Entry::Merged { parent: 12 }, + Entry::Merged { parent: 12 }, + // row 5 + Entry::Cell(sample_cell()), + Entry::Cell(cell_with_colspan_rowspan(1, 2)), + Entry::Cell(cell_with_colspan_rowspan(2, 1)), + Entry::Merged { parent: 22 }, + // row 6 + Entry::Cell(sample_cell()), + Entry::Merged { parent: 21 }, + Entry::Cell(sample_cell()), + Entry::Cell(sample_cell()), + // row 7 (adjacent rowspans covering the whole row) + Entry::Cell(cell_with_colspan_rowspan(2, 2)), + Entry::Merged { parent: 28 }, + Entry::Cell(cell_with_colspan_rowspan(2, 2)), + Entry::Merged { parent: 30 }, + // row 8 + Entry::Merged { parent: 28 }, + Entry::Merged { parent: 28 }, + Entry::Merged { parent: 30 }, + Entry::Merged { parent: 30 }, + ]; + CellGrid::new_internal( + Axes::with_x(&[Sizing::Auto; COLS]), + if gutters { + Axes::new(&[Sizing::Auto; COLS - 1], &[Sizing::Auto; ROWS - 1]) + } else { + Axes::default() + }, + vec![], + vec![], + None, + None, + entries, + ) + } + + #[test] + fn test_hline_splitting_without_gutter() { + let stroke = Arc::new(Stroke::default()); + let grid = sample_grid_for_hlines(false); + let columns = &[Abs::pt(1.), Abs::pt(2.), Abs::pt(4.), Abs::pt(8.)]; + // Assume all rows would be drawn in the same region, and are available. + let rows = grid + .rows + .iter() + .enumerate() + .map(|(y, _)| RowPiece { height: Abs::pt(f64::from(2u32.pow(y as u32))), y }) + .collect::<Vec<_>>(); + let expected_hline_splits = &[ + // top border + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8.), + priority: StrokePriority::GridStroke, + }], + // interrupted a few times by rowspans + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1.), + length: Abs::pt(2.), + priority: StrokePriority::GridStroke, + }], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8.), + priority: StrokePriority::GridStroke, + }], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8.), + priority: StrokePriority::GridStroke, + }], + // interrupted every time by rowspans + vec![], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8.), + priority: StrokePriority::GridStroke, + }], + // interrupted once by rowspan + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2.), + length: Abs::pt(4. + 8.), + priority: StrokePriority::GridStroke, + }, + ], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8.), + priority: StrokePriority::GridStroke, + }], + // interrupted every time by successive rowspans + vec![], + // bottom border + vec![LineSegment { + stroke, + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8.), + priority: StrokePriority::GridStroke, + }], + ]; + for (y, expected_splits) in expected_hline_splits.iter().enumerate() { + let tracks = columns.iter().copied().enumerate(); + assert_eq!( + expected_splits, + &generate_line_segments(&grid, tracks, y, &[], |grid, y, x, stroke| { + hline_stroke_at_column( + grid, + &rows, + y.checked_sub(1), + true, + y, + x, + stroke, + ) + }) + .collect::<Vec<_>>(), + ); + } + } + + #[test] + fn test_hline_splitting_with_gutter_and_explicit_hlines() { + let stroke = Arc::new(Stroke::default()); + let grid = sample_grid_for_hlines(true); + let columns = &[ + Abs::pt(1.0), + Abs::pt(2.0), + Abs::pt(4.0), + Abs::pt(8.0), + Abs::pt(16.0), + Abs::pt(32.0), + Abs::pt(64.0), + ]; + // Assume all rows would be drawn in the same region, and are available. + let rows = grid + .rows + .iter() + .enumerate() + .map(|(y, _)| RowPiece { height: Abs::pt(f64::from(2u32.pow(y as u32))), y }) + .collect::<Vec<_>>(); + let expected_hline_splits = &[ + // top border + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.), + priority: StrokePriority::ExplicitLine, + }], + // gutter line below + // interrupted a few times by rowspans + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1.), + length: Abs::pt(2. + 4. + 8.), + priority: StrokePriority::ExplicitLine, + }], + // interrupted a few times by rowspans + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1.), + length: Abs::pt(2. + 4. + 8.), + priority: StrokePriority::ExplicitLine, + }], + // gutter line below + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.), + priority: StrokePriority::ExplicitLine, + }], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.), + priority: StrokePriority::ExplicitLine, + }], + // gutter line below + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.), + priority: StrokePriority::ExplicitLine, + }], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.), + priority: StrokePriority::ExplicitLine, + }], + // gutter line below + // interrupted every time by rowspans + vec![], + // interrupted every time by rowspans + vec![], + // gutter line below + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.), + priority: StrokePriority::ExplicitLine, + }], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.), + priority: StrokePriority::ExplicitLine, + }], + // gutter line below + // interrupted once by rowspan + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4.), + length: Abs::pt(8. + 16. + 32. + 64.), + priority: StrokePriority::ExplicitLine, + }, + ], + // interrupted once by rowspan + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4.), + length: Abs::pt(8. + 16. + 32. + 64.), + priority: StrokePriority::ExplicitLine, + }, + ], + // gutter line below + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.), + priority: StrokePriority::ExplicitLine, + }], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.), + priority: StrokePriority::ExplicitLine, + }], + // gutter line below + // there are two consecutive rowspans, but the gutter column + // between them is free. + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4.), + length: Abs::pt(8.), + priority: StrokePriority::ExplicitLine, + }], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4.), + length: Abs::pt(8.), + priority: StrokePriority::ExplicitLine, + }], + // bottom border + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.), + priority: StrokePriority::ExplicitLine, + }], + ]; + for (y, expected_splits) in expected_hline_splits.iter().enumerate() { + let tracks = columns.iter().copied().enumerate(); + assert_eq!( + expected_splits, + &generate_line_segments( + &grid, + tracks, + y, + &[ + Line { + index: y, + start: 0, + end: None, + stroke: Some(stroke.clone()), + position: LinePosition::Before + }, + Line { + index: y, + start: 0, + end: None, + stroke: Some(stroke.clone()), + position: LinePosition::After + }, + ], + |grid, y, x, stroke| hline_stroke_at_column( + grid, + &rows, + y.checked_sub(1), + true, + y, + x, + stroke + ) + ) + .collect::<Vec<_>>(), + ); + } + } + + #[test] + fn test_hline_splitting_considers_absent_rows() { + let grid = sample_grid_for_hlines(false); + let columns = &[Abs::pt(1.), Abs::pt(2.), Abs::pt(4.), Abs::pt(8.)]; + // Assume row 3 is absent (even though there's a rowspan between rows + // 3 and 4) + // This can happen if it is an auto row which turns out to be fully + // empty. + let rows = grid + .rows + .iter() + .enumerate() + .filter(|(y, _)| *y != 3) + .map(|(y, _)| RowPiece { height: Abs::pt(f64::from(2u32.pow(y as u32))), y }) + .collect::<Vec<_>>(); + + // Hline above row 4 is no longer blocked, since the rowspan is now + // effectively spanning just one row (at least, visibly). + assert_eq!( + &vec![LineSegment { + stroke: Arc::new(Stroke::default()), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8.), + priority: StrokePriority::GridStroke + }], + &generate_line_segments( + &grid, + columns.iter().copied().enumerate(), + 4, + &[], + |grid, y, x, stroke| hline_stroke_at_column( + grid, + &rows, + if y == 4 { Some(2) } else { y.checked_sub(1) }, + true, + y, + x, + stroke + ) + ) + .collect::<Vec<_>>() + ); + } +} diff --git a/crates/typst-layout/src/grid/mod.rs b/crates/typst-layout/src/grid/mod.rs new file mode 100644 index 00000000..769bef8c --- /dev/null +++ b/crates/typst-layout/src/grid/mod.rs @@ -0,0 +1,416 @@ +mod cells; +mod layouter; +mod lines; +mod repeated; +mod rowspans; + +pub use self::cells::{Cell, CellGrid}; +pub use self::layouter::GridLayouter; + +use std::num::NonZeroUsize; +use std::sync::Arc; + +use ecow::eco_format; +use typst_library::diag::{SourceResult, Trace, Tracepoint}; +use typst_library::engine::Engine; +use typst_library::foundations::{Fold, Packed, Smart, StyleChain}; +use typst_library::introspection::Locator; +use typst_library::layout::{ + Abs, Alignment, Axes, Dir, Fragment, GridCell, GridChild, GridElem, GridItem, Length, + OuterHAlignment, OuterVAlignment, Regions, Rel, Sides, +}; +use typst_library::model::{TableCell, TableChild, TableElem, TableItem}; +use typst_library::text::TextElem; +use typst_library::visualize::{Paint, Stroke}; +use typst_syntax::Span; + +use self::cells::{ + LinePosition, ResolvableCell, ResolvableGridChild, ResolvableGridItem, +}; +use self::layouter::RowPiece; +use self::lines::{ + generate_line_segments, hline_stroke_at_column, vline_stroke_at_row, Line, + LineSegment, +}; +use self::repeated::{Footer, Header, Repeatable}; +use self::rowspans::{Rowspan, UnbreakableRowGroup}; + +/// Layout the grid. +#[typst_macros::time(span = elem.span())] +pub fn layout_grid( + elem: &Packed<GridElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + regions: Regions, +) -> SourceResult<Fragment> { + let inset = elem.inset(styles); + let align = elem.align(styles); + let columns = elem.columns(styles); + let rows = elem.rows(styles); + let column_gutter = elem.column_gutter(styles); + let row_gutter = elem.row_gutter(styles); + let fill = elem.fill(styles); + let stroke = elem.stroke(styles); + + let tracks = Axes::new(columns.0.as_slice(), rows.0.as_slice()); + let gutter = Axes::new(column_gutter.0.as_slice(), row_gutter.0.as_slice()); + // Use trace to link back to the grid when a specific cell errors + let tracepoint = || Tracepoint::Call(Some(eco_format!("grid"))); + let resolve_item = |item: &GridItem| grid_item_to_resolvable(item, styles); + let children = elem.children().iter().map(|child| match child { + GridChild::Header(header) => ResolvableGridChild::Header { + repeat: header.repeat(styles), + span: header.span(), + items: header.children().iter().map(resolve_item), + }, + GridChild::Footer(footer) => ResolvableGridChild::Footer { + repeat: footer.repeat(styles), + span: footer.span(), + items: footer.children().iter().map(resolve_item), + }, + GridChild::Item(item) => { + ResolvableGridChild::Item(grid_item_to_resolvable(item, styles)) + } + }); + let grid = CellGrid::resolve( + tracks, + gutter, + locator, + children, + fill, + align, + &inset, + &stroke, + engine, + styles, + elem.span(), + ) + .trace(engine.world, tracepoint, elem.span())?; + + let layouter = GridLayouter::new(&grid, regions, styles, elem.span()); + + // Measure the columns and layout the grid row-by-row. + layouter.layout(engine) +} + +/// Layout the table. +#[typst_macros::time(span = elem.span())] +pub fn layout_table( + elem: &Packed<TableElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + regions: Regions, +) -> SourceResult<Fragment> { + let inset = elem.inset(styles); + let align = elem.align(styles); + let columns = elem.columns(styles); + let rows = elem.rows(styles); + let column_gutter = elem.column_gutter(styles); + let row_gutter = elem.row_gutter(styles); + let fill = elem.fill(styles); + let stroke = elem.stroke(styles); + + let tracks = Axes::new(columns.0.as_slice(), rows.0.as_slice()); + let gutter = Axes::new(column_gutter.0.as_slice(), row_gutter.0.as_slice()); + // Use trace to link back to the table when a specific cell errors + let tracepoint = || Tracepoint::Call(Some(eco_format!("table"))); + let resolve_item = |item: &TableItem| table_item_to_resolvable(item, styles); + let children = elem.children().iter().map(|child| match child { + TableChild::Header(header) => ResolvableGridChild::Header { + repeat: header.repeat(styles), + span: header.span(), + items: header.children().iter().map(resolve_item), + }, + TableChild::Footer(footer) => ResolvableGridChild::Footer { + repeat: footer.repeat(styles), + span: footer.span(), + items: footer.children().iter().map(resolve_item), + }, + TableChild::Item(item) => { + ResolvableGridChild::Item(table_item_to_resolvable(item, styles)) + } + }); + let grid = CellGrid::resolve( + tracks, + gutter, + locator, + children, + fill, + align, + &inset, + &stroke, + engine, + styles, + elem.span(), + ) + .trace(engine.world, tracepoint, elem.span())?; + + let layouter = GridLayouter::new(&grid, regions, styles, elem.span()); + layouter.layout(engine) +} + +fn grid_item_to_resolvable( + item: &GridItem, + styles: StyleChain, +) -> ResolvableGridItem<Packed<GridCell>> { + match item { + GridItem::HLine(hline) => ResolvableGridItem::HLine { + y: hline.y(styles), + start: hline.start(styles), + end: hline.end(styles), + stroke: hline.stroke(styles), + span: hline.span(), + position: match hline.position(styles) { + OuterVAlignment::Top => LinePosition::Before, + OuterVAlignment::Bottom => LinePosition::After, + }, + }, + GridItem::VLine(vline) => ResolvableGridItem::VLine { + x: vline.x(styles), + start: vline.start(styles), + end: vline.end(styles), + stroke: vline.stroke(styles), + span: vline.span(), + position: match vline.position(styles) { + OuterHAlignment::Left if TextElem::dir_in(styles) == Dir::RTL => { + LinePosition::After + } + OuterHAlignment::Right if TextElem::dir_in(styles) == Dir::RTL => { + LinePosition::Before + } + OuterHAlignment::Start | OuterHAlignment::Left => LinePosition::Before, + OuterHAlignment::End | OuterHAlignment::Right => LinePosition::After, + }, + }, + GridItem::Cell(cell) => ResolvableGridItem::Cell(cell.clone()), + } +} + +fn table_item_to_resolvable( + item: &TableItem, + styles: StyleChain, +) -> ResolvableGridItem<Packed<TableCell>> { + match item { + TableItem::HLine(hline) => ResolvableGridItem::HLine { + y: hline.y(styles), + start: hline.start(styles), + end: hline.end(styles), + stroke: hline.stroke(styles), + span: hline.span(), + position: match hline.position(styles) { + OuterVAlignment::Top => LinePosition::Before, + OuterVAlignment::Bottom => LinePosition::After, + }, + }, + TableItem::VLine(vline) => ResolvableGridItem::VLine { + x: vline.x(styles), + start: vline.start(styles), + end: vline.end(styles), + stroke: vline.stroke(styles), + span: vline.span(), + position: match vline.position(styles) { + OuterHAlignment::Left if TextElem::dir_in(styles) == Dir::RTL => { + LinePosition::After + } + OuterHAlignment::Right if TextElem::dir_in(styles) == Dir::RTL => { + LinePosition::Before + } + OuterHAlignment::Start | OuterHAlignment::Left => LinePosition::Before, + OuterHAlignment::End | OuterHAlignment::Right => LinePosition::After, + }, + }, + TableItem::Cell(cell) => ResolvableGridItem::Cell(cell.clone()), + } +} + +impl ResolvableCell for Packed<TableCell> { + fn resolve_cell<'a>( + mut self, + x: usize, + y: usize, + fill: &Option<Paint>, + align: Smart<Alignment>, + inset: Sides<Option<Rel<Length>>>, + stroke: Sides<Option<Option<Arc<Stroke<Abs>>>>>, + breakable: bool, + locator: Locator<'a>, + styles: StyleChain, + ) -> Cell<'a> { + let cell = &mut *self; + let colspan = cell.colspan(styles); + let rowspan = cell.rowspan(styles); + let breakable = cell.breakable(styles).unwrap_or(breakable); + let fill = cell.fill(styles).unwrap_or_else(|| fill.clone()); + + let cell_stroke = cell.stroke(styles); + let stroke_overridden = + cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_)))); + + // Using a typical 'Sides' fold, an unspecified side loses to a + // specified side. Additionally, when both are specified, an inner + // None wins over the outer Some, and vice-versa. When both are + // specified and Some, fold occurs, which, remarkably, leads to an Arc + // clone. + // + // In the end, we flatten because, for layout purposes, an unspecified + // cell stroke is the same as specifying 'none', so we equate the two + // concepts. + let stroke = cell_stroke.fold(stroke).map(Option::flatten); + cell.push_x(Smart::Custom(x)); + cell.push_y(Smart::Custom(y)); + cell.push_fill(Smart::Custom(fill.clone())); + cell.push_align(match align { + Smart::Custom(align) => { + Smart::Custom(cell.align(styles).map_or(align, |inner| inner.fold(align))) + } + // Don't fold if the table is using outer alignment. Use the + // cell's alignment instead (which, in the end, will fold with + // the outer alignment when it is effectively displayed). + Smart::Auto => cell.align(styles), + }); + cell.push_inset(Smart::Custom( + cell.inset(styles).map_or(inset, |inner| inner.fold(inset)), + )); + cell.push_stroke( + // Here we convert the resolved stroke to a regular stroke, however + // with resolved units (that is, 'em' converted to absolute units). + // We also convert any stroke unspecified by both the cell and the + // outer stroke ('None' in the folded stroke) to 'none', that is, + // all sides are present in the resulting Sides object accessible + // by show rules on table cells. + stroke.as_ref().map(|side| { + Some(side.as_ref().map(|cell_stroke| { + Arc::new((**cell_stroke).clone().map(Length::from)) + })) + }), + ); + cell.push_breakable(Smart::Custom(breakable)); + Cell { + body: self.pack(), + locator, + fill, + colspan, + rowspan, + stroke, + stroke_overridden, + breakable, + } + } + + fn x(&self, styles: StyleChain) -> Smart<usize> { + (**self).x(styles) + } + + fn y(&self, styles: StyleChain) -> Smart<usize> { + (**self).y(styles) + } + + fn colspan(&self, styles: StyleChain) -> NonZeroUsize { + (**self).colspan(styles) + } + + fn rowspan(&self, styles: StyleChain) -> NonZeroUsize { + (**self).rowspan(styles) + } + + fn span(&self) -> Span { + Packed::span(self) + } +} + +impl ResolvableCell for Packed<GridCell> { + fn resolve_cell<'a>( + mut self, + x: usize, + y: usize, + fill: &Option<Paint>, + align: Smart<Alignment>, + inset: Sides<Option<Rel<Length>>>, + stroke: Sides<Option<Option<Arc<Stroke<Abs>>>>>, + breakable: bool, + locator: Locator<'a>, + styles: StyleChain, + ) -> Cell<'a> { + let cell = &mut *self; + let colspan = cell.colspan(styles); + let rowspan = cell.rowspan(styles); + let breakable = cell.breakable(styles).unwrap_or(breakable); + let fill = cell.fill(styles).unwrap_or_else(|| fill.clone()); + + let cell_stroke = cell.stroke(styles); + let stroke_overridden = + cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_)))); + + // Using a typical 'Sides' fold, an unspecified side loses to a + // specified side. Additionally, when both are specified, an inner + // None wins over the outer Some, and vice-versa. When both are + // specified and Some, fold occurs, which, remarkably, leads to an Arc + // clone. + // + // In the end, we flatten because, for layout purposes, an unspecified + // cell stroke is the same as specifying 'none', so we equate the two + // concepts. + let stroke = cell_stroke.fold(stroke).map(Option::flatten); + cell.push_x(Smart::Custom(x)); + cell.push_y(Smart::Custom(y)); + cell.push_fill(Smart::Custom(fill.clone())); + cell.push_align(match align { + Smart::Custom(align) => { + Smart::Custom(cell.align(styles).map_or(align, |inner| inner.fold(align))) + } + // Don't fold if the grid is using outer alignment. Use the + // cell's alignment instead (which, in the end, will fold with + // the outer alignment when it is effectively displayed). + Smart::Auto => cell.align(styles), + }); + cell.push_inset(Smart::Custom( + cell.inset(styles).map_or(inset, |inner| inner.fold(inset)), + )); + cell.push_stroke( + // Here we convert the resolved stroke to a regular stroke, however + // with resolved units (that is, 'em' converted to absolute units). + // We also convert any stroke unspecified by both the cell and the + // outer stroke ('None' in the folded stroke) to 'none', that is, + // all sides are present in the resulting Sides object accessible + // by show rules on grid cells. + stroke.as_ref().map(|side| { + Some(side.as_ref().map(|cell_stroke| { + Arc::new((**cell_stroke).clone().map(Length::from)) + })) + }), + ); + cell.push_breakable(Smart::Custom(breakable)); + Cell { + body: self.pack(), + locator, + fill, + colspan, + rowspan, + stroke, + stroke_overridden, + breakable, + } + } + + fn x(&self, styles: StyleChain) -> Smart<usize> { + (**self).x(styles) + } + + fn y(&self, styles: StyleChain) -> Smart<usize> { + (**self).y(styles) + } + + fn colspan(&self, styles: StyleChain) -> NonZeroUsize { + (**self).colspan(styles) + } + + fn rowspan(&self, styles: StyleChain) -> NonZeroUsize { + (**self).rowspan(styles) + } + + fn span(&self) -> Span { + Packed::span(self) + } +} diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs new file mode 100644 index 00000000..972179da --- /dev/null +++ b/crates/typst-layout/src/grid/repeated.rs @@ -0,0 +1,192 @@ +use typst_library::diag::SourceResult; +use typst_library::engine::Engine; +use typst_library::layout::{Abs, Axes, Frame, Regions}; + +use super::layouter::GridLayouter; +use super::rowspans::UnbreakableRowGroup; + +/// A repeatable grid header. Starts at the first row. +pub struct Header { + /// The index after the last row included in this header. + pub end: usize, +} + +/// A repeatable grid footer. Stops at the last row. +pub struct Footer { + /// The first row included in this footer. + pub start: usize, +} + +/// A possibly repeatable grid object. +/// It still exists even when not repeatable, but must not have additional +/// considerations by grid layout, other than for consistency (such as making +/// a certain group of rows unbreakable). +pub enum Repeatable<T> { + Repeated(T), + NotRepeated(T), +} + +impl<T> Repeatable<T> { + /// Gets the value inside this repeatable, regardless of whether + /// it repeats. + pub fn unwrap(&self) -> &T { + match self { + Self::Repeated(repeated) => repeated, + Self::NotRepeated(not_repeated) => not_repeated, + } + } + + /// Returns `Some` if the value is repeated, `None` otherwise. + pub fn as_repeated(&self) -> Option<&T> { + match self { + Self::Repeated(repeated) => Some(repeated), + Self::NotRepeated(_) => None, + } + } +} + +impl<'a> GridLayouter<'a> { + /// Layouts the header's rows. + /// Skips regions as necessary. + pub fn layout_header( + &mut self, + header: &Header, + engine: &mut Engine, + disambiguator: usize, + ) -> SourceResult<()> { + let header_rows = + self.simulate_header(header, &self.regions, engine, disambiguator)?; + let mut skipped_region = false; + while self.unbreakable_rows_left == 0 + && !self.regions.size.y.fits(header_rows.height + self.footer_height) + && self.regions.may_progress() + { + // Advance regions without any output until we can place the + // header and the footer. + self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]); + skipped_region = true; + } + + // Reset the header height for this region. + // It will be re-calculated when laying out each header row. + self.header_height = Abs::zero(); + + if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + if skipped_region { + // Simulate the footer again; the region's 'full' might have + // changed. + self.footer_height = self + .simulate_footer(footer, &self.regions, engine, disambiguator)? + .height; + } + } + + // Header is unbreakable. + // Thus, no risk of 'finish_region' being recursively called from + // within 'layout_row'. + self.unbreakable_rows_left += header.end; + for y in 0..header.end { + self.layout_row(y, engine, disambiguator)?; + } + Ok(()) + } + + /// Simulate the header's group of rows. + pub fn simulate_header( + &self, + header: &Header, + regions: &Regions<'_>, + engine: &mut Engine, + disambiguator: usize, + ) -> SourceResult<UnbreakableRowGroup> { + // Note that we assume the invariant that any rowspan in a header is + // fully contained within that header. Therefore, there won't be any + // unbreakable rowspans exceeding the header's rows, and we can safely + // assume that the amount of unbreakable rows following the first row + // in the header will be precisely the rows in the header. + self.simulate_unbreakable_row_group( + 0, + Some(header.end), + regions, + engine, + disambiguator, + ) + } + + /// Updates `self.footer_height` by simulating the footer, and skips to fitting region. + pub fn prepare_footer( + &mut self, + footer: &Footer, + engine: &mut Engine, + disambiguator: usize, + ) -> SourceResult<()> { + let footer_height = self + .simulate_footer(footer, &self.regions, engine, disambiguator)? + .height; + let mut skipped_region = false; + while self.unbreakable_rows_left == 0 + && !self.regions.size.y.fits(footer_height) + && self.regions.may_progress() + { + // Advance regions without any output until we can place the + // footer. + self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]); + skipped_region = true; + } + + self.footer_height = if skipped_region { + // Simulate the footer again; the region's 'full' might have + // changed. + self.simulate_footer(footer, &self.regions, engine, disambiguator)? + .height + } else { + footer_height + }; + + Ok(()) + } + + /// Lays out all rows in the footer. + /// They are unbreakable. + pub fn layout_footer( + &mut self, + footer: &Footer, + engine: &mut Engine, + disambiguator: usize, + ) -> SourceResult<()> { + // Ensure footer rows have their own height available. + // Won't change much as we're creating an unbreakable row group + // anyway, so this is mostly for correctness. + self.regions.size.y += self.footer_height; + + let footer_len = self.grid.rows.len() - footer.start; + self.unbreakable_rows_left += footer_len; + for y in footer.start..self.grid.rows.len() { + self.layout_row(y, engine, disambiguator)?; + } + + Ok(()) + } + + // Simulate the footer's group of rows. + pub fn simulate_footer( + &self, + footer: &Footer, + regions: &Regions<'_>, + engine: &mut Engine, + disambiguator: usize, + ) -> SourceResult<UnbreakableRowGroup> { + // Note that we assume the invariant that any rowspan in a footer is + // fully contained within that footer. Therefore, there won't be any + // unbreakable rowspans exceeding the footer's rows, and we can safely + // assume that the amount of unbreakable rows following the first row + // in the footer will be precisely the rows in the footer. + self.simulate_unbreakable_row_group( + footer.start, + Some(self.grid.rows.len() - footer.start), + regions, + engine, + disambiguator, + ) + } +} diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs new file mode 100644 index 00000000..03b4103f --- /dev/null +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -0,0 +1,1217 @@ +use typst_library::diag::SourceResult; +use typst_library::engine::Engine; +use typst_library::foundations::Resolve; +use typst_library::layout::{Abs, Axes, Frame, Point, Region, Regions, Size, Sizing}; +use typst_utils::MaybeReverseIter; + +use super::layouter::{in_last_with_offset, points, Row, RowPiece}; +use super::repeated::Repeatable; +use super::{Cell, GridLayouter}; + +/// All information needed to layout a single rowspan. +pub struct Rowspan { + /// First column of this rowspan. + pub x: usize, + /// First row of this rowspan. + pub y: usize, + /// The disambiguator for laying out the cells. + pub disambiguator: usize, + /// Amount of rows spanned by the cell at (x, y). + pub rowspan: usize, + /// Whether all rows of the rowspan are part of an unbreakable row group. + /// This is true e.g. in headers and footers, regardless of what the user + /// specified for the parent cell's `breakable` field. + pub is_effectively_unbreakable: bool, + /// The horizontal offset of this rowspan in all regions. + pub dx: Abs, + /// The vertical offset of this rowspan in the first region. + pub dy: Abs, + /// The index of the first region this rowspan appears in. + pub first_region: usize, + /// The full height in the first region this rowspan appears in, for + /// relative sizing. + pub region_full: Abs, + /// The vertical space available for this rowspan in each region. + pub heights: Vec<Abs>, + /// The index of the largest resolved spanned row so far. + /// Once a spanned row is resolved and its height added to `heights`, this + /// number is increased. Older rows, even if repeated through e.g. a + /// header, will no longer contribute height to this rowspan. + /// + /// This is `None` if no spanned rows were resolved in `finish_region` yet. + pub max_resolved_row: Option<usize>, +} + +/// The output of the simulation of an unbreakable row group. +#[derive(Default)] +pub struct UnbreakableRowGroup { + /// The rows in this group of unbreakable rows. + /// Includes their indices and their predicted heights. + pub rows: Vec<(usize, Abs)>, + /// The total height of this row group. + pub height: Abs, +} + +/// Data used to measure a cell in an auto row. +pub struct CellMeasurementData<'layouter> { + /// The available width for the cell across all regions. + pub width: Abs, + /// The available height for the cell in its first region. + /// Infinite when the auto row is unbreakable. + pub height: Abs, + /// The backlog of heights available for the cell in later regions. + /// + /// When this is `None`, the `custom_backlog` field should be used instead. + /// That's because, otherwise, this field would have to contain a reference + /// to the `custom_backlog` field, which isn't possible in Rust without + /// resorting to unsafe hacks. + pub backlog: Option<&'layouter [Abs]>, + /// If the backlog needs to be built from scratch instead of reusing the + /// one at the current region, which is the case of a multi-region rowspan + /// (needs to join its backlog of already laid out heights with the current + /// backlog), then this vector will store the new backlog. + pub custom_backlog: Vec<Abs>, + /// The full height of the first region of the cell. + /// Infinite when the auto row is unbreakable. + pub full: Abs, + /// The height of the last repeated region to use in the measurement pod, + /// if any. + pub last: Option<Abs>, + /// The total height of previous rows spanned by the cell in the current + /// region (so far). + pub height_in_this_region: Abs, + /// The amount of previous regions spanned by the cell. + /// They are skipped for measurement purposes. + pub frames_in_previous_regions: usize, +} + +impl<'a> GridLayouter<'a> { + /// Layout a rowspan over the already finished regions, plus the current + /// region's frame and resolved rows, if it wasn't finished yet (because + /// we're being called from `finish_region`, but note that this function is + /// also called once after all regions are finished, in which case + /// `current_region_data` is `None`). + /// + /// We need to do this only once we already know the heights of all + /// spanned rows, which is only possible after laying out the last row + /// spanned by the rowspan (or some row immediately after the last one). + pub fn layout_rowspan( + &mut self, + rowspan_data: Rowspan, + current_region_data: Option<(&mut Frame, &[RowPiece])>, + engine: &mut Engine, + ) -> SourceResult<()> { + let Rowspan { + x, + y, + disambiguator, + rowspan, + is_effectively_unbreakable, + dx, + dy, + first_region, + region_full, + heights, + .. + } = rowspan_data; + let [first_height, backlog @ ..] = heights.as_slice() else { + // Nothing to layout. + return Ok(()); + }; + let first_column = self.rcols[x]; + let cell = self.grid.cell(x, y).unwrap(); + let width = self.cell_spanned_width(cell, x); + let dx = if self.is_rtl { dx - width + first_column } else { dx }; + + // Prepare regions. + let size = Size::new(width, *first_height); + let mut pod: Regions = Region::new(size, Axes::splat(true)).into(); + pod.backlog = backlog; + + if !is_effectively_unbreakable + && self.grid.rows[y..][..rowspan] + .iter() + .any(|spanned_row| spanned_row == &Sizing::Auto) + { + // If the rowspan spans an auto row and is breakable, it will see + // '100%' as the full page height, at least at its first region. + // This is consistent with how it is measured, and with how + // non-rowspan cells behave in auto rows. + pod.full = region_full; + } + + // Push the layouted frames directly into the finished frames. + let fragment = cell.layout(engine, disambiguator, self.styles, pod)?; + let (current_region, current_rrows) = current_region_data.unzip(); + for ((i, finished), frame) in self + .finished + .iter_mut() + .chain(current_region.into_iter()) + .skip(first_region) + .enumerate() + .zip(fragment) + { + let dy = if i == 0 { + // At first, we draw the rowspan starting at its expected + // vertical offset in the first region. + dy + } else { + // The rowspan continuation starts after the header (thus, + // at a position after the sum of the laid out header + // rows). + if let Some(Repeatable::Repeated(header)) = &self.grid.header { + let header_rows = self + .rrows + .get(i) + .map(Vec::as_slice) + .or(current_rrows) + .unwrap_or(&[]) + .iter() + .take_while(|row| row.y < header.end); + + header_rows.map(|row| row.height).sum() + } else { + // Without a header, start at the very top of the region. + Abs::zero() + } + }; + + finished.push_frame(Point::new(dx, dy), frame); + } + + Ok(()) + } + + /// Checks if a row contains the beginning of one or more rowspan cells. + /// If so, adds them to the rowspans vector. + pub fn check_for_rowspans(&mut self, disambiguator: usize, y: usize) { + // We will compute the horizontal offset of each rowspan in advance. + // For that reason, we must reverse the column order when using RTL. + let offsets = points(self.rcols.iter().copied().rev_if(self.is_rtl)); + for (x, dx) in (0..self.rcols.len()).rev_if(self.is_rtl).zip(offsets) { + let Some(cell) = self.grid.cell(x, y) else { + continue; + }; + let rowspan = self.grid.effective_rowspan_of_cell(cell); + if rowspan > 1 { + // Rowspan detected. We will lay it out later. + self.rowspans.push(Rowspan { + x, + y, + disambiguator, + rowspan, + // The field below will be updated in + // 'check_for_unbreakable_rows'. + is_effectively_unbreakable: !cell.breakable, + dx, + // The four fields below will be updated in 'finish_region'. + dy: Abs::zero(), + first_region: usize::MAX, + region_full: Abs::zero(), + heights: vec![], + max_resolved_row: None, + }); + } + } + } + + /// Checks if the upcoming rows will be grouped together under an + /// unbreakable row group, and, if so, advances regions until there is + /// enough space for them. This can be needed, for example, if there's an + /// unbreakable rowspan crossing those rows. + pub fn check_for_unbreakable_rows( + &mut self, + current_row: usize, + engine: &mut Engine, + ) -> SourceResult<()> { + if self.unbreakable_rows_left == 0 { + // By default, the amount of unbreakable rows starting at the + // current row is dynamic and depends on the amount of upcoming + // unbreakable cells (with or without a rowspan setting). + let mut amount_unbreakable_rows = None; + if let Some(Repeatable::NotRepeated(header)) = &self.grid.header { + if current_row < header.end { + // Non-repeated header, so keep it unbreakable. + amount_unbreakable_rows = Some(header.end); + } + } + if let Some(Repeatable::NotRepeated(footer)) = &self.grid.footer { + if current_row >= footer.start { + // Non-repeated footer, so keep it unbreakable. + amount_unbreakable_rows = Some(self.grid.rows.len() - footer.start); + } + } + + let row_group = self.simulate_unbreakable_row_group( + current_row, + amount_unbreakable_rows, + &self.regions, + engine, + 0, + )?; + + // Skip to fitting region. + while !self.regions.size.y.fits(row_group.height) + && !in_last_with_offset( + self.regions, + self.header_height + self.footer_height, + ) + { + self.finish_region(engine, false)?; + } + + // Update unbreakable rows left. + self.unbreakable_rows_left = row_group.rows.len(); + } + + if self.unbreakable_rows_left > 1 { + // Mark rowspans as effectively unbreakable where applicable + // (if all of their spanned rows would be in the same unbreakable + // row group). + // Not needed if only one unbreakable row is left, since, then, + // no rowspan will be effectively unbreakable, at least necessarily. + // Note that this function is called after 'check_for_rowspans' and + // potentially updates the amount of remaining unbreakable rows, so + // it wouldn't be accurate to only check for this condition in that + // function. We need to check here instead. + for rowspan_data in + self.rowspans.iter_mut().filter(|rowspan| rowspan.y == current_row) + { + rowspan_data.is_effectively_unbreakable |= + self.unbreakable_rows_left >= rowspan_data.rowspan; + } + } + + Ok(()) + } + + /// Simulates a group of unbreakable rows, starting with the index of the + /// first row in the group. If `amount_unbreakable_rows` is `None`, keeps + /// adding rows to the group until none have unbreakable cells in common. + /// Otherwise, adds specifically the given amount of rows to the group. + /// + /// This is used to figure out how much height the next unbreakable row + /// group (if any) needs. + pub fn simulate_unbreakable_row_group( + &self, + first_row: usize, + amount_unbreakable_rows: Option<usize>, + regions: &Regions<'_>, + engine: &mut Engine, + disambiguator: usize, + ) -> SourceResult<UnbreakableRowGroup> { + let mut row_group = UnbreakableRowGroup::default(); + let mut unbreakable_rows_left = amount_unbreakable_rows.unwrap_or(0); + for (y, row) in self.grid.rows.iter().enumerate().skip(first_row) { + if amount_unbreakable_rows.is_none() { + // When we don't set a fixed amount of unbreakable rows, + // determine the amount based on the rowspan of unbreakable + // cells in rows. + let additional_unbreakable_rows = self.check_for_unbreakable_cells(y); + unbreakable_rows_left = + unbreakable_rows_left.max(additional_unbreakable_rows); + } + if unbreakable_rows_left == 0 { + // This check is in case the first row does not have any + // unbreakable cells. Therefore, no unbreakable row group + // is formed. + break; + } + let height = match row { + Sizing::Rel(v) => v.resolve(self.styles).relative_to(regions.base().y), + + // No need to pass the regions to the auto row, since + // unbreakable auto rows are always measured with infinite + // height, ignore backlog, and do not invoke the rowspan + // simulation procedure at all. + Sizing::Auto => self + .measure_auto_row( + engine, + disambiguator, + y, + false, + unbreakable_rows_left, + Some(&row_group), + )? + .unwrap() + .first() + .copied() + .unwrap_or_else(Abs::zero), + // Fractional rows don't matter when calculating the space + // needed for unbreakable rows + Sizing::Fr(_) => Abs::zero(), + }; + row_group.height += height; + row_group.rows.push((y, height)); + unbreakable_rows_left -= 1; + if unbreakable_rows_left == 0 { + // This second check is necessary so we can tell distinct + // but consecutive unbreakable row groups apart. If the + // unbreakable row group ended at this row, we stop before + // checking the next one. + break; + } + } + + Ok(row_group) + } + + /// Checks if one or more of the cells at the given row are unbreakable. + /// If so, returns the largest rowspan among the unbreakable cells; + /// the spanned rows must, as a result, be laid out in the same region. + pub fn check_for_unbreakable_cells(&self, y: usize) -> usize { + (0..self.grid.cols.len()) + .filter_map(|x| self.grid.cell(x, y)) + .filter(|cell| !cell.breakable) + .map(|cell| self.grid.effective_rowspan_of_cell(cell)) + .max() + .unwrap_or(0) + } + + /// Used by `measure_auto_row` to gather data needed to measure the cell. + pub fn prepare_auto_row_cell_measurement( + &self, + parent: Axes<usize>, + cell: &Cell, + breakable: bool, + row_group_data: Option<&UnbreakableRowGroup>, + ) -> CellMeasurementData<'_> { + let rowspan = self.grid.effective_rowspan_of_cell(cell); + + // This variable is used to construct a custom backlog if the cell + // is a rowspan, or if headers or footers are used. When measuring, we + // join the heights from previous regions to the current backlog to + // form a rowspan's expected backlog. We also subtract the header's + // and footer's heights from all regions. + let mut custom_backlog: Vec<Abs> = vec![]; + + // This function is used to subtract the expected header and footer + // height from each upcoming region size in the current backlog and + // last region. + let mut subtract_header_footer_height_from_regions = || { + // Only breakable auto rows need to update their backlogs based + // on the presence of a header or footer, given that unbreakable + // auto rows don't depend on the backlog, as they only span one + // region. + if breakable + && (matches!(self.grid.header, Some(Repeatable::Repeated(_))) + || matches!(self.grid.footer, Some(Repeatable::Repeated(_)))) + { + // Subtract header and footer height from all upcoming regions + // when measuring the cell, including the last repeated region. + // + // This will update the 'custom_backlog' vector with the + // updated heights of the upcoming regions. + let mapped_regions = self.regions.map(&mut custom_backlog, |size| { + Size::new(size.x, size.y - self.header_height - self.footer_height) + }); + + // Callees must use the custom backlog instead of the current + // backlog, so we return 'None'. + return (None, mapped_regions.last); + } + + // No need to change the backlog or last region. + (Some(self.regions.backlog), self.regions.last) + }; + + // Each declaration, from top to bottom: + // 1. The height available to the cell in the first region. + // Usually, this will just be the size remaining in the current + // region. + // 2. The backlog of upcoming region heights to specify as + // available to the cell. + // 3. The full height of the first region of the cell. + // 4. Height of the last repeated region to use in the measurement pod. + // 5. The total height of the cell covered by previously spanned + // rows in this region. This is used by rowspans to be able to tell + // how much the auto row needs to expand. + // 6. The amount of frames laid out by this cell in previous + // regions. When the cell isn't a rowspan, this is always zero. + // These frames are skipped after measuring. + let height; + let backlog; + let full; + let last; + let height_in_this_region; + let frames_in_previous_regions; + + if rowspan == 1 { + // Not a rowspan, so the cell only occupies this row. Therefore: + // 1. When we measure the cell below, use the available height + // remaining in the region as the height it has available. + // However, if the auto row is unbreakable, measure with infinite + // height instead to see how much content expands. + // 2. Use the region's backlog and last region when measuring, + // however subtract the expected header and footer heights from + // each upcoming size, if there is a header or footer. + // 3. Use the same full region height. + // 4. No height occupied by this cell in this region so far. + // 5. Yes, this cell started in this region. + height = if breakable { self.regions.size.y } else { Abs::inf() }; + (backlog, last) = subtract_header_footer_height_from_regions(); + full = if breakable { self.regions.full } else { Abs::inf() }; + height_in_this_region = Abs::zero(); + frames_in_previous_regions = 0; + } else { + // Height of the rowspan covered by spanned rows in the current + // region. + let laid_out_height: Abs = self + .lrows + .iter() + .filter_map(|row| match row { + Row::Frame(frame, y, _) + if (parent.y..parent.y + rowspan).contains(y) => + { + Some(frame.height()) + } + // Either we have a row outside of the rowspan, or a + // fractional row, whose size we can't really guess. + _ => None, + }) + .sum(); + + // If we're currently simulating an unbreakable row group, also + // consider the height of previously spanned rows which are in + // the row group but not yet laid out. + let unbreakable_height: Abs = row_group_data + .into_iter() + .flat_map(|row_group| &row_group.rows) + .filter(|(y, _)| (parent.y..parent.y + rowspan).contains(y)) + .map(|(_, height)| height) + .sum(); + + height_in_this_region = laid_out_height + unbreakable_height; + + // Ensure we will measure the rowspan with the correct heights. + // For that, we will gather the total height spanned by this + // rowspan in previous regions. + if let Some((rowspan_full, [rowspan_height, rowspan_other_heights @ ..])) = + self.rowspans + .iter() + .find(|data| data.x == parent.x && data.y == parent.y) + .map(|data| (data.region_full, &*data.heights)) + { + // The rowspan started in a previous region (as it already + // has at least one region height). + // Therefore, its initial height will be the height in its + // first spanned region, and the backlog will be the + // remaining heights, plus the current region's size, plus + // the current backlog. + frames_in_previous_regions = rowspan_other_heights.len() + 1; + + let heights_up_to_current_region = rowspan_other_heights + .iter() + .copied() + .chain(std::iter::once(if breakable { + self.initial.y - self.header_height - self.footer_height + } else { + // When measuring unbreakable auto rows, infinite + // height is available for content to expand. + Abs::inf() + })); + + custom_backlog = if breakable { + // This auto row is breakable. Therefore, join the + // rowspan's already laid out heights with the current + // region's height and current backlog to ensure a good + // level of accuracy in the measurements. + let backlog = self + .regions + .backlog + .iter() + .map(|&size| size - self.header_height - self.footer_height); + + heights_up_to_current_region.chain(backlog).collect::<Vec<_>>() + } else { + // No extra backlog if this is an unbreakable auto row. + // Ensure, when measuring, that the rowspan can be laid + // out through all spanned rows which were already laid + // out so far, but don't go further than this region. + heights_up_to_current_region.collect::<Vec<_>>() + }; + + height = *rowspan_height; + backlog = None; + full = rowspan_full; + last = self + .regions + .last + .map(|size| size - self.header_height - self.footer_height); + } else { + // The rowspan started in the current region, as its vector + // of heights in regions is currently empty. + // Therefore, the initial height it has available will be + // the current available size, plus the size spanned in + // previous rows in this region (and/or unbreakable row + // group, if it's being simulated). + // The backlog and full will be that of the current region. + // However, use infinite height instead if we're measuring an + // unbreakable auto row. + height = if breakable { + height_in_this_region + self.regions.size.y + } else { + Abs::inf() + }; + (backlog, last) = subtract_header_footer_height_from_regions(); + full = if breakable { self.regions.full } else { Abs::inf() }; + frames_in_previous_regions = 0; + } + } + + let width = self.cell_spanned_width(cell, parent.x); + CellMeasurementData { + width, + height, + backlog, + custom_backlog, + full, + last, + height_in_this_region, + frames_in_previous_regions, + } + } + + /// Used in `measure_auto_row` to prepare a rowspan's `sizes` vector. + /// Returns `true` if we'll need to run a simulation to more accurately + /// expand the auto row based on the rowspan's demanded size, or `false` + /// otherwise. + #[allow(clippy::too_many_arguments)] + pub fn prepare_rowspan_sizes( + &self, + auto_row_y: usize, + sizes: &mut Vec<Abs>, + cell: &Cell, + parent_y: usize, + rowspan: usize, + unbreakable_rows_left: usize, + measurement_data: &CellMeasurementData<'_>, + ) -> bool { + if sizes.len() <= 1 + && sizes.first().map_or(true, |&first_frame_size| { + first_frame_size <= measurement_data.height_in_this_region + }) + { + // Ignore a rowspan fully covered by rows in previous + // regions and/or in the current region. + sizes.clear(); + return false; + } + if let Some(first_frame_size) = sizes.first_mut() { + // Subtract already covered height from the size requested + // by this rowspan to the auto row in the first region. + *first_frame_size = (*first_frame_size + - measurement_data.height_in_this_region) + .max(Abs::zero()); + } + + let last_spanned_row = parent_y + rowspan - 1; + + // When the rowspan is unbreakable, or all of its upcoming + // spanned rows are in the same unbreakable row group, its + // spanned gutter will certainly be in the same region as all + // of its other spanned rows, thus gutters won't be removed, + // and we can safely reduce how much the auto row expands by + // without using simulation. + let is_effectively_unbreakable_rowspan = + !cell.breakable || auto_row_y + unbreakable_rows_left > last_spanned_row; + + // If the rowspan doesn't end at this row and the grid has + // gutter, we will need to run a simulation to find out how + // much to expand this row by later. This is because gutters + // spanned by this rowspan might be removed if they appear + // around a pagebreak, so the auto row might have to expand a + // bit more to compensate for the missing gutter height. + // However, unbreakable rowspans aren't affected by that + // problem. + if auto_row_y != last_spanned_row + && !sizes.is_empty() + && self.grid.has_gutter + && !is_effectively_unbreakable_rowspan + { + return true; + } + + // We can only predict the resolved size of upcoming fixed-size + // rows, but not fractional rows. In the future, we might be + // able to simulate and circumvent the problem with fractional + // rows. Relative rows are currently always measured relative + // to the first region as well. + // We can ignore auto rows since this is the last spanned auto + // row. + let will_be_covered_height: Abs = self + .grid + .rows + .iter() + .skip(auto_row_y + 1) + .take(last_spanned_row - auto_row_y) + .map(|row| match row { + Sizing::Rel(v) => { + v.resolve(self.styles).relative_to(self.regions.base().y) + } + _ => Abs::zero(), + }) + .sum(); + + // Remove or reduce the sizes of the rowspan at the current or future + // regions where it will already be covered by further rows spanned by + // it. + subtract_end_sizes(sizes, will_be_covered_height); + + // No need to run a simulation for this rowspan. + false + } + + /// Performs a simulation to predict by how much height the last spanned + /// auto row will have to expand, given the current sizes of the auto row + /// in each region and the pending rowspans' data (parent Y, rowspan amount + /// and vector of requested sizes). + #[allow(clippy::too_many_arguments)] + pub fn simulate_and_measure_rowspans_in_auto_row( + &self, + y: usize, + resolved: &mut Vec<Abs>, + pending_rowspans: &[(usize, usize, Vec<Abs>)], + unbreakable_rows_left: usize, + row_group_data: Option<&UnbreakableRowGroup>, + mut disambiguator: usize, + engine: &mut Engine, + ) -> SourceResult<()> { + // To begin our simulation, we have to unify the sizes demanded by + // each rowspan into one simple vector of sizes, as if they were + // all a single rowspan. These sizes will be appended to + // 'resolved' once we finish our simulation. + let mut simulated_sizes: Vec<Abs> = vec![]; + let last_resolved_size = resolved.last().copied(); + let mut max_spanned_row = y; + for (parent_y, rowspan, sizes) in pending_rowspans { + let mut sizes = sizes.iter(); + for (target, size) in resolved.iter_mut().zip(&mut sizes) { + // First, we update the already resolved sizes as required + // by this rowspan. No need to simulate this since the auto row + // will already expand throughout already resolved regions. + // Our simulation, therefore, won't otherwise change already + // resolved sizes, other than, perhaps, the last one (at the + // last currently resolved region, at which we can expand). + target.set_max(*size); + } + for (simulated_target, rowspan_size) in + simulated_sizes.iter_mut().zip(&mut sizes) + { + // The remaining sizes are exclusive to rowspans, since + // other cells in this row didn't require as many regions. + // We will perform a simulation to see how much of these sizes + // does the auto row actually need to expand by, and how much + // is already covered by upcoming rows spanned by the rowspans. + simulated_target.set_max(*rowspan_size); + } + simulated_sizes.extend(sizes); + max_spanned_row = max_spanned_row.max(parent_y + rowspan - 1); + } + if simulated_sizes.is_empty() && resolved.last() == last_resolved_size.as_ref() { + // The rowspans already fit in the already resolved sizes. + // No need for simulation. + return Ok(()); + } + + // We will be updating the last resolved size (expanding the auto + // row) as needed. Therefore, consider it as part of the simulation. + // At the end, we push it back. + if let Some(modified_last_resolved_size) = resolved.pop() { + simulated_sizes.insert(0, modified_last_resolved_size); + } + + // Prepare regions for simulation. + // If we're currently inside an unbreakable row group simulation, + // subtract the current row group height from the available space + // when simulating rowspans in said group. + let mut simulated_regions = self.regions; + simulated_regions.size.y -= + row_group_data.map_or(Abs::zero(), |row_group| row_group.height); + + for _ in 0..resolved.len() { + // Ensure we start at the region where we will expand the auto + // row. + // Note that we won't accidentally call '.next()' once more than + // desired (we won't skip the last resolved frame, where we will + // expand) because we popped the last resolved size from the + // resolved vector, above. + simulated_regions.next(); + disambiguator += 1; + + // Subtract the initial header and footer height, since that's the + // height we used when subtracting from the region backlog's + // heights while measuring cells. + simulated_regions.size.y -= self.header_height + self.footer_height; + } + + if let Some(original_last_resolved_size) = last_resolved_size { + // We're now at the (current) last region of this auto row. + // Consider resolved height as already taken space. + simulated_regions.size.y -= original_last_resolved_size; + } + + // Now we run the simulation to check how much the auto row needs to + // grow to ensure that rowspans have the height they need. + let simulations_stabilized = self.run_rowspan_simulation( + y, + max_spanned_row, + simulated_regions, + &mut simulated_sizes, + engine, + last_resolved_size, + unbreakable_rows_left, + disambiguator, + )?; + + if !simulations_stabilized { + // If the simulation didn't stabilize above, we will just pretend + // all gutters were removed, as a best effort. That means the auto + // row will expand more than it normally should, but there isn't + // much we can do. + let will_be_covered_height = self + .grid + .rows + .iter() + .enumerate() + .skip(y + 1) + .take(max_spanned_row - y) + .filter(|(y, _)| !self.grid.is_gutter_track(*y)) + .map(|(_, row)| match row { + Sizing::Rel(v) => { + v.resolve(self.styles).relative_to(self.regions.base().y) + } + _ => Abs::zero(), + }) + .sum(); + + subtract_end_sizes(&mut simulated_sizes, will_be_covered_height); + } + + resolved.extend(simulated_sizes); + + Ok(()) + } + + /// Performs a simulation of laying out multiple rowspans (consolidated + /// into a single vector of simulated sizes) ending in a certain auto row + /// in order to find out how much the auto row will need to expand to cover + /// the rowspans' requested sizes, considering how much size has been + /// covered by other rows and by gutter between rows. + /// + /// For example, for a rowspan cell containing a block of 8pt of height + /// spanning rows (1pt, auto, 0.5pt, 0.5pt), with a gutter of 1pt between + /// each row, we have that the rows it spans provide 1pt + 0.5pt + 0.5pt + /// = 2pt of height, plus 1pt + 1pt + 1pt = 3pt of gutter, with a total of + /// 2pt + 3pt = 5pt of height already covered by fixed-size rows and + /// gutters. This means that the auto row must (under normal conditions) + /// expand by 3pt (8pt - 5pt) so that the rowspan has enough height across + /// rows to fully draw its contents. + /// + /// However, it's possible that the last row is sent to the next page to + /// respect a pagebreak, and then the 1pt gutter before it disappears. This + /// would lead to our rowspan having a height of 7pt available if we fail + /// to predict this situation when measuring the auto row. + /// + /// The algorithm below will, thus, attempt to simulate the layout of each + /// spanned row, considering the space available in the current page and in + /// upcoming pages (through the region backlog), in order to predict which + /// rows will be sent to a new page and thus have their preceding gutter + /// spacing removed (meaning the auto row has to grow a bit more). After + /// simulating, we subtract the total height spanned by upcoming rows and + /// gutter from the total rowspan height - this will be how much our auto + /// row has to expand. We then simulate again to check if, if the auto row + /// expanded by that amount, that would prompt the auto row to need to + /// expand even more, because expanding the auto row might cause some other + /// larger gutter spacing to disappear (leading to the rowspan having less + /// space available instead of more); if so, we update the amount to expand + /// and run the simulation again. Otherwise (if it should expand by the + /// same amount, meaning we predicted correctly, or by less, meaning the + /// auto row will be a bit larger than it should be, but that's a + /// compromise we're willing to accept), we conclude the simulation + /// (consider it stabilized) and return the result. + /// + /// Tries up to 5 times. If two consecutive simulations stabilize, then + /// we subtract the predicted expansion height ('amount_to_grow') from the + /// total height requested by rowspans (the 'requested_rowspan_height') to + /// obtain how much height is covered by upcoming rows, according to our + /// simulation, and the result of that operation is used to reduce or + /// remove heights from the end of the vector of simulated sizes, such that + /// the remaining heights are exactly how much the auto row should expand + /// by. Then, we return `true`. + /// + /// If the simulations don't stabilize (they return 5 different and + /// successively larger values), aborts and returns `false`. + #[allow(clippy::too_many_arguments)] + fn run_rowspan_simulation( + &self, + y: usize, + max_spanned_row: usize, + mut simulated_regions: Regions<'_>, + simulated_sizes: &mut Vec<Abs>, + engine: &mut Engine, + last_resolved_size: Option<Abs>, + unbreakable_rows_left: usize, + mut disambiguator: usize, + ) -> SourceResult<bool> { + // The max amount this row can expand will be the total size requested + // by rowspans which was not yet resolved. It is worth noting that, + // earlier, we pushed the last resolved size to 'simulated_sizes' as + // row expansion starts with it, so it's possible a rowspan requested + // to extend that size (we will see, through the simulation, if that's + // needed); however, we must subtract that resolved size from the total + // sum of sizes, as it was already resolved and thus the auto row will + // already grow by at least that much in the last resolved region (we + // would grow by the same size twice otherwise). + let requested_rowspan_height = + simulated_sizes.iter().sum::<Abs>() - last_resolved_size.unwrap_or_default(); + + // The amount the row will effectively grow by, according to the latest + // simulation. + let mut amount_to_grow = Abs::zero(); + + // Try to simulate up to 5 times. If it doesn't stabilize at a value + // which, when used and combined with upcoming spanned rows, covers all + // of the requested rowspan height, we give up. + for _attempt in 0..5 { + let rowspan_simulator = RowspanSimulator::new( + disambiguator, + simulated_regions, + self.header_height, + self.footer_height, + ); + + let total_spanned_height = rowspan_simulator.simulate_rowspan_layout( + y, + max_spanned_row, + amount_to_grow, + requested_rowspan_height, + unbreakable_rows_left, + self, + engine, + )?; + + // If the total height spanned by upcoming spanned rows plus the + // current amount we predict the auto row will have to grow (from + // the previous iteration) are larger than the size requested by + // rowspans, this means the auto row will grow enough in order to + // cover the requested rowspan height, so we stop the simulation. + // + // If that's not yet the case, we will simulate again and make the + // auto row grow even more, and do so until either the auto row has + // grown enough, or we tried to do so over 5 times. + // + // A flaw of this approach is that we consider rowspans' content to + // be contiguous. That is, we treat rowspans' requested heights as + // a simple number, instead of properly using the vector of + // requested heights in each region. This can lead to some + // weirdness when using multi-page rowspans with content that + // reacts to the amount of space available, including paragraphs. + // However, this is probably the best we can do for now. + if (total_spanned_height + amount_to_grow).fits(requested_rowspan_height) { + // Reduce sizes by the amount to be covered by upcoming spanned + // rows, which is equivalent to the amount that we don't grow. + // We reduce from the end as that's where the spanned rows will + // cover. The remaining sizes will all be covered by the auto + // row instead (which will grow by those sizes). + subtract_end_sizes( + simulated_sizes, + requested_rowspan_height - amount_to_grow, + ); + + if let Some(last_resolved_size) = last_resolved_size { + // Ensure the first simulated size is at least as large as + // the last resolved size (its initial value). As it was + // already resolved before, we must not reduce below the + // resolved size to avoid problems with non-rowspan cells. + if let Some(first_simulated_size) = simulated_sizes.first_mut() { + first_simulated_size.set_max(last_resolved_size); + } else { + simulated_sizes.push(last_resolved_size); + } + } + + return Ok(true); + } + + // For the next simulation, we will test if the auto row can grow + // by precisely how much rowspan height is not covered by upcoming + // spanned rows, according to the current simulation. + // We know that the new amount to grow is larger (and thus the + // auto row only expands between each simulation), because we + // checked above if + // 'total_spanned_height + (now old_)amount_to_grow >= requested_rowspan_height', + // which was false, so it holds that + // 'total_spanned_height + old_amount_to_grow < requested_rowspan_height' + // Thus, + // 'old_amount_to_grow < requested_rowspan_height - total_spanned_height' + // Therefore, by definition, 'old_amount_to_grow < amount_to_grow'. + let old_amount_to_grow = std::mem::replace( + &mut amount_to_grow, + requested_rowspan_height - total_spanned_height, + ); + + // We advance the 'regions' variable accordingly, so that, in the + // next simulation, we consider already grown space as final. + // That is, we effectively simulate how rows would be placed if the + // auto row grew by precisely the new value of 'amount_to_grow'. + let mut extra_amount_to_grow = amount_to_grow - old_amount_to_grow; + while extra_amount_to_grow > Abs::zero() + && simulated_regions.size.y < extra_amount_to_grow + { + extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero()); + simulated_regions.next(); + simulated_regions.size.y -= self.header_height + self.footer_height; + disambiguator += 1; + } + simulated_regions.size.y -= extra_amount_to_grow; + } + + // Simulation didn't succeed in 5 attempts. + Ok(false) + } +} + +/// Auxiliary structure holding state during rowspan simulation. +struct RowspanSimulator<'a> { + /// The number of finished regions. + finished: usize, + /// The state of regions during the simulation. + regions: Regions<'a>, + /// The height of the header in the currently simulated region. + header_height: Abs, + /// The height of the footer in the currently simulated region. + footer_height: Abs, + /// The total spanned height so far in the simulation. + total_spanned_height: Abs, + /// Height of the latest spanned gutter row in the simulation. + /// Zero if it was removed. + latest_spanned_gutter_height: Abs, +} + +impl<'a> RowspanSimulator<'a> { + /// Creates new rowspan simulation state with the given regions and initial + /// header and footer heights. Other fields should always start as zero. + fn new( + finished: usize, + regions: Regions<'a>, + header_height: Abs, + footer_height: Abs, + ) -> Self { + Self { + finished, + regions, + header_height, + footer_height, + total_spanned_height: Abs::zero(), + latest_spanned_gutter_height: Abs::zero(), + } + } + + /// Calculates the total spanned height of the rowspan. + /// Stops calculating if, at any point in the simulation, the value of + /// `total_spanned_height + amount_to_grow` becomes larger than + /// `requested_rowspan_height`, as the results are not going to become any + /// more useful after that point. + #[allow(clippy::too_many_arguments)] + fn simulate_rowspan_layout( + mut self, + y: usize, + max_spanned_row: usize, + amount_to_grow: Abs, + requested_rowspan_height: Abs, + mut unbreakable_rows_left: usize, + layouter: &GridLayouter<'_>, + engine: &mut Engine, + ) -> SourceResult<Abs> { + let spanned_rows = &layouter.grid.rows[y + 1..=max_spanned_row]; + for (offset, row) in spanned_rows.iter().enumerate() { + if (self.total_spanned_height + amount_to_grow).fits(requested_rowspan_height) + { + // Stop the simulation, as the combination of upcoming + // spanned rows (so far) and the current amount the auto + // row expands by has already fully covered the height the + // rowspans need. + return Ok(self.total_spanned_height); + } + let spanned_y = y + 1 + offset; + let is_gutter = layouter.grid.is_gutter_track(spanned_y); + + if unbreakable_rows_left == 0 { + // Simulate unbreakable row groups, and skip regions until + // they fit. There is no risk of infinite recursion, as + // no auto rows participate in the simulation, so the + // unbreakable row group simulator won't recursively call + // 'measure_auto_row' or (consequently) this function. + let row_group = layouter.simulate_unbreakable_row_group( + spanned_y, + None, + &self.regions, + engine, + 0, + )?; + while !self.regions.size.y.fits(row_group.height) + && !in_last_with_offset( + self.regions, + self.header_height + self.footer_height, + ) + { + self.finish_region(layouter, engine)?; + } + + unbreakable_rows_left = row_group.rows.len(); + } + + match row { + // Fixed-size spanned rows are what we are interested in. + // They contribute a fixed amount of height to our rowspan. + Sizing::Rel(v) => { + let height = + v.resolve(layouter.styles).relative_to(self.regions.base().y); + self.total_spanned_height += height; + if is_gutter { + self.latest_spanned_gutter_height = height; + } + + let mut skipped_region = false; + while unbreakable_rows_left == 0 + && !self.regions.size.y.fits(height) + && !in_last_with_offset( + self.regions, + self.header_height + self.footer_height, + ) + { + self.finish_region(layouter, engine)?; + + skipped_region = true; + } + + if !skipped_region || !is_gutter { + // No gutter at the top of a new region, so don't + // account for it if we just skipped a region. + self.regions.size.y -= height; + } + } + Sizing::Auto => { + // We only simulate for rowspans which end at the + // current auto row. Therefore, there won't be any + // further auto rows. + unreachable!(); + } + // For now, we ignore fractional rows on simulation. + Sizing::Fr(_) if is_gutter => { + self.latest_spanned_gutter_height = Abs::zero(); + } + Sizing::Fr(_) => {} + } + + unbreakable_rows_left = unbreakable_rows_left.saturating_sub(1); + } + + Ok(self.total_spanned_height) + } + + fn simulate_header_footer_layout( + &mut self, + layouter: &GridLayouter<'_>, + engine: &mut Engine, + ) -> SourceResult<()> { + // We can't just use the initial header/footer height on each region, + // because header/footer height might vary depending on region size if + // it contains rows with relative lengths. Therefore, we re-simulate + // headers and footers on each new region. + // It's true that, when measuring cells, we reduce each height in the + // backlog to consider the initial header and footer heights; however, + // our simulation checks what happens AFTER the auto row, so we can + // just use the original backlog from `self.regions`. + let disambiguator = self.finished; + let header_height = + if let Some(Repeatable::Repeated(header)) = &layouter.grid.header { + layouter + .simulate_header(header, &self.regions, engine, disambiguator)? + .height + } else { + Abs::zero() + }; + + let footer_height = + if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer { + layouter + .simulate_footer(footer, &self.regions, engine, disambiguator)? + .height + } else { + Abs::zero() + }; + + let mut skipped_region = false; + + // Skip until we reach a fitting region for both header and footer. + while !self.regions.size.y.fits(header_height + footer_height) + && self.regions.may_progress() + { + self.regions.next(); + self.finished += 1; + skipped_region = true; + } + + if let Some(Repeatable::Repeated(header)) = &layouter.grid.header { + self.header_height = if skipped_region { + // Simulate headers again, at the new region, as + // the full region height may change. + layouter + .simulate_header(header, &self.regions, engine, disambiguator)? + .height + } else { + header_height + }; + } + + if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer { + self.footer_height = if skipped_region { + // Simulate footers again, at the new region, as + // the full region height may change. + layouter + .simulate_footer(footer, &self.regions, engine, disambiguator)? + .height + } else { + footer_height + }; + } + + // Consume the header's and footer's heights from the new region, + // but don't consider them spanned. The rowspan does not go over the + // header or footer (as an invariant, any rowspans spanning any header + // or footer rows are fully contained within that header's or footer's rows). + self.regions.size.y -= self.header_height + self.footer_height; + + Ok(()) + } + + fn finish_region( + &mut self, + layouter: &GridLayouter<'_>, + engine: &mut Engine, + ) -> SourceResult<()> { + // If a row was pushed to the next region, the immediately + // preceding gutter row is removed. + self.total_spanned_height -= self.latest_spanned_gutter_height; + self.latest_spanned_gutter_height = Abs::zero(); + self.regions.next(); + self.finished += 1; + + self.simulate_header_footer_layout(layouter, engine) + } +} + +/// Subtracts some size from the end of a vector of sizes. +/// For example, subtracting 5pt from \[2pt, 1pt, 3pt\] will result in \[1pt\]. +fn subtract_end_sizes(sizes: &mut Vec<Abs>, mut subtract: Abs) { + while subtract > Abs::zero() && sizes.last().is_some_and(|&size| size <= subtract) { + subtract -= sizes.pop().unwrap(); + } + if subtract > Abs::zero() { + if let Some(last_size) = sizes.last_mut() { + *last_size -= subtract; + } + } +} diff --git a/crates/typst-layout/src/image.rs b/crates/typst-layout/src/image.rs new file mode 100644 index 00000000..84a60282 --- /dev/null +++ b/crates/typst-layout/src/image.rs @@ -0,0 +1,142 @@ +use std::ffi::OsStr; + +use typst_library::diag::{bail, warning, At, SourceResult, StrResult}; +use typst_library::engine::Engine; +use typst_library::foundations::{Packed, Smart, StyleChain}; +use typst_library::introspection::Locator; +use typst_library::layout::{ + Abs, Axes, FixedAlignment, Frame, FrameItem, Point, Region, Size, +}; +use typst_library::loading::Readable; +use typst_library::text::families; +use typst_library::visualize::{ + Image, ImageElem, ImageFit, ImageFormat, Path, RasterFormat, VectorFormat, +}; + +/// Layout the image. +#[typst_macros::time(span = elem.span())] +pub fn layout_image( + elem: &Packed<ImageElem>, + engine: &mut Engine, + _: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + let span = elem.span(); + + // Take the format that was explicitly defined, or parse the extension, + // or try to detect the format. + let data = elem.data(); + let format = match elem.format(styles) { + Smart::Custom(v) => v, + Smart::Auto => determine_format(elem.path().as_str(), data).at(span)?, + }; + + // Warn the user if the image contains a foreign object. Not perfect + // because the svg could also be encoded, but that's an edge case. + if format == ImageFormat::Vector(VectorFormat::Svg) { + let has_foreign_object = + data.as_str().is_some_and(|s| s.contains("<foreignObject")); + + if has_foreign_object { + engine.sink.warn(warning!( + span, + "image contains foreign object"; + hint: "SVG images with foreign objects might render incorrectly in typst"; + hint: "see https://github.com/typst/typst/issues/1421 for more information" + )); + } + } + + // Construct the image itself. + let image = Image::with_fonts( + data.clone().into(), + format, + elem.alt(styles), + engine.world, + &families(styles).collect::<Vec<_>>(), + ) + .at(span)?; + + // Determine the image's pixel aspect ratio. + let pxw = image.width(); + let pxh = image.height(); + let px_ratio = pxw / pxh; + + // Determine the region's aspect ratio. + let region_ratio = region.size.x / region.size.y; + + // Find out whether the image is wider or taller than the region. + let wide = px_ratio > region_ratio; + + // The space into which the image will be placed according to its fit. + let target = if region.expand.x && region.expand.y { + // If both width and height are forced, take them. + region.size + } else if region.expand.x { + // If just width is forced, take it. + Size::new(region.size.x, region.size.y.min(region.size.x / px_ratio)) + } else if region.expand.y { + // If just height is forced, take it. + Size::new(region.size.x.min(region.size.y * px_ratio), region.size.y) + } else { + // If neither is forced, take the natural image size at the image's + // DPI bounded by the available space. + let dpi = image.dpi().unwrap_or(Image::DEFAULT_DPI); + let natural = Axes::new(pxw, pxh).map(|v| Abs::inches(v / dpi)); + Size::new( + natural.x.min(region.size.x).min(region.size.y * px_ratio), + natural.y.min(region.size.y).min(region.size.x / px_ratio), + ) + }; + + // Compute the actual size of the fitted image. + let fit = elem.fit(styles); + let fitted = match fit { + ImageFit::Cover | ImageFit::Contain => { + if wide == (fit == ImageFit::Contain) { + Size::new(target.x, target.x / px_ratio) + } else { + Size::new(target.y * px_ratio, target.y) + } + } + ImageFit::Stretch => target, + }; + + // First, place the image in a frame of exactly its size and then resize + // the frame to the target size, center aligning the image in the + // process. + let mut frame = Frame::soft(fitted); + frame.push(Point::zero(), FrameItem::Image(image, fitted, span)); + frame.resize(target, Axes::splat(FixedAlignment::Center)); + + // Create a clipping group if only part of the image should be visible. + if fit == ImageFit::Cover && !target.fits(fitted) { + frame.clip(Path::rect(frame.size())); + } + + Ok(frame) +} + +/// Determine the image format based on path and data. +fn determine_format(path: &str, data: &Readable) -> StrResult<ImageFormat> { + let ext = std::path::Path::new(path) + .extension() + .and_then(OsStr::to_str) + .unwrap_or_default() + .to_lowercase(); + + Ok(match ext.as_str() { + "png" => ImageFormat::Raster(RasterFormat::Png), + "jpg" | "jpeg" => ImageFormat::Raster(RasterFormat::Jpg), + "gif" => ImageFormat::Raster(RasterFormat::Gif), + "svg" | "svgz" => ImageFormat::Vector(VectorFormat::Svg), + _ => match &data { + Readable::Str(_) => ImageFormat::Vector(VectorFormat::Svg), + Readable::Bytes(bytes) => match RasterFormat::detect(bytes) { + Some(f) => ImageFormat::Raster(f), + None => bail!("unknown image format"), + }, + }, + }) +} diff --git a/crates/typst-layout/src/inline/box.rs b/crates/typst-layout/src/inline/box.rs new file mode 100644 index 00000000..30572e4e --- /dev/null +++ b/crates/typst-layout/src/inline/box.rs @@ -0,0 +1,87 @@ +use once_cell::unsync::Lazy; +use typst_library::diag::SourceResult; +use typst_library::engine::Engine; +use typst_library::foundations::{Packed, StyleChain}; +use typst_library::introspection::Locator; +use typst_library::layout::{BoxElem, Frame, FrameKind, Size}; +use typst_library::visualize::Stroke; +use typst_utils::Numeric; + +use crate::flow::unbreakable_pod; +use crate::shapes::{clip_rect, fill_and_stroke}; + +/// Lay out a box as part of a paragraph. +#[typst_macros::time(name = "box", span = elem.span())] +pub fn layout_box( + elem: &Packed<BoxElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Size, +) -> SourceResult<Frame> { + // Fetch sizing properties. + let width = elem.width(styles); + let height = elem.height(styles); + let inset = elem.inset(styles).unwrap_or_default(); + + // Build the pod region. + let pod = unbreakable_pod(&width, &height.into(), &inset, styles, region); + + // Layout the body. + let mut frame = match elem.body(styles) { + // If we have no body, just create an empty frame. If necessary, + // its size will be adjusted below. + None => Frame::hard(Size::zero()), + + // If we have a child, layout it into the body. Boxes are boundaries + // for gradient relativeness, so we set the `FrameKind` to `Hard`. + Some(body) => crate::layout_frame(engine, body, locator, styles, pod)? + .with_kind(FrameKind::Hard), + }; + + // Enforce a correct frame size on the expanded axes. Do this before + // applying the inset, since the pod shrunk. + frame.set_size(pod.expand.select(pod.size, frame.size())); + + // Apply the inset. + if !inset.is_zero() { + crate::pad::grow(&mut frame, &inset); + } + + // Prepare fill and stroke. + let fill = elem.fill(styles); + let stroke = elem + .stroke(styles) + .unwrap_or_default() + .map(|s| s.map(Stroke::unwrap_or_default)); + + // Only fetch these if necessary (for clipping or filling/stroking). + let outset = Lazy::new(|| elem.outset(styles).unwrap_or_default()); + let radius = Lazy::new(|| elem.radius(styles).unwrap_or_default()); + + // Clip the contents, if requested. + if elem.clip(styles) { + let size = frame.size() + outset.relative_to(frame.size()).sum_by_axis(); + frame.clip(clip_rect(size, &radius, &stroke)); + } + + // Add fill and/or stroke. + if fill.is_some() || stroke.iter().any(Option::is_some) { + fill_and_stroke(&mut frame, fill, &stroke, &outset, &radius, elem.span()); + } + + // Assign label to the frame. + if let Some(label) = elem.label() { + frame.label(label); + } + + // Apply baseline shift. Do this after setting the size and applying the + // inset, so that a relative shift is resolved relative to the final + // height. + let shift = elem.baseline(styles).relative_to(frame.height()); + if !shift.is_zero() { + frame.set_baseline(frame.baseline() - shift); + } + + Ok(frame) +} diff --git a/crates/typst-layout/src/inline/collect.rs b/crates/typst-layout/src/inline/collect.rs new file mode 100644 index 00000000..fbcddee5 --- /dev/null +++ b/crates/typst-layout/src/inline/collect.rs @@ -0,0 +1,328 @@ +use typst_library::diag::bail; +use typst_library::foundations::{Packed, Resolve}; +use typst_library::introspection::{SplitLocator, Tag, TagElem}; +use typst_library::layout::{ + Abs, AlignElem, BoxElem, Dir, Fr, Frame, HElem, InlineElem, InlineItem, Sizing, + Spacing, +}; +use typst_library::text::{ + is_default_ignorable, LinebreakElem, SmartQuoteElem, SmartQuoter, SmartQuotes, + SpaceElem, TextElem, +}; +use typst_syntax::Span; +use typst_utils::Numeric; + +use super::*; + +// The characters by which spacing, inline content and pins are replaced in the +// paragraph's full text. +const SPACING_REPLACE: &str = " "; // Space +const OBJ_REPLACE: &str = "\u{FFFC}"; // Object Replacement Character + +// Unicode BiDi control characters. +const LTR_EMBEDDING: &str = "\u{202A}"; +const RTL_EMBEDDING: &str = "\u{202B}"; +const POP_EMBEDDING: &str = "\u{202C}"; +const LTR_ISOLATE: &str = "\u{2066}"; +const POP_ISOLATE: &str = "\u{2069}"; + +/// A prepared item in a paragraph layout. +#[derive(Debug)] +pub enum Item<'a> { + /// A shaped text run with consistent style and direction. + Text(ShapedText<'a>), + /// Absolute spacing between other items, and whether it is weak. + Absolute(Abs, bool), + /// Fractional spacing between other items. + Fractional(Fr, Option<(&'a Packed<BoxElem>, Locator<'a>, StyleChain<'a>)>), + /// Layouted inline-level content. + Frame(Frame, StyleChain<'a>), + /// A tag. + Tag(&'a Tag), + /// An item that is invisible and needs to be skipped, e.g. a Unicode + /// isolate. + Skip(&'static str), +} + +impl<'a> Item<'a> { + /// If this a text item, return it. + pub fn text(&self) -> Option<&ShapedText<'a>> { + match self { + Self::Text(shaped) => Some(shaped), + _ => None, + } + } + + /// If this a text item, return it mutably. + pub fn text_mut(&mut self) -> Option<&mut ShapedText<'a>> { + match self { + Self::Text(shaped) => Some(shaped), + _ => None, + } + } + + /// Return the textual representation of this item: Either just itself (for + /// a text item) or a replacement string (for any other item). + pub fn textual(&self) -> &str { + match self { + Self::Text(shaped) => shaped.text, + Self::Absolute(_, _) | Self::Fractional(_, _) => SPACING_REPLACE, + Self::Frame(_, _) => OBJ_REPLACE, + Self::Tag(_) => "", + Self::Skip(s) => s, + } + } + + /// The text length of the item. + pub fn textual_len(&self) -> usize { + self.textual().len() + } + + /// The natural layouted width of the item. + pub fn natural_width(&self) -> Abs { + match self { + Self::Text(shaped) => shaped.width, + Self::Absolute(v, _) => *v, + Self::Frame(frame, _) => frame.width(), + Self::Fractional(_, _) | Self::Tag(_) => Abs::zero(), + Self::Skip(_) => Abs::zero(), + } + } +} + +/// An item or not-yet shaped text. We can't shape text until we have collected +/// all items because only then we can compute BiDi, and we need to split shape +/// runs at level boundaries. +#[derive(Debug)] +pub enum Segment<'a> { + /// One or multiple collapsed text children. Stores how long the segment is + /// (in bytes of the full text string). + Text(usize, StyleChain<'a>), + /// An already prepared item. + Item(Item<'a>), +} + +impl Segment<'_> { + /// The text length of the item. + pub fn textual_len(&self) -> usize { + match self { + Self::Text(len, _) => *len, + Self::Item(item) => item.textual_len(), + } + } +} + +/// Collects all text of the paragraph into one string and a collection of +/// segments that correspond to pieces of that string. This also performs +/// string-level preprocessing like case transformations. +#[typst_macros::time] +pub fn collect<'a>( + children: &'a StyleVec, + engine: &mut Engine<'_>, + locator: &mut SplitLocator<'a>, + styles: &'a StyleChain<'a>, + region: Size, + consecutive: bool, +) -> SourceResult<(String, Vec<Segment<'a>>, SpanMapper)> { + let mut collector = Collector::new(2 + children.len()); + let mut quoter = SmartQuoter::new(); + + let outer_dir = TextElem::dir_in(*styles); + let first_line_indent = ParElem::first_line_indent_in(*styles); + if !first_line_indent.is_zero() + && consecutive + && AlignElem::alignment_in(*styles).resolve(*styles).x == outer_dir.start().into() + { + collector.push_item(Item::Absolute(first_line_indent.resolve(*styles), false)); + collector.spans.push(1, Span::detached()); + } + + let hang = ParElem::hanging_indent_in(*styles); + if !hang.is_zero() { + collector.push_item(Item::Absolute(-hang, false)); + collector.spans.push(1, Span::detached()); + } + + for (child, styles) in children.iter(styles) { + let prev_len = collector.full.len(); + + if child.is::<SpaceElem>() { + collector.push_text(" ", styles); + } else if let Some(elem) = child.to_packed::<TextElem>() { + collector.build_text(styles, |full| { + let dir = TextElem::dir_in(styles); + if dir != outer_dir { + // Insert "Explicit Directional Embedding". + match dir { + Dir::LTR => full.push_str(LTR_EMBEDDING), + Dir::RTL => full.push_str(RTL_EMBEDDING), + _ => {} + } + } + + if let Some(case) = TextElem::case_in(styles) { + full.push_str(&case.apply(elem.text())); + } else { + full.push_str(elem.text()); + } + + if dir != outer_dir { + // Insert "Pop Directional Formatting". + full.push_str(POP_EMBEDDING); + } + }); + } else if let Some(elem) = child.to_packed::<HElem>() { + let amount = elem.amount(); + if amount.is_zero() { + continue; + } + + collector.push_item(match amount { + Spacing::Fr(fr) => Item::Fractional(*fr, None), + Spacing::Rel(rel) => Item::Absolute( + rel.resolve(styles).relative_to(region.x), + elem.weak(styles), + ), + }); + } else if let Some(elem) = child.to_packed::<LinebreakElem>() { + collector + .push_text(if elem.justify(styles) { "\u{2028}" } else { "\n" }, styles); + } else if let Some(elem) = child.to_packed::<SmartQuoteElem>() { + let double = elem.double(styles); + if elem.enabled(styles) { + let quotes = SmartQuotes::get( + elem.quotes(styles), + TextElem::lang_in(styles), + TextElem::region_in(styles), + elem.alternative(styles), + ); + let before = + collector.full.chars().rev().find(|&c| !is_default_ignorable(c)); + let quote = quoter.quote(before, "es, double); + collector.push_text(quote, styles); + } else { + collector.push_text(if double { "\"" } else { "'" }, styles); + } + } else if let Some(elem) = child.to_packed::<InlineElem>() { + collector.push_item(Item::Skip(LTR_ISOLATE)); + + for item in elem.layout(engine, locator.next(&elem.span()), styles, region)? { + match item { + InlineItem::Space(space, weak) => { + collector.push_item(Item::Absolute(space, weak)); + } + InlineItem::Frame(frame) => { + collector.push_item(Item::Frame(frame, styles)); + } + } + } + + collector.push_item(Item::Skip(POP_ISOLATE)); + } else if let Some(elem) = child.to_packed::<BoxElem>() { + let loc = locator.next(&elem.span()); + if let Sizing::Fr(v) = elem.width(styles) { + collector.push_item(Item::Fractional(v, Some((elem, loc, styles)))); + } else { + let frame = layout_box(elem, engine, loc, styles, region)?; + collector.push_item(Item::Frame(frame, styles)); + } + } else if let Some(elem) = child.to_packed::<TagElem>() { + collector.push_item(Item::Tag(&elem.tag)); + } else { + bail!(child.span(), "unexpected paragraph child"); + }; + + let len = collector.full.len() - prev_len; + collector.spans.push(len, child.span()); + } + + Ok((collector.full, collector.segments, collector.spans)) +} + +/// Collects segments. +struct Collector<'a> { + full: String, + segments: Vec<Segment<'a>>, + spans: SpanMapper, +} + +impl<'a> Collector<'a> { + fn new(capacity: usize) -> Self { + Self { + full: String::new(), + segments: Vec::with_capacity(capacity), + spans: SpanMapper::new(), + } + } + + fn push_text(&mut self, text: &str, styles: StyleChain<'a>) { + self.full.push_str(text); + self.push_segment(Segment::Text(text.len(), styles)); + } + + fn build_text<F>(&mut self, styles: StyleChain<'a>, f: F) + where + F: FnOnce(&mut String), + { + let prev = self.full.len(); + f(&mut self.full); + let len = self.full.len() - prev; + self.push_segment(Segment::Text(len, styles)); + } + + fn push_item(&mut self, item: Item<'a>) { + self.full.push_str(item.textual()); + self.push_segment(Segment::Item(item)); + } + + fn push_segment(&mut self, segment: Segment<'a>) { + match (self.segments.last_mut(), &segment) { + // Merge adjacent text segments with the same styles. + (Some(Segment::Text(last_len, last_styles)), Segment::Text(len, styles)) + if *last_styles == *styles => + { + *last_len += *len; + } + + // Merge adjacent weak spacing by taking the maximum. + ( + Some(Segment::Item(Item::Absolute(prev_amount, true))), + Segment::Item(Item::Absolute(amount, true)), + ) => { + *prev_amount = (*prev_amount).max(*amount); + } + + _ => self.segments.push(segment), + } + } +} + +/// Maps byte offsets back to spans. +#[derive(Default)] +pub struct SpanMapper(Vec<(usize, Span)>); + +impl SpanMapper { + /// Create a new span mapper. + pub fn new() -> Self { + Self::default() + } + + /// Push a span for a segment with the given length. + pub fn push(&mut self, len: usize, span: Span) { + self.0.push((len, span)); + } + + /// Determine the span at the given byte offset. + /// + /// May return a detached span. + pub fn span_at(&self, offset: usize) -> (Span, u16) { + let mut cursor = 0; + for &(len, span) in &self.0 { + if (cursor..cursor + len).contains(&offset) { + return (span, u16::try_from(offset - cursor).unwrap_or(0)); + } + cursor += len; + } + (Span::detached(), 0) + } +} diff --git a/crates/typst-layout/src/inline/deco.rs b/crates/typst-layout/src/inline/deco.rs new file mode 100644 index 00000000..c01b369b --- /dev/null +++ b/crates/typst-layout/src/inline/deco.rs @@ -0,0 +1,213 @@ +use kurbo::{BezPath, Line, ParamCurve}; +use ttf_parser::{GlyphId, OutlineBuilder}; +use typst_library::layout::{Abs, Em, Frame, FrameItem, Point, Size}; +use typst_library::text::{ + BottomEdge, DecoLine, Decoration, TextEdgeBounds, TextItem, TopEdge, +}; +use typst_library::visualize::{FixedStroke, Geometry}; +use typst_syntax::Span; + +use crate::shapes::styled_rect; + +/// Add line decorations to a single run of shaped text. +pub fn decorate( + frame: &mut Frame, + deco: &Decoration, + text: &TextItem, + width: Abs, + shift: Abs, + pos: Point, +) { + let font_metrics = text.font.metrics(); + + if let DecoLine::Highlight { fill, stroke, top_edge, bottom_edge, radius } = + &deco.line + { + let (top, bottom) = determine_edges(text, *top_edge, *bottom_edge); + let size = Size::new(width + 2.0 * deco.extent, top + bottom); + let rects = styled_rect(size, radius, fill.clone(), stroke); + let origin = Point::new(pos.x - deco.extent, pos.y - top - shift); + frame.prepend_multiple( + rects + .into_iter() + .map(|shape| (origin, FrameItem::Shape(shape, Span::detached()))), + ); + return; + } + + let (stroke, metrics, offset, evade, background) = match &deco.line { + DecoLine::Strikethrough { stroke, offset, background } => { + (stroke, font_metrics.strikethrough, offset, false, *background) + } + DecoLine::Overline { stroke, offset, evade, background } => { + (stroke, font_metrics.overline, offset, *evade, *background) + } + DecoLine::Underline { stroke, offset, evade, background } => { + (stroke, font_metrics.underline, offset, *evade, *background) + } + _ => return, + }; + + let offset = offset.unwrap_or(-metrics.position.at(text.size)) - shift; + let stroke = stroke.clone().unwrap_or(FixedStroke::from_pair( + text.fill.as_decoration(), + metrics.thickness.at(text.size), + )); + + let gap_padding = 0.08 * text.size; + let min_width = 0.162 * text.size; + + let start = pos.x - deco.extent; + let end = pos.x + width + deco.extent; + + let mut push_segment = |from: Abs, to: Abs, prepend: bool| { + let origin = Point::new(from, pos.y + offset); + let target = Point::new(to - from, Abs::zero()); + + if target.x >= min_width || !evade { + let shape = Geometry::Line(target).stroked(stroke.clone()); + + if prepend { + frame.prepend(origin, FrameItem::Shape(shape, Span::detached())); + } else { + frame.push(origin, FrameItem::Shape(shape, Span::detached())); + } + } + }; + + if !evade { + push_segment(start, end, background); + return; + } + + let line = Line::new( + kurbo::Point::new(pos.x.to_raw(), offset.to_raw()), + kurbo::Point::new((pos.x + width).to_raw(), offset.to_raw()), + ); + + let mut x = pos.x; + let mut intersections = vec![]; + + for glyph in text.glyphs.iter() { + let dx = glyph.x_offset.at(text.size) + x; + let mut builder = + BezPathBuilder::new(font_metrics.units_per_em, text.size, dx.to_raw()); + + let bbox = text.font.ttf().outline_glyph(GlyphId(glyph.id), &mut builder); + let path = builder.finish(); + + x += glyph.x_advance.at(text.size); + + // Only do the costly segments intersection test if the line + // intersects the bounding box. + let intersect = bbox.is_some_and(|bbox| { + let y_min = -text.font.to_em(bbox.y_max).at(text.size); + let y_max = -text.font.to_em(bbox.y_min).at(text.size); + offset >= y_min && offset <= y_max + }); + + if intersect { + // Find all intersections of segments with the line. + intersections.extend( + path.segments() + .flat_map(|seg| seg.intersect_line(line)) + .map(|is| Abs::raw(line.eval(is.line_t).x)), + ); + } + } + + // Add start and end points, taking padding into account. + intersections.push(start - gap_padding); + intersections.push(end + gap_padding); + // When emitting the decorative line segments, we move from left to + // right. The intersections are not necessarily in this order, yet. + intersections.sort(); + + for edge in intersections.windows(2) { + let l = edge[0]; + let r = edge[1]; + + // If we are too close, don't draw the segment + if r - l < gap_padding { + continue; + } else { + push_segment(l + gap_padding, r - gap_padding, background); + } + } +} + +// Return the top/bottom edge of the text given the metric of the font. +fn determine_edges( + text: &TextItem, + top_edge: TopEdge, + bottom_edge: BottomEdge, +) -> (Abs, Abs) { + let mut top = Abs::zero(); + let mut bottom = Abs::zero(); + + for g in text.glyphs.iter() { + let (t, b) = text.font.edges( + top_edge, + bottom_edge, + text.size, + TextEdgeBounds::Glyph(g.id), + ); + top.set_max(t); + bottom.set_max(b); + } + + (top, bottom) +} + +/// Builds a kurbo [`BezPath`] for a glyph. +struct BezPathBuilder { + path: BezPath, + units_per_em: f64, + font_size: Abs, + x_offset: f64, +} + +impl BezPathBuilder { + fn new(units_per_em: f64, font_size: Abs, x_offset: f64) -> Self { + Self { + path: BezPath::new(), + units_per_em, + font_size, + x_offset, + } + } + + fn finish(self) -> BezPath { + self.path + } + + fn p(&self, x: f32, y: f32) -> kurbo::Point { + kurbo::Point::new(self.s(x) + self.x_offset, -self.s(y)) + } + + fn s(&self, v: f32) -> f64 { + Em::from_units(v, self.units_per_em).at(self.font_size).to_raw() + } +} + +impl OutlineBuilder for BezPathBuilder { + fn move_to(&mut self, x: f32, y: f32) { + self.path.move_to(self.p(x, y)); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.path.line_to(self.p(x, y)); + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + self.path.quad_to(self.p(x1, y1), self.p(x, y)); + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + self.path.curve_to(self.p(x1, y1), self.p(x2, y2), self.p(x, y)); + } + + fn close(&mut self) { + self.path.close_path(); + } +} diff --git a/crates/typst-layout/src/inline/finalize.rs b/crates/typst-layout/src/inline/finalize.rs new file mode 100644 index 00000000..599ace9d --- /dev/null +++ b/crates/typst-layout/src/inline/finalize.rs @@ -0,0 +1,36 @@ +use typst_library::introspection::SplitLocator; +use typst_utils::Numeric; + +use super::*; + +/// Turns the selected lines into frames. +#[typst_macros::time] +pub fn finalize( + engine: &mut Engine, + p: &Preparation, + lines: &[Line], + styles: StyleChain, + region: Size, + expand: bool, + locator: &mut SplitLocator<'_>, +) -> SourceResult<Fragment> { + // Determine the paragraph's width: Full width of the region if we should + // expand or there's fractional spacing, fit-to-width otherwise. + let width = if !region.x.is_finite() + || (!expand && lines.iter().all(|line| line.fr().is_zero())) + { + region + .x + .min(p.hang + lines.iter().map(|line| line.width).max().unwrap_or_default()) + } else { + region.x + }; + + // Stack the lines into one frame per region. + let shrink = ParElem::shrink_in(styles); + lines + .iter() + .map(|line| commit(engine, p, line, width, region.y, shrink, locator, styles)) + .collect::<SourceResult<_>>() + .map(Fragment::frames) +} diff --git a/crates/typst-layout/src/inline/line.rs b/crates/typst-layout/src/inline/line.rs new file mode 100644 index 00000000..596e109e --- /dev/null +++ b/crates/typst-layout/src/inline/line.rs @@ -0,0 +1,750 @@ +use std::fmt::{self, Debug, Formatter}; +use std::ops::{Deref, DerefMut}; + +use typst_library::engine::Engine; +use typst_library::foundations::NativeElement; +use typst_library::introspection::{SplitLocator, Tag}; +use typst_library::layout::{Abs, Dir, Em, Fr, Frame, FrameItem, Point}; +use typst_library::model::{ParLine, ParLineMarker}; +use typst_library::text::{Lang, TextElem}; +use typst_utils::Numeric; + +use super::*; + +const SHY: char = '\u{ad}'; +const HYPHEN: char = '-'; +const EN_DASH: char = '–'; +const EM_DASH: char = '—'; +const LINE_SEPARATOR: char = '\u{2028}'; // We use LS to distinguish justified breaks. + +/// A layouted line, consisting of a sequence of layouted paragraph items that +/// are mostly borrowed from the preparation phase. This type enables you to +/// measure the size of a line in a range before committing to building the +/// line's frame. +/// +/// At most two paragraph items must be created individually for this line: The +/// first and last one since they may be broken apart by the start or end of the +/// line, respectively. But even those can partially reuse previous results when +/// the break index is safe-to-break per rustybuzz. +pub struct Line<'a> { + /// The items the line is made of. + pub items: Items<'a>, + /// The exact natural width of the line. + pub width: Abs, + /// Whether the line should be justified. + pub justify: bool, + /// Whether the line ends with a hyphen or dash, either naturally or through + /// hyphenation. + pub dash: Option<Dash>, +} + +impl<'a> Line<'a> { + /// Create an empty line. + pub fn empty() -> Self { + Self { + items: Items::new(), + width: Abs::zero(), + justify: false, + dash: None, + } + } + + /// How many glyphs are in the text where we can insert additional + /// space when encountering underfull lines. + pub fn justifiables(&self) -> usize { + let mut count = 0; + for shaped in self.items.iter().filter_map(Item::text) { + count += shaped.justifiables(); + } + + // CJK character at line end should not be adjusted. + if self + .items + .last() + .and_then(Item::text) + .map(|s| s.cjk_justifiable_at_last()) + .unwrap_or(false) + { + count -= 1; + } + + count + } + + /// How much the line can stretch. + pub fn stretchability(&self) -> Abs { + self.items + .iter() + .filter_map(Item::text) + .map(|s| s.stretchability()) + .sum() + } + + /// How much the line can shrink. + pub fn shrinkability(&self) -> Abs { + self.items + .iter() + .filter_map(Item::text) + .map(|s| s.shrinkability()) + .sum() + } + + /// Whether the line has items with negative width. + pub fn has_negative_width_items(&self) -> bool { + self.items.iter().any(|item| match item { + Item::Absolute(amount, _) => *amount < Abs::zero(), + Item::Frame(frame, _) => frame.width() < Abs::zero(), + _ => false, + }) + } + + /// The sum of fractions in the line. + pub fn fr(&self) -> Fr { + self.items + .iter() + .filter_map(|item| match item { + Item::Fractional(fr, _) => Some(*fr), + _ => None, + }) + .sum() + } +} + +/// A dash at the end of a line. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum Dash { + /// A soft hyphen added to break a word. + Soft, + /// A regular hyphen, present in a compound word, e.g. beija-flor. + Hard, + /// Another kind of dash. Only relevant for cost computation. + Other, +} + +/// Create a line which spans the given range. +pub fn line<'a>( + engine: &Engine, + p: &'a Preparation, + range: Range, + breakpoint: Breakpoint, + pred: Option<&Line>, +) -> Line<'a> { + // The line's full text. + let full = &p.text[range.clone()]; + + // Whether the line is justified. + let justify = full.ends_with(LINE_SEPARATOR) + || (p.justify && breakpoint != Breakpoint::Mandatory); + + // Process dashes. + let dash = if breakpoint.is_hyphen() || full.ends_with(SHY) { + Some(Dash::Soft) + } else if full.ends_with(HYPHEN) { + Some(Dash::Hard) + } else if full.ends_with([EN_DASH, EM_DASH]) { + Some(Dash::Other) + } else { + None + }; + + // Trim the line at the end, if necessary for this breakpoint. + let trim = range.start + breakpoint.trim(full).len(); + + // Collect the items for the line. + let mut items = collect_items(engine, p, range, trim); + + // Add a hyphen at the line start, if a previous dash should be repeated. + if pred.map_or(false, |pred| should_repeat_hyphen(pred, full)) { + if let Some(shaped) = items.first_text_mut() { + shaped.prepend_hyphen(engine, p.fallback); + } + } + + // Add a hyphen at the line end, if we ended on a soft hyphen. + if dash == Some(Dash::Soft) { + if let Some(shaped) = items.last_text_mut() { + shaped.push_hyphen(engine, p.fallback); + } + } + + // Deal with CJ characters at line boundaries. + adjust_cj_at_line_boundaries(p, full, &mut items); + + // Compute the line's width. + let width = items.iter().map(Item::natural_width).sum(); + + Line { items, width, justify, dash } +} + +/// Collects / reshapes all items for the line with the given `range`. +/// +/// The `trim` defines an end position to which text items are trimmed. For +/// example, the `range` may span "hello\n", but the `trim` specifies that the +/// linebreak is trimmed. +/// +/// We do not factor the `trim` directly into the `range` because we still want +/// to keep non-text items after the trim (e.g. tags). +fn collect_items<'a>( + engine: &Engine, + p: &'a Preparation, + range: Range, + trim: usize, +) -> Items<'a> { + let mut items = Items::new(); + let mut fallback = None; + + // Collect the items for each consecutively ordered run. + reorder(p, range.clone(), |subrange, rtl| { + let from = items.len(); + collect_range(engine, p, subrange, trim, &mut items, &mut fallback); + if rtl { + items.reorder(from); + } + }); + + // Trim weak spacing at the start of the line. + let prefix = items + .iter() + .take_while(|item| matches!(item, Item::Absolute(_, true))) + .count(); + if prefix > 0 { + items.drain(..prefix); + } + + // Trim weak spacing at the end of the line. + while matches!(items.last(), Some(Item::Absolute(_, true))) { + items.pop(); + } + + // Add fallback text to expand the line height, if necessary. + if !items.iter().any(|item| matches!(item, Item::Text(_))) { + if let Some(fallback) = fallback { + items.push(fallback); + } + } + + items +} + +/// Calls `f` for the BiDi-reordered ranges of a line. +fn reorder<F>(p: &Preparation, range: Range, mut f: F) +where + F: FnMut(Range, bool), +{ + // If there is nothing bidirectional going on, skip reordering. + let Some(bidi) = &p.bidi else { + f(range, p.dir == Dir::RTL); + return; + }; + + // The bidi crate panics for empty lines. + if range.is_empty() { + f(range, p.dir == Dir::RTL); + return; + } + + // Find the paragraph that contains the line. + let para = bidi + .paragraphs + .iter() + .find(|para| para.range.contains(&range.start)) + .unwrap(); + + // Compute the reordered ranges in visual order (left to right). + let (levels, runs) = bidi.visual_runs(para, range.clone()); + + // Call `f` for each run. + for run in runs { + let rtl = levels[run.start].is_rtl(); + f(run, rtl) + } +} + +/// Collects / reshapes all items for the given `subrange` with continuous +/// direction. +fn collect_range<'a>( + engine: &Engine, + p: &'a Preparation, + range: Range, + trim: usize, + items: &mut Items<'a>, + fallback: &mut Option<ItemEntry<'a>>, +) { + for (subrange, item) in p.slice(range.clone()) { + // All non-text items are just kept, they can't be split. + let Item::Text(shaped) = item else { + items.push(item); + continue; + }; + + // The intersection range of the item, the subrange, and the line's + // trimming. + let sliced = + range.start.max(subrange.start)..range.end.min(subrange.end).min(trim); + + // Whether the item is split by the line. + let split = subrange.start < sliced.start || sliced.end < subrange.end; + + if sliced.is_empty() { + // When there is no text, still keep this as a fallback item, which + // we can use to force a non-zero line-height when the line doesn't + // contain any other text. + *fallback = Some(ItemEntry::from(Item::Text(shaped.empty()))); + } else if split { + // When the item is split in half, reshape it. + let reshaped = shaped.reshape(engine, sliced); + items.push(Item::Text(reshaped)); + } else { + // When the item is fully contained, just keep it. + items.push(item); + } + } +} + +/// Add spacing around punctuation marks for CJ glyphs at line boundaries. +/// +/// See Requirements for Chinese Text Layout, Section 3.1.6.3 Compression of +/// punctuation marks at line start or line end. +fn adjust_cj_at_line_boundaries(p: &Preparation, text: &str, items: &mut Items) { + if text.starts_with(BEGIN_PUNCT_PAT) + || (p.cjk_latin_spacing && text.starts_with(is_of_cj_script)) + { + adjust_cj_at_line_start(p, items); + } + + if text.ends_with(END_PUNCT_PAT) + || (p.cjk_latin_spacing && text.ends_with(is_of_cj_script)) + { + adjust_cj_at_line_end(p, items); + } +} + +/// Add spacing around punctuation marks for CJ glyphs at the line start. +fn adjust_cj_at_line_start(p: &Preparation, items: &mut Items) { + let Some(shaped) = items.first_text_mut() else { return }; + let Some(glyph) = shaped.glyphs.first() else { return }; + + if glyph.is_cjk_right_aligned_punctuation() { + // If the first glyph is a CJK punctuation, we want to + // shrink it. + let glyph = shaped.glyphs.to_mut().first_mut().unwrap(); + let shrink = glyph.shrinkability().0; + glyph.shrink_left(shrink); + shaped.width -= shrink.at(shaped.size); + } else if p.cjk_latin_spacing && glyph.is_cj_script() && glyph.x_offset > Em::zero() { + // If the first glyph is a CJK character adjusted by + // [`add_cjk_latin_spacing`], restore the original width. + let glyph = shaped.glyphs.to_mut().first_mut().unwrap(); + let shrink = glyph.x_offset; + glyph.x_advance -= shrink; + glyph.x_offset = Em::zero(); + glyph.adjustability.shrinkability.0 = Em::zero(); + shaped.width -= shrink.at(shaped.size); + } +} + +/// Add spacing around punctuation marks for CJ glyphs at the line end. +fn adjust_cj_at_line_end(p: &Preparation, items: &mut Items) { + let Some(shaped) = items.last_text_mut() else { return }; + let Some(glyph) = shaped.glyphs.last() else { return }; + + // Deal with CJK punctuation at line ends. + let style = cjk_punct_style(shaped.lang, shaped.region); + + if glyph.is_cjk_left_aligned_punctuation(style) { + // If the last glyph is a CJK punctuation, we want to + // shrink it. + let shrink = glyph.shrinkability().1; + let punct = shaped.glyphs.to_mut().last_mut().unwrap(); + punct.shrink_right(shrink); + shaped.width -= shrink.at(shaped.size); + } else if p.cjk_latin_spacing + && glyph.is_cj_script() + && (glyph.x_advance - glyph.x_offset) > Em::one() + { + // If the last glyph is a CJK character adjusted by + // [`add_cjk_latin_spacing`], restore the original width. + let shrink = glyph.x_advance - glyph.x_offset - Em::one(); + let glyph = shaped.glyphs.to_mut().last_mut().unwrap(); + glyph.x_advance -= shrink; + glyph.adjustability.shrinkability.1 = Em::zero(); + shaped.width -= shrink.at(shaped.size); + } +} + +/// Whether a hyphen should be inserted at the start of the next line. +fn should_repeat_hyphen(pred_line: &Line, text: &str) -> bool { + // If the predecessor line does not end with a `Dash::Hard`, we shall + // not place a hyphen at the start of the next line. + if pred_line.dash != Some(Dash::Hard) { + return false; + } + + // The hyphen should repeat only in the languages that require that feature. + // For more information see the discussion at https://github.com/typst/typst/issues/3235 + let Some(Item::Text(shaped)) = pred_line.items.last() else { return false }; + + match shaped.lang { + // - Lower Sorbian: see https://dolnoserbski.de/ortografija/psawidla/K3 + // - Czech: see https://prirucka.ujc.cas.cz/?id=164 + // - Croatian: see http://pravopis.hr/pravilo/spojnica/68/ + // - Polish: see https://www.ortograf.pl/zasady-pisowni/lacznik-zasady-pisowni + // - Portuguese: see https://www2.senado.leg.br/bdsf/bitstream/handle/id/508145/000997415.pdf (Base XX) + // - Slovak: see https://www.zones.sk/studentske-prace/gramatika/10620-pravopis-rozdelovanie-slov/ + Lang::LOWER_SORBIAN + | Lang::CZECH + | Lang::CROATIAN + | Lang::POLISH + | Lang::PORTUGUESE + | Lang::SLOVAK => true, + + // In Spanish the hyphen is required only if the word next to hyphen is + // not capitalized. Otherwise, the hyphen must not be repeated. + // + // See § 4.1.1.1.2.e on the "Ortografía de la lengua española" + // https://www.rae.es/ortografía/como-signo-de-división-de-palabras-a-final-de-línea + Lang::SPANISH => text.chars().next().map_or(false, |c| !c.is_uppercase()), + + _ => false, + } +} + +/// Commit to a line and build its frame. +#[allow(clippy::too_many_arguments)] +pub fn commit( + engine: &mut Engine, + p: &Preparation, + line: &Line, + width: Abs, + full: Abs, + shrink: bool, + locator: &mut SplitLocator<'_>, + styles: StyleChain, +) -> SourceResult<Frame> { + let mut remaining = width - line.width - p.hang; + let mut offset = Abs::zero(); + + // We always build the line from left to right. In an LTR paragraph, we must + // thus add the hanging indent to the offset. When the paragraph is RTL, the + // hanging indent arises naturally due to the line width. + if p.dir == Dir::LTR { + offset += p.hang; + } + + // Handle hanging punctuation to the left. + if let Some(Item::Text(text)) = line.items.first() { + if let Some(glyph) = text.glyphs.first() { + if !text.dir.is_positive() + && TextElem::overhang_in(text.styles) + && (line.items.len() > 1 || text.glyphs.len() > 1) + { + let amount = overhang(glyph.c) * glyph.x_advance.at(text.size); + offset -= amount; + remaining += amount; + } + } + } + + // Handle hanging punctuation to the right. + if let Some(Item::Text(text)) = line.items.last() { + if let Some(glyph) = text.glyphs.last() { + if text.dir.is_positive() + && TextElem::overhang_in(text.styles) + && (line.items.len() > 1 || text.glyphs.len() > 1) + { + let amount = overhang(glyph.c) * glyph.x_advance.at(text.size); + remaining += amount; + } + } + } + + // Determine how much additional space is needed. The justification_ratio is + // for the first step justification, extra_justification is for the last + // step. For more info on multi-step justification, see Procedures for + // Inter- Character Space Expansion in W3C document Chinese Layout + // Requirements. + let fr = line.fr(); + let mut justification_ratio = 0.0; + let mut extra_justification = Abs::zero(); + + let shrinkability = line.shrinkability(); + let stretchability = line.stretchability(); + if remaining < Abs::zero() && shrinkability > Abs::zero() && shrink { + // Attempt to reduce the length of the line, using shrinkability. + justification_ratio = (remaining / shrinkability).max(-1.0); + remaining = (remaining + shrinkability).min(Abs::zero()); + } else if line.justify && fr.is_zero() { + // Attempt to increase the length of the line, using stretchability. + if stretchability > Abs::zero() { + justification_ratio = (remaining / stretchability).min(1.0); + remaining = (remaining - stretchability).max(Abs::zero()); + } + + let justifiables = line.justifiables(); + if justifiables > 0 && remaining > Abs::zero() { + // Underfull line, distribute the extra space. + extra_justification = remaining / justifiables as f64; + remaining = Abs::zero(); + } + } + + let mut top = Abs::zero(); + let mut bottom = Abs::zero(); + + // Build the frames and determine the height and baseline. + let mut frames = vec![]; + for item in line.items.iter() { + let mut push = |offset: &mut Abs, frame: Frame| { + let width = frame.width(); + top.set_max(frame.baseline()); + bottom.set_max(frame.size().y - frame.baseline()); + frames.push((*offset, frame)); + *offset += width; + }; + + match item { + Item::Absolute(v, _) => { + offset += *v; + } + Item::Fractional(v, elem) => { + let amount = v.share(fr, remaining); + if let Some((elem, loc, styles)) = elem { + let region = Size::new(amount, full); + let mut frame = + layout_box(elem, engine, loc.relayout(), *styles, region)?; + frame.translate(Point::with_y(TextElem::baseline_in(*styles))); + push(&mut offset, frame.post_processed(*styles)); + } else { + offset += amount; + } + } + Item::Text(shaped) => { + let frame = shaped.build( + engine, + &p.spans, + justification_ratio, + extra_justification, + ); + push(&mut offset, frame.post_processed(shaped.styles)); + } + Item::Frame(frame, styles) => { + let mut frame = frame.clone(); + frame.translate(Point::with_y(TextElem::baseline_in(*styles))); + push(&mut offset, frame.post_processed(*styles)); + } + Item::Tag(tag) => { + let mut frame = Frame::soft(Size::zero()); + frame.push(Point::zero(), FrameItem::Tag((*tag).clone())); + frames.push((offset, frame)); + } + Item::Skip(_) => {} + } + } + + // Remaining space is distributed now. + if !fr.is_zero() { + remaining = Abs::zero(); + } + + let size = Size::new(width, top + bottom); + let mut output = Frame::soft(size); + output.set_baseline(top); + + add_par_line_marker(&mut output, styles, engine, locator, top); + + // Construct the line's frame. + for (offset, frame) in frames { + let x = offset + p.align.position(remaining); + let y = top - frame.baseline(); + output.push_frame(Point::new(x, y), frame); + } + + Ok(output) +} + +/// Adds a paragraph line marker to a paragraph line's output frame if +/// line numbering is not `None` at this point. Ensures other style properties, +/// namely number margin, number align and number clearance, are stored in the +/// marker as well. +/// +/// The `top` parameter is used to ensure the marker, and thus the line's +/// number in the margin, is aligned to the line's baseline. +fn add_par_line_marker( + output: &mut Frame, + styles: StyleChain, + engine: &mut Engine, + locator: &mut SplitLocator, + top: Abs, +) { + let Some(numbering) = ParLine::numbering_in(styles) else { return }; + let margin = ParLine::number_margin_in(styles); + let align = ParLine::number_align_in(styles); + + // Delay resolving the number clearance until line numbers are laid out to + // avoid inconsistent spacing depending on varying font size. + let clearance = ParLine::number_clearance_in(styles); + + // Elements in tags must have a location for introspection to work. We do + // the work here instead of going through all of the realization process + // just for this, given we don't need to actually place the marker as we + // manually search for it in the frame later (when building a root flow, + // where line numbers can be displayed), so we just need it to be in a tag + // and to be valid (to have a location). + let mut marker = ParLineMarker::new(numbering, align, margin, clearance).pack(); + let key = typst_utils::hash128(&marker); + let loc = locator.next_location(engine.introspector, key); + marker.set_location(loc); + + // Create start and end tags through which we can search for this line's + // marker later. The 'x' coordinate is not important, just the 'y' + // coordinate, as that's what is used for line numbers. We will place the + // tags among other subframes in the line such that it is aligned with the + // line's general baseline. However, the line number will still need to + // manually adjust its own 'y' position based on its own baseline. + let pos = Point::with_y(top); + output.push(pos, FrameItem::Tag(Tag::Start(marker))); + output.push(pos, FrameItem::Tag(Tag::End(loc, key))); +} + +/// How much a character should hang into the end margin. +/// +/// For more discussion, see: +/// <https://recoveringphysicist.com/21/> +fn overhang(c: char) -> f64 { + match c { + // Dashes. + '–' | '—' => 0.2, + '-' => 0.55, + + // Punctuation. + '.' | ',' => 0.8, + ':' | ';' => 0.3, + + // Arabic + '\u{60C}' | '\u{6D4}' => 0.4, + + _ => 0.0, + } +} + +/// A collection of owned or borrowed paragraph items. +pub struct Items<'a>(Vec<ItemEntry<'a>>); + +impl<'a> Items<'a> { + /// Create empty items. + pub fn new() -> Self { + Self(vec![]) + } + + /// Push a new item. + pub fn push(&mut self, entry: impl Into<ItemEntry<'a>>) { + self.0.push(entry.into()); + } + + /// Iterate over the items + pub fn iter(&self) -> impl Iterator<Item = &Item<'a>> { + self.0.iter().map(|item| &**item) + } + + /// Access the first item. + pub fn first(&self) -> Option<&Item<'a>> { + self.0.first().map(|item| &**item) + } + + /// Access the last item. + pub fn last(&self) -> Option<&Item<'a>> { + self.0.last().map(|item| &**item) + } + + /// Access the first item mutably, if it is text. + pub fn first_text_mut(&mut self) -> Option<&mut ShapedText<'a>> { + self.0.first_mut()?.text_mut() + } + + /// Access the last item mutably, if it is text. + pub fn last_text_mut(&mut self) -> Option<&mut ShapedText<'a>> { + self.0.last_mut()?.text_mut() + } + + /// Reorder the items starting at the given index to RTL. + pub fn reorder(&mut self, from: usize) { + self.0[from..].reverse() + } +} + +impl<'a> FromIterator<ItemEntry<'a>> for Items<'a> { + fn from_iter<I: IntoIterator<Item = ItemEntry<'a>>>(iter: I) -> Self { + Self(iter.into_iter().collect()) + } +} + +impl<'a> Deref for Items<'a> { + type Target = Vec<ItemEntry<'a>>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'a> DerefMut for Items<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Debug for Items<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_list().entries(&self.0).finish() + } +} + +/// A reference to or a boxed item. +pub enum ItemEntry<'a> { + Ref(&'a Item<'a>), + Box(Box<Item<'a>>), +} + +impl<'a> ItemEntry<'a> { + fn text_mut(&mut self) -> Option<&mut ShapedText<'a>> { + match self { + Self::Ref(item) => { + let text = item.text()?; + *self = Self::Box(Box::new(Item::Text(text.clone()))); + match self { + Self::Box(item) => item.text_mut(), + _ => unreachable!(), + } + } + Self::Box(item) => item.text_mut(), + } + } +} + +impl<'a> Deref for ItemEntry<'a> { + type Target = Item<'a>; + + fn deref(&self) -> &Self::Target { + match self { + Self::Ref(item) => item, + Self::Box(item) => item, + } + } +} + +impl Debug for ItemEntry<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + (**self).fmt(f) + } +} + +impl<'a> From<&'a Item<'a>> for ItemEntry<'a> { + fn from(item: &'a Item<'a>) -> Self { + Self::Ref(item) + } +} + +impl<'a> From<Item<'a>> for ItemEntry<'a> { + fn from(item: Item<'a>) -> Self { + Self::Box(Box::new(item)) + } +} diff --git a/crates/typst-layout/src/inline/linebreak.rs b/crates/typst-layout/src/inline/linebreak.rs new file mode 100644 index 00000000..7fc8b368 --- /dev/null +++ b/crates/typst-layout/src/inline/linebreak.rs @@ -0,0 +1,980 @@ +use std::ops::{Add, Sub}; + +use az::SaturatingAs; +use icu_properties::maps::{CodePointMapData, CodePointMapDataBorrowed}; +use icu_properties::LineBreak; +use icu_provider::AsDeserializingBufferProvider; +use icu_provider_adapters::fork::ForkByKeyProvider; +use icu_provider_blob::BlobDataProvider; +use icu_segmenter::LineSegmenter; +use once_cell::sync::Lazy; +use typst_library::engine::Engine; +use typst_library::layout::{Abs, Em}; +use typst_library::model::Linebreaks; +use typst_library::text::{is_default_ignorable, Lang, TextElem}; +use typst_syntax::link_prefix; +use unicode_segmentation::UnicodeSegmentation; + +use super::*; + +/// The cost of a line or paragraph layout. +type Cost = f64; + +// Cost parameters. +// +// We choose higher costs than the Knuth-Plass paper (which would be 50) because +// it hyphenates way to eagerly in Typst otherwise. Could be related to the +// ratios coming out differently since Typst doesn't have the concept of glue, +// so things work a bit differently. +const DEFAULT_HYPH_COST: Cost = 135.0; +const DEFAULT_RUNT_COST: Cost = 100.0; + +// Other parameters. +const MIN_RATIO: f64 = -1.0; +const MIN_APPROX_RATIO: f64 = -0.5; +const BOUND_EPS: f64 = 1e-3; + +/// The ICU blob data. +fn blob() -> BlobDataProvider { + BlobDataProvider::try_new_from_static_blob(typst_assets::icu::ICU).unwrap() +} + +/// The general line break segmenter. +static SEGMENTER: Lazy<LineSegmenter> = + Lazy::new(|| LineSegmenter::try_new_lstm_with_buffer_provider(&blob()).unwrap()); + +/// The line break segmenter for Chinese/Japanese text. +static CJ_SEGMENTER: Lazy<LineSegmenter> = Lazy::new(|| { + let cj_blob = + BlobDataProvider::try_new_from_static_blob(typst_assets::icu::ICU_CJ_SEGMENT) + .unwrap(); + let cj_provider = ForkByKeyProvider::new(cj_blob, blob()); + LineSegmenter::try_new_lstm_with_buffer_provider(&cj_provider).unwrap() +}); + +/// The Unicode line break properties for each code point. +static LINEBREAK_DATA: Lazy<CodePointMapData<LineBreak>> = Lazy::new(|| { + icu_properties::maps::load_line_break(&blob().as_deserializing()).unwrap() +}); + +/// A line break opportunity. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum Breakpoint { + /// Just a normal opportunity (e.g. after a space). + Normal, + /// A mandatory breakpoint (after '\n' or at the end of the text). + Mandatory, + /// An opportunity for hyphenating and how many chars are before/after it + /// in the word. + Hyphen(u8, u8), +} + +impl Breakpoint { + /// Trim a line before this breakpoint. + pub fn trim(self, line: &str) -> &str { + // Trim default ignorables. + let line = line.trim_end_matches(is_default_ignorable); + + match self { + // Trim whitespace. + Self::Normal => line.trim_end_matches(char::is_whitespace), + + // Trim linebreaks. + Self::Mandatory => { + let lb = LINEBREAK_DATA.as_borrowed(); + line.trim_end_matches(|c| { + matches!( + lb.get(c), + LineBreak::MandatoryBreak + | LineBreak::CarriageReturn + | LineBreak::LineFeed + | LineBreak::NextLine + ) + }) + } + + // Trim nothing further. + Self::Hyphen(..) => line, + } + } + + /// Whether this is a hyphen breakpoint. + pub fn is_hyphen(self) -> bool { + matches!(self, Self::Hyphen(..)) + } +} + +/// Breaks the paragraph into lines. +pub fn linebreak<'a>( + engine: &Engine, + p: &'a Preparation<'a>, + width: Abs, +) -> Vec<Line<'a>> { + let linebreaks = p.linebreaks.unwrap_or_else(|| { + if p.justify { + Linebreaks::Optimized + } else { + Linebreaks::Simple + } + }); + + match linebreaks { + Linebreaks::Simple => linebreak_simple(engine, p, width), + Linebreaks::Optimized => linebreak_optimized(engine, p, width), + } +} + +/// Performs line breaking in simple first-fit style. This means that we build +/// lines greedily, always taking the longest possible line. This may lead to +/// very unbalanced line, but is fast and simple. +#[typst_macros::time] +fn linebreak_simple<'a>( + engine: &Engine, + p: &'a Preparation<'a>, + width: Abs, +) -> Vec<Line<'a>> { + let mut lines = Vec::with_capacity(16); + let mut start = 0; + let mut last = None; + + breakpoints(p, |end, breakpoint| { + // Compute the line and its size. + let mut attempt = line(engine, p, start..end, breakpoint, lines.last()); + + // If the line doesn't fit anymore, we push the last fitting attempt + // into the stack and rebuild the line from the attempt's end. The + // resulting line cannot be broken up further. + if !width.fits(attempt.width) { + if let Some((last_attempt, last_end)) = last.take() { + lines.push(last_attempt); + start = last_end; + attempt = line(engine, p, start..end, breakpoint, lines.last()); + } + } + + // Finish the current line if there is a mandatory line break (i.e. due + // to "\n") or if the line doesn't fit horizontally already since then + // no shorter line will be possible. + if breakpoint == Breakpoint::Mandatory || !width.fits(attempt.width) { + lines.push(attempt); + start = end; + last = None; + } else { + last = Some((attempt, end)); + } + }); + + if let Some((line, _)) = last { + lines.push(line); + } + + lines +} + +/// Performs line breaking in optimized Knuth-Plass style. Here, we use more +/// context to determine the line breaks than in the simple first-fit style. For +/// example, we might choose to cut a line short even though there is still a +/// bit of space to improve the fit of one of the following lines. The +/// Knuth-Plass algorithm is based on the idea of "cost". A line which has a +/// very tight or very loose fit has a higher cost than one that is just right. +/// Ending a line with a hyphen incurs extra cost and endings two successive +/// lines with hyphens even more. +/// +/// To find the layout with the minimal total cost the algorithm uses dynamic +/// programming: For each possible breakpoint it determines the optimal +/// paragraph layout _up to that point_. It walks over all possible start points +/// for a line ending at that point and finds the one for which the cost of the +/// line plus the cost of the optimal paragraph up to the start point (already +/// computed and stored in dynamic programming table) is minimal. The final +/// result is simply the layout determined for the last breakpoint at the end of +/// text. +#[typst_macros::time] +fn linebreak_optimized<'a>( + engine: &Engine, + p: &'a Preparation<'a>, + width: Abs, +) -> Vec<Line<'a>> { + let metrics = CostMetrics::compute(p); + + // Determines the exact costs of a likely good layout through Knuth-Plass + // with approximate metrics. We can use this cost as an upper bound to prune + // the search space in our proper optimization pass below. + let upper_bound = linebreak_optimized_approximate(engine, p, width, &metrics); + + // Using the upper bound, perform exact optimized linebreaking. + linebreak_optimized_bounded(engine, p, width, &metrics, upper_bound) +} + +/// Performs line breaking in optimized Knuth-Plass style, but with an upper +/// bound on the cost. This allows us to skip many parts of the search space. +#[typst_macros::time] +fn linebreak_optimized_bounded<'a>( + engine: &Engine, + p: &'a Preparation<'a>, + width: Abs, + metrics: &CostMetrics, + upper_bound: Cost, +) -> Vec<Line<'a>> { + /// An entry in the dynamic programming table for paragraph optimization. + struct Entry<'a> { + pred: usize, + total: Cost, + line: Line<'a>, + end: usize, + } + + // Dynamic programming table. + let mut table = vec![Entry { pred: 0, total: 0.0, line: Line::empty(), end: 0 }]; + + let mut active = 0; + let mut prev_end = 0; + + breakpoints(p, |end, breakpoint| { + // Find the optimal predecessor. + let mut best: Option<Entry> = None; + + // A lower bound for the cost of all following line attempts. + let mut line_lower_bound = None; + + for (pred_index, pred) in table.iter().enumerate().skip(active) { + let start = pred.end; + let unbreakable = prev_end == start; + + // If the minimum cost we've established for the line is already + // too much, skip this attempt. + if line_lower_bound + .is_some_and(|lower| pred.total + lower > upper_bound + BOUND_EPS) + { + continue; + } + + // Build the line. + let attempt = line(engine, p, start..end, breakpoint, Some(&pred.line)); + + // Determine the cost of the line and its stretch ratio. + let (line_ratio, line_cost) = ratio_and_cost( + p, + metrics, + width, + &pred.line, + &attempt, + breakpoint, + unbreakable, + ); + + // If the line is overfull, we adjust the set of active candidate + // line starts. This is the case if + // - justification is on, but we'd need to shrink too much + // - justification is off and the line just doesn't fit + // + // If this is the earliest breakpoint in the active set + // (active == i), remove it from the active set. If there is an + // earlier one (active < i), then the logically shorter line was + // in fact longer (can happen with negative spacing) and we + // can't trim the active set just yet. + if line_ratio < metrics.min_ratio && active == pred_index { + active += 1; + } + + // The total cost of this line and its chain of predecessors. + let total = pred.total + line_cost; + + // If the line is already underfull (`line_ratio > 0`), any shorter + // slice of the line will be even more underfull. So it'll only get + // worse from here and further attempts would also have a cost + // exceeding `bound`. There is one exception: When the line has + // negative spacing, we can't know for sure, so we don't assign the + // lower bound in that case. + if line_ratio > 0.0 + && line_lower_bound.is_none() + && !attempt.has_negative_width_items() + { + line_lower_bound = Some(line_cost); + } + + // If the cost already exceeds the upper bound, we don't need to + // integrate this result into the table. + if total > upper_bound + BOUND_EPS { + continue; + } + + // If this attempt is better than what we had before, take it! + if best.as_ref().map_or(true, |best| best.total >= total) { + best = Some(Entry { pred: pred_index, total, line: attempt, end }); + } + } + + // If this is a mandatory break, all breakpoints before this one become + // inactive since no line can span over the mandatory break. + if breakpoint == Breakpoint::Mandatory { + active = table.len(); + } + + table.extend(best); + prev_end = end; + }); + + // Retrace the best path. + let mut lines = Vec::with_capacity(16); + let mut idx = table.len() - 1; + + // This should only happen if our bound was faulty. Which shouldn't happen! + if table[idx].end != p.text.len() { + #[cfg(debug_assertions)] + panic!("bounded paragraph layout is incomplete"); + + #[cfg(not(debug_assertions))] + return linebreak_optimized_bounded(engine, p, width, metrics, Cost::INFINITY); + } + + while idx != 0 { + table.truncate(idx + 1); + let entry = table.pop().unwrap(); + lines.push(entry.line); + idx = entry.pred; + } + + lines.reverse(); + lines +} + +/// Runs the normal Knuth-Plass algorithm, but instead of building proper lines +/// (which is costly) to determine costs, it determines approximate costs using +/// cumulative arrays. +/// +/// This results in a likely good paragraph layouts, for which we then compute +/// the exact cost. This cost is an upper bound for proper optimized +/// linebreaking. We can use it to heavily prune the search space. +#[typst_macros::time] +fn linebreak_optimized_approximate( + engine: &Engine, + p: &Preparation, + width: Abs, + metrics: &CostMetrics, +) -> Cost { + // Determine the cumulative estimation metrics. + let estimates = Estimates::compute(p); + + /// An entry in the dynamic programming table for paragraph optimization. + struct Entry { + pred: usize, + total: Cost, + end: usize, + unbreakable: bool, + breakpoint: Breakpoint, + } + + // Dynamic programming table. + let mut table = vec![Entry { + pred: 0, + total: 0.0, + end: 0, + unbreakable: false, + breakpoint: Breakpoint::Mandatory, + }]; + + let mut active = 0; + let mut prev_end = 0; + + breakpoints(p, |end, breakpoint| { + // Find the optimal predecessor. + let mut best: Option<Entry> = None; + for (pred_index, pred) in table.iter().enumerate().skip(active) { + let start = pred.end; + let unbreakable = prev_end == start; + + // Whether the line is justified. This is not 100% accurate w.r.t + // to line()'s behaviour, but good enough. + let justify = p.justify && breakpoint != Breakpoint::Mandatory; + + // We don't really know whether the line naturally ends with a dash + // here, so we can miss that case, but it's ok, since all of this + // just an estimate. + let consecutive_dash = pred.breakpoint.is_hyphen() && breakpoint.is_hyphen(); + + // Estimate how much the line's spaces would need to be stretched to + // make it the desired width. We trim at the end to not take into + // account trailing spaces. This is, again, only an approximation of + // the real behaviour of `line`. + let trimmed_end = start + p.text[start..end].trim_end().len(); + let line_ratio = raw_ratio( + p, + width, + estimates.widths.estimate(start..trimmed_end) + + if breakpoint.is_hyphen() { + metrics.approx_hyphen_width + } else { + Abs::zero() + }, + estimates.stretchability.estimate(start..trimmed_end), + estimates.shrinkability.estimate(start..trimmed_end), + estimates.justifiables.estimate(start..trimmed_end), + ); + + // Determine the line's cost. + let line_cost = raw_cost( + metrics, + breakpoint, + line_ratio, + justify, + unbreakable, + consecutive_dash, + true, + ); + + // Adjust the set of active breakpoints. + // See `linebreak_optimized` for details. + if line_ratio < metrics.min_ratio && active == pred_index { + active += 1; + } + + // The total cost of this line and its chain of predecessors. + let total = pred.total + line_cost; + + // If this attempt is better than what we had before, take it! + if best.as_ref().map_or(true, |best| best.total >= total) { + best = Some(Entry { + pred: pred_index, + total, + end, + unbreakable, + breakpoint, + }); + } + } + + // If this is a mandatory break, all breakpoints before this one become + // inactive. + if breakpoint == Breakpoint::Mandatory { + active = table.len(); + } + + table.extend(best); + prev_end = end; + }); + + // Retrace the best path. + let mut indices = Vec::with_capacity(16); + let mut idx = table.len() - 1; + while idx != 0 { + indices.push(idx); + idx = table[idx].pred; + } + + let mut pred = Line::empty(); + let mut start = 0; + let mut exact = 0.0; + + // The cost that we optimized was only an approximate cost, so the layout we + // got here is only likely to be good, not guaranteed to be the best. We now + // computes its exact cost as that gives us a sound upper bound for the + // proper optimization pass. + for idx in indices.into_iter().rev() { + let Entry { end, breakpoint, unbreakable, .. } = table[idx]; + + let attempt = line(engine, p, start..end, breakpoint, Some(&pred)); + let (ratio, line_cost) = + ratio_and_cost(p, metrics, width, &pred, &attempt, breakpoint, unbreakable); + + // If approximation produces a valid layout without too much shrinking, + // exact layout is guaranteed to find the same layout. If, however, the + // line is overfull, we do not have this guarantee. Then, our bound + // becomes useless and actively harmful (it could be lower than what + // optimal layout produces). Thus, we immediately bail with an infinite + // bound in this case. + if ratio < metrics.min_ratio { + return Cost::INFINITY; + } + + pred = attempt; + start = end; + exact += line_cost; + } + + exact +} + +/// Compute the stretch ratio and cost of a line. +#[allow(clippy::too_many_arguments)] +fn ratio_and_cost( + p: &Preparation, + metrics: &CostMetrics, + available_width: Abs, + pred: &Line, + attempt: &Line, + breakpoint: Breakpoint, + unbreakable: bool, +) -> (f64, Cost) { + let ratio = raw_ratio( + p, + available_width, + attempt.width, + attempt.stretchability(), + attempt.shrinkability(), + attempt.justifiables(), + ); + + let cost = raw_cost( + metrics, + breakpoint, + ratio, + attempt.justify, + unbreakable, + pred.dash.is_some() && attempt.dash.is_some(), + false, + ); + + (ratio, cost) +} + +/// Determine the stretch ratio for a line given raw metrics. +/// +/// - A ratio < min_ratio indicates an overfull line. +/// - A negative ratio indicates a line that needs shrinking. +/// - A ratio of zero indicates a perfect line. +/// - A positive ratio indicates a line that needs stretching. +fn raw_ratio( + p: &Preparation, + available_width: Abs, + line_width: Abs, + stretchability: Abs, + shrinkability: Abs, + justifiables: usize, +) -> f64 { + // Determine how much the line's spaces would need to be stretched + // to make it the desired width. + let delta = available_width - line_width; + + // Determine how much stretch or shrink is natural. + let adjustability = if delta >= Abs::zero() { stretchability } else { shrinkability }; + + // Observations: + // - `delta` is negative for a line that needs shrinking and positive for a + // line that needs stretching. + // - `adjustability` must be non-negative to make sense. + // - `ratio` inherits the sign of `delta`. + let mut ratio = delta / adjustability.max(Abs::zero()); + + // The most likely cause of a NaN result is that `delta` was zero. This + // often happens with monospace fonts and CJK texts. It means that the line + // already fits perfectly, so `ratio` should be zero then. + if ratio.is_nan() { + ratio = 0.0; + } + + // If the ratio exceeds 1, we should stretch above the natural + // stretchability using justifiables. + if ratio > 1.0 { + // We should stretch the line above its stretchability. Now + // calculate the extra amount. Also, don't divide by zero. + let extra_stretch = (delta - adjustability) / justifiables.max(1) as f64; + // Normalize the amount by half the em size. + ratio = 1.0 + extra_stretch / (p.size / 2.0); + } + + // The min value must be < MIN_RATIO, but how much smaller doesn't matter + // since overfull lines have hard-coded huge costs anyway. + // + // The max value is clamped to 10 since it doesn't really matter whether a + // line is stretched 10x or 20x. + ratio.clamp(MIN_RATIO - 1.0, 10.0) +} + +/// Compute the cost of a line given raw metrics. +/// +/// This mostly follows the formula in the Knuth-Plass paper, but there are some +/// adjustments. +fn raw_cost( + metrics: &CostMetrics, + breakpoint: Breakpoint, + ratio: f64, + justify: bool, + unbreakable: bool, + consecutive_dash: bool, + approx: bool, +) -> Cost { + // Determine the stretch/shrink cost of the line. + let badness = if ratio < metrics.min_ratio(approx) { + // Overfull line always has maximum cost. + 1_000_000.0 + } else if breakpoint != Breakpoint::Mandatory || justify || ratio < 0.0 { + // If the line shall be justified or needs shrinking, it has normal + // badness with cost 100|ratio|^3. We limit the ratio to 10 as to not + // get to close to our maximum cost. + 100.0 * ratio.abs().powi(3) + } else { + // If the line shouldn't be justified and doesn't need shrink, we don't + // pay any cost. + 0.0 + }; + + // Compute penalties. + let mut penalty = 0.0; + + // Penalize runts (lone words before a mandatory break / at the end). + if unbreakable && breakpoint == Breakpoint::Mandatory { + penalty += metrics.runt_cost; + } + + // Penalize hyphenation. + if let Breakpoint::Hyphen(l, r) = breakpoint { + // We penalize hyphenations close to the edges of the word (< LIMIT + // chars) extra. For each step of distance from the limit, we add 15% + // to the cost. + const LIMIT: u8 = 5; + let steps = LIMIT.saturating_sub(l) + LIMIT.saturating_sub(r); + let extra = 0.15 * steps as f64; + penalty += (1.0 + extra) * metrics.hyph_cost; + } + + // Penalize two consecutive dashes extra (not necessarily hyphens). + // Knuth-Plass does this separately after the squaring, with a higher cost, + // but I couldn't find any explanation as to why. + if consecutive_dash { + penalty += metrics.hyph_cost; + } + + // From the Knuth-Plass Paper: $ (1 + beta_j + pi_j)^2 $. + // + // We add one to minimize the number of lines when everything else is more + // or less equal. + (1.0 + badness + penalty).powi(2) +} + +/// Calls `f` for all possible points in the text where lines can broken. +/// +/// Yields for each breakpoint the text index, whether the break is mandatory +/// (after `\n`) and whether a hyphen is required (when breaking inside of a +/// word). +/// +/// This is an internal instead of an external iterator because it makes the +/// code much simpler and the consumers of this function don't need the +/// composability and flexibility of external iteration anyway. +fn breakpoints(p: &Preparation, mut f: impl FnMut(usize, Breakpoint)) { + let text = p.text; + + // Single breakpoint at the end for empty text. + if text.is_empty() { + f(0, Breakpoint::Mandatory); + return; + } + + let hyphenate = p.hyphenate != Some(false); + let lb = LINEBREAK_DATA.as_borrowed(); + let segmenter = match p.lang { + Some(Lang::CHINESE | Lang::JAPANESE) => &CJ_SEGMENTER, + _ => &SEGMENTER, + }; + + let mut last = 0; + let mut iter = segmenter.segment_str(text).peekable(); + + loop { + // Special case for links. UAX #14 doesn't handle them well. + let (head, tail) = text.split_at(last); + if head.ends_with("://") || tail.starts_with("www.") { + let (link, _) = link_prefix(tail); + linebreak_link(link, |i| f(last + i, Breakpoint::Normal)); + last += link.len(); + while iter.peek().is_some_and(|&p| p < last) { + iter.next(); + } + } + + // Get the next UAX #14 linebreak opportunity. + let Some(point) = iter.next() else { break }; + + // Skip breakpoint if there is no char before it. icu4x generates one + // at offset 0, but we don't want it. + let Some(c) = text[..point].chars().next_back() else { continue }; + + // Find out whether the last break was mandatory by checking against + // rules LB4 and LB5, special-casing the end of text according to LB3. + // See also: https://docs.rs/icu_segmenter/latest/icu_segmenter/struct.LineSegmenter.html + let breakpoint = if point == text.len() { + Breakpoint::Mandatory + } else { + match lb.get(c) { + // Fix for: https://github.com/unicode-org/icu4x/issues/4146 + LineBreak::Glue | LineBreak::WordJoiner | LineBreak::ZWJ => continue, + LineBreak::MandatoryBreak + | LineBreak::CarriageReturn + | LineBreak::LineFeed + | LineBreak::NextLine => Breakpoint::Mandatory, + _ => Breakpoint::Normal, + } + }; + + // Hyphenate between the last and current breakpoint. + if hyphenate && last < point { + for segment in text[last..point].split_word_bounds() { + if !segment.is_empty() && segment.chars().all(char::is_alphabetic) { + hyphenations(p, &lb, last, segment, &mut f); + } + last += segment.len(); + } + } + + // Call `f` for the UAX #14 break opportunity. + f(point, breakpoint); + last = point; + } +} + +/// Generate breakpoints for hyphenations within a word. +fn hyphenations( + p: &Preparation, + lb: &CodePointMapDataBorrowed<LineBreak>, + mut offset: usize, + word: &str, + mut f: impl FnMut(usize, Breakpoint), +) { + let Some(lang) = lang_at(p, offset) else { return }; + let count = word.chars().count(); + let end = offset + word.len(); + + let mut chars = 0; + for syllable in hypher::hyphenate(word, lang) { + offset += syllable.len(); + chars += syllable.chars().count(); + + // Don't hyphenate after the final syllable. + if offset == end { + continue; + } + + // Filter out hyphenation opportunities where hyphenation was actually + // disabled. + if !hyphenate_at(p, offset) { + continue; + } + + // Filter out forbidden hyphenation opportunities. + if matches!( + syllable.chars().next_back().map(|c| lb.get(c)), + Some(LineBreak::Glue | LineBreak::WordJoiner | LineBreak::ZWJ) + ) { + continue; + } + + // Determine the number of codepoints before and after the hyphenation. + let l = chars.saturating_as::<u8>(); + let r = (count - chars).saturating_as::<u8>(); + + // Call `f` for the word-internal hyphenation opportunity. + f(offset, Breakpoint::Hyphen(l, r)); + } +} + +/// Produce linebreak opportunities for a link. +fn linebreak_link(link: &str, mut f: impl FnMut(usize)) { + #[derive(PartialEq)] + enum Class { + Alphabetic, + Digit, + Open, + Other, + } + + impl Class { + fn of(c: char) -> Self { + if c.is_alphabetic() { + Class::Alphabetic + } else if c.is_numeric() { + Class::Digit + } else if matches!(c, '(' | '[') { + Class::Open + } else { + Class::Other + } + } + } + + let mut offset = 0; + let mut prev = Class::Other; + + for (end, c) in link.char_indices() { + let class = Class::of(c); + + // Emit opportunities when going from + // - other -> other + // - alphabetic -> numeric + // - numeric -> alphabetic + // Never before/after opening delimiters. + if end > 0 + && prev != Class::Open + && if class == Class::Other { prev == Class::Other } else { class != prev } + { + let piece = &link[offset..end]; + if piece.len() < 16 { + // For bearably long segments, emit them as one. + offset = end; + f(offset); + } else { + // If it gets very long (e.g. a hash in the URL), just allow a + // break at every char. + for c in piece.chars() { + offset += c.len_utf8(); + f(offset); + } + } + } + + prev = class; + } +} + +/// Whether hyphenation is enabled at the given offset. +fn hyphenate_at(p: &Preparation, offset: usize) -> bool { + p.hyphenate + .or_else(|| { + let (_, item) = p.get(offset); + let styles = item.text()?.styles; + Some(TextElem::hyphenate_in(styles)) + }) + .unwrap_or(false) +} + +/// The text language at the given offset. +fn lang_at(p: &Preparation, offset: usize) -> Option<hypher::Lang> { + let lang = p.lang.or_else(|| { + let (_, item) = p.get(offset); + let styles = item.text()?.styles; + Some(TextElem::lang_in(styles)) + })?; + + let bytes = lang.as_str().as_bytes().try_into().ok()?; + hypher::Lang::from_iso(bytes) +} + +/// Resolved metrics relevant for cost computation. +struct CostMetrics { + min_ratio: f64, + min_approx_ratio: f64, + approx_hyphen_width: Abs, + hyph_cost: Cost, + runt_cost: Cost, +} + +impl CostMetrics { + /// Compute shared metrics for paragraph optimization. + fn compute(p: &Preparation) -> Self { + Self { + // When justifying, we may stretch spaces below their natural width. + min_ratio: if p.justify { MIN_RATIO } else { 0.0 }, + min_approx_ratio: if p.justify { MIN_APPROX_RATIO } else { 0.0 }, + // Approximate hyphen width for estimates. + approx_hyphen_width: Em::new(0.33).at(p.size), + // Costs. + hyph_cost: DEFAULT_HYPH_COST * p.costs.hyphenation().get(), + runt_cost: DEFAULT_RUNT_COST * p.costs.runt().get(), + } + } + + /// The minimum line ratio we allow for shrinking. For approximate layout, + /// we allow less because otherwise we get an invalid layout fairly often, + /// which makes our bound useless. + fn min_ratio(&self, approx: bool) -> f64 { + if approx { + self.min_approx_ratio + } else { + self.min_ratio + } + } +} + +/// Estimated line metrics. +/// +/// Allows to get a quick estimate of a metric for a line between two byte +/// positions. +struct Estimates { + widths: CumulativeVec<Abs>, + stretchability: CumulativeVec<Abs>, + shrinkability: CumulativeVec<Abs>, + justifiables: CumulativeVec<usize>, +} + +impl Estimates { + /// Compute estimations for approximate Knuth-Plass layout. + fn compute(p: &Preparation) -> Self { + let cap = p.text.len(); + + let mut widths = CumulativeVec::with_capacity(cap); + let mut stretchability = CumulativeVec::with_capacity(cap); + let mut shrinkability = CumulativeVec::with_capacity(cap); + let mut justifiables = CumulativeVec::with_capacity(cap); + + for (range, item) in p.items.iter() { + if let Item::Text(shaped) = item { + for g in shaped.glyphs.iter() { + let byte_len = g.range.len(); + let stretch = g.stretchability().0 + g.stretchability().1; + let shrink = g.shrinkability().0 + g.shrinkability().1; + widths.push(byte_len, g.x_advance.at(shaped.size)); + stretchability.push(byte_len, stretch.at(shaped.size)); + shrinkability.push(byte_len, shrink.at(shaped.size)); + justifiables.push(byte_len, g.is_justifiable() as usize); + } + } else { + widths.push(range.len(), item.natural_width()); + } + + widths.adjust(range.end); + stretchability.adjust(range.end); + shrinkability.adjust(range.end); + justifiables.adjust(range.end); + } + + Self { + widths, + stretchability, + shrinkability, + justifiables, + } + } +} + +/// An accumulative array of a metric. +struct CumulativeVec<T> { + total: T, + summed: Vec<T>, +} + +impl<T> CumulativeVec<T> +where + T: Default + Copy + Add<Output = T> + Sub<Output = T>, +{ + /// Create a new instance with the given capacity. + fn with_capacity(capacity: usize) -> Self { + let total = T::default(); + let mut summed = Vec::with_capacity(capacity); + summed.push(total); + Self { total, summed } + } + + /// Adjust to cover the given byte length. + fn adjust(&mut self, len: usize) { + self.summed.resize(len, self.total); + } + + /// Adds a new segment with the given byte length and metric. + fn push(&mut self, byte_len: usize, metric: T) { + self.total = self.total + metric; + for _ in 0..byte_len { + self.summed.push(self.total); + } + } + + /// Estimates the metrics for the line spanned by the range. + fn estimate(&self, range: Range) -> T { + self.get(range.end) - self.get(range.start) + } + + /// Get the metric at the given byte position. + fn get(&self, index: usize) -> T { + match index.checked_sub(1) { + None => T::default(), + Some(i) => self.summed[i], + } + } +} diff --git a/crates/typst-layout/src/inline/mod.rs b/crates/typst-layout/src/inline/mod.rs new file mode 100644 index 00000000..658e3084 --- /dev/null +++ b/crates/typst-layout/src/inline/mod.rs @@ -0,0 +1,105 @@ +#[path = "box.rs"] +mod box_; +mod collect; +mod deco; +mod finalize; +mod line; +mod linebreak; +mod prepare; +mod shaping; + +pub use self::box_::layout_box; + +use comemo::{Track, Tracked, TrackedMut}; +use typst_library::diag::SourceResult; +use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::foundations::{StyleChain, StyleVec}; +use typst_library::introspection::{Introspector, Locator, LocatorLink}; +use typst_library::layout::{Fragment, Size}; +use typst_library::model::ParElem; +use typst_library::routines::Routines; +use typst_library::World; + +use self::collect::{collect, Item, Segment, SpanMapper}; +use self::deco::decorate; +use self::finalize::finalize; +use self::line::{commit, line, Line}; +use self::linebreak::{linebreak, Breakpoint}; +use self::prepare::{prepare, Preparation}; +use self::shaping::{ + cjk_punct_style, is_of_cj_script, shape_range, ShapedGlyph, ShapedText, + BEGIN_PUNCT_PAT, END_PUNCT_PAT, +}; + +/// Range of a substring of text. +type Range = std::ops::Range<usize>; + +/// Layouts content inline. +pub fn layout_inline( + engine: &mut Engine, + children: &StyleVec, + locator: Locator, + styles: StyleChain, + consecutive: bool, + region: Size, + expand: bool, +) -> SourceResult<Fragment> { + layout_inline_impl( + children, + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + locator.track(), + styles, + consecutive, + region, + expand, + ) +} + +/// The internal, memoized implementation of `layout_inline`. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn layout_inline_impl( + children: &StyleVec, + routines: &Routines, + world: Tracked<dyn World + '_>, + introspector: Tracked<Introspector>, + traced: Tracked<Traced>, + sink: TrackedMut<Sink>, + route: Tracked<Route>, + locator: Tracked<Locator>, + styles: StyleChain, + consecutive: bool, + region: Size, + expand: bool, +) -> SourceResult<Fragment> { + let link = LocatorLink::new(locator); + let locator = Locator::link(&link); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route), + }; + + let mut locator = locator.split(); + + // Collect all text into one string for BiDi analysis. + let (text, segments, spans) = + collect(children, &mut engine, &mut locator, &styles, region, consecutive)?; + + // Perform BiDi analysis and then prepares paragraph layout. + let p = prepare(&mut engine, children, &text, segments, spans, styles)?; + + // Break the paragraph into lines. + let lines = linebreak(&engine, &p, region.x - p.hang); + + // Turn the selected lines into frames. + finalize(&mut engine, &p, &lines, styles, region, expand, &mut locator) +} diff --git a/crates/typst-layout/src/inline/prepare.rs b/crates/typst-layout/src/inline/prepare.rs new file mode 100644 index 00000000..2dd79aec --- /dev/null +++ b/crates/typst-layout/src/inline/prepare.rs @@ -0,0 +1,196 @@ +use typst_library::foundations::{Resolve, Smart}; +use typst_library::layout::{Abs, AlignElem, Dir, Em, FixedAlignment}; +use typst_library::model::Linebreaks; +use typst_library::text::{Costs, Lang, TextElem}; +use unicode_bidi::{BidiInfo, Level as BidiLevel}; + +use super::*; + +/// A paragraph representation in which children are already layouted and text +/// is already preshaped. +/// +/// In many cases, we can directly reuse these results when constructing a line. +/// Only when a line break falls onto a text index that is not safe-to-break per +/// rustybuzz, we have to reshape that portion. +pub struct Preparation<'a> { + /// The paragraph's full text. + pub text: &'a str, + /// Bidirectional text embedding levels for the paragraph. + /// + /// This is `None` if the paragraph is BiDi-uniform (all the base direction). + pub bidi: Option<BidiInfo<'a>>, + /// Text runs, spacing and layouted elements. + pub items: Vec<(Range, Item<'a>)>, + /// Maps from byte indices to item indices. + pub indices: Vec<usize>, + /// The span mapper. + pub spans: SpanMapper, + /// Whether to hyphenate if it's the same for all children. + pub hyphenate: Option<bool>, + /// Costs for various layout decisions. + pub costs: Costs, + /// The dominant direction. + pub dir: Dir, + /// The text language if it's the same for all children. + pub lang: Option<Lang>, + /// The paragraph's resolved horizontal alignment. + pub align: FixedAlignment, + /// Whether to justify the paragraph. + pub justify: bool, + /// The paragraph's hanging indent. + pub hang: Abs, + /// Whether to add spacing between CJK and Latin characters. + pub cjk_latin_spacing: bool, + /// Whether font fallback is enabled for this paragraph. + pub fallback: bool, + /// How to determine line breaks. + pub linebreaks: Smart<Linebreaks>, + /// The text size. + pub size: Abs, +} + +impl<'a> Preparation<'a> { + /// Get the item that contains the given `text_offset`. + pub fn get(&self, offset: usize) -> &(Range, Item<'a>) { + let idx = self.indices.get(offset).copied().unwrap_or(0); + &self.items[idx] + } + + /// Iterate over the items that intersect the given `sliced` range. + pub fn slice(&self, sliced: Range) -> impl Iterator<Item = &(Range, Item<'a>)> { + // Usually, we don't want empty-range items at the start of the line + // (because they will be part of the previous line), but for the first + // line, we need to keep them. + let start = match sliced.start { + 0 => 0, + n => self.indices.get(n).copied().unwrap_or(0), + }; + self.items[start..].iter().take_while(move |(range, _)| { + range.start < sliced.end || range.end <= sliced.end + }) + } +} + +/// Performs BiDi analysis and then prepares paragraph layout by building a +/// representation on which we can do line breaking without layouting each and +/// every line from scratch. +#[typst_macros::time] +pub fn prepare<'a>( + engine: &mut Engine, + children: &'a StyleVec, + text: &'a str, + segments: Vec<Segment<'a>>, + spans: SpanMapper, + styles: StyleChain<'a>, +) -> SourceResult<Preparation<'a>> { + let dir = TextElem::dir_in(styles); + let default_level = match dir { + Dir::RTL => BidiLevel::rtl(), + _ => BidiLevel::ltr(), + }; + + let bidi = BidiInfo::new(text, Some(default_level)); + let is_bidi = bidi + .levels + .iter() + .any(|level| level.is_ltr() != default_level.is_ltr()); + + let mut cursor = 0; + let mut items = Vec::with_capacity(segments.len()); + + // Shape the text to finalize the items. + for segment in segments { + let len = segment.textual_len(); + let end = cursor + len; + let range = cursor..end; + + match segment { + Segment::Text(_, styles) => { + shape_range(&mut items, engine, text, &bidi, range, styles); + } + Segment::Item(item) => items.push((range, item)), + } + + cursor = end; + } + + // Build the mapping from byte to item indices. + let mut indices = Vec::with_capacity(text.len()); + for (i, (range, _)) in items.iter().enumerate() { + indices.extend(range.clone().map(|_| i)); + } + + let cjk_latin_spacing = TextElem::cjk_latin_spacing_in(styles).is_auto(); + if cjk_latin_spacing { + add_cjk_latin_spacing(&mut items); + } + + Ok(Preparation { + text, + bidi: is_bidi.then_some(bidi), + items, + indices, + spans, + hyphenate: children.shared_get(styles, TextElem::hyphenate_in), + costs: TextElem::costs_in(styles), + dir, + lang: children.shared_get(styles, TextElem::lang_in), + align: AlignElem::alignment_in(styles).resolve(styles).x, + justify: ParElem::justify_in(styles), + hang: ParElem::hanging_indent_in(styles), + cjk_latin_spacing, + fallback: TextElem::fallback_in(styles), + linebreaks: ParElem::linebreaks_in(styles), + size: TextElem::size_in(styles), + }) +} + +/// Add some spacing between Han characters and western characters. See +/// Requirements for Chinese Text Layout, Section 3.2.2 Mixed Text Composition +/// in Horizontal Written Mode +fn add_cjk_latin_spacing(items: &mut [(Range, Item)]) { + let mut items = items + .iter_mut() + .filter(|(_, x)| !matches!(x, Item::Tag(_))) + .peekable(); + + let mut prev: Option<&ShapedGlyph> = None; + while let Some((_, item)) = items.next() { + let Some(text) = item.text_mut() else { + prev = None; + continue; + }; + + // Since we only call this function in [`prepare`], we can assume that + // the Cow is owned, and `to_mut` can be called without overhead. + debug_assert!(matches!(text.glyphs, std::borrow::Cow::Owned(_))); + let mut glyphs = text.glyphs.to_mut().iter_mut().peekable(); + + while let Some(glyph) = glyphs.next() { + let next = glyphs.peek().map(|n| n as _).or_else(|| { + items + .peek() + .and_then(|(_, i)| i.text()) + .and_then(|shaped| shaped.glyphs.first()) + }); + + // Case 1: CJ followed by a Latin character + if glyph.is_cj_script() && next.is_some_and(|g| g.is_letter_or_number()) { + // The spacing is default to 1/4 em, and can be shrunk to 1/8 em. + glyph.x_advance += Em::new(0.25); + glyph.adjustability.shrinkability.1 += Em::new(0.125); + text.width += Em::new(0.25).at(text.size); + } + + // Case 2: Latin followed by a CJ character + if glyph.is_cj_script() && prev.is_some_and(|g| g.is_letter_or_number()) { + glyph.x_advance += Em::new(0.25); + glyph.x_offset += Em::new(0.25); + glyph.adjustability.shrinkability.0 += Em::new(0.125); + text.width += Em::new(0.25).at(text.size); + } + + prev = Some(glyph); + } + } +} diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs new file mode 100644 index 00000000..bd803b52 --- /dev/null +++ b/crates/typst-layout/src/inline/shaping.rs @@ -0,0 +1,1175 @@ +use std::borrow::Cow; +use std::fmt::{self, Debug, Formatter}; +use std::str::FromStr; +use std::sync::Arc; + +use az::SaturatingAs; +use ecow::EcoString; +use rustybuzz::{BufferFlags, ShapePlan, UnicodeBuffer}; +use ttf_parser::Tag; +use typst_library::engine::Engine; +use typst_library::foundations::{Smart, StyleChain}; +use typst_library::layout::{Abs, Dir, Em, Frame, FrameItem, Point, Size}; +use typst_library::text::{ + families, features, is_default_ignorable, variant, Font, FontVariant, Glyph, Lang, + Region, TextEdgeBounds, TextElem, TextItem, +}; +use typst_library::World; +use typst_utils::SliceExt; +use unicode_bidi::{BidiInfo, Level as BidiLevel}; +use unicode_script::{Script, UnicodeScript}; + +use super::{decorate, Item, Range, SpanMapper}; + +/// The result of shaping text. +/// +/// This type contains owned or borrowed shaped text runs, which can be +/// measured, used to reshape substrings more quickly and converted into a +/// frame. +#[derive(Clone)] +pub struct ShapedText<'a> { + /// The start of the text in the full paragraph. + pub base: usize, + /// The text that was shaped. + pub text: &'a str, + /// The text direction. + pub dir: Dir, + /// The text language. + pub lang: Lang, + /// The text region. + pub region: Option<Region>, + /// The text's style properties. + pub styles: StyleChain<'a>, + /// The font variant. + pub variant: FontVariant, + /// The font size. + pub size: Abs, + /// The width of the text's bounding box. + pub width: Abs, + /// The shaped glyphs. + pub glyphs: Cow<'a, [ShapedGlyph]>, +} + +/// A single glyph resulting from shaping. +#[derive(Debug, Clone)] +pub struct ShapedGlyph { + /// The font the glyph is contained in. + pub font: Font, + /// The glyph's index in the font. + pub glyph_id: u16, + /// The advance width of the glyph. + pub x_advance: Em, + /// The horizontal offset of the glyph. + pub x_offset: Em, + /// The vertical offset of the glyph. + pub y_offset: Em, + /// The adjustability of the glyph. + pub adjustability: Adjustability, + /// The byte range of this glyph's cluster in the full paragraph. A cluster + /// is a sequence of one or multiple glyphs that cannot be separated and + /// must always be treated as a union. + /// + /// The range values of the glyphs in a [`ShapedText`] should not overlap + /// with each other, and they should be monotonically increasing (for + /// left-to-right or top-to-bottom text) or monotonically decreasing (for + /// right-to-left or bottom-to-top text). + pub range: Range, + /// Whether splitting the shaping result before this glyph would yield the + /// same results as shaping the parts to both sides of `text_index` + /// separately. + pub safe_to_break: bool, + /// The first char in this glyph's cluster. + pub c: char, + /// Whether this glyph is justifiable for CJK scripts. + pub is_justifiable: bool, + /// The script of the glyph. + pub script: Script, +} + +#[derive(Debug, Clone, Default)] +pub struct Adjustability { + /// The left and right stretchability + pub stretchability: (Em, Em), + /// The left and right shrinkability + pub shrinkability: (Em, Em), +} + +impl ShapedGlyph { + /// Whether the glyph is a space. + pub fn is_space(&self) -> bool { + is_space(self.c) + } + + /// Whether the glyph is justifiable. + pub fn is_justifiable(&self) -> bool { + // GB style is not relevant here. + self.is_justifiable + } + + /// Whether the glyph is part of Chinese or Japanese script (i.e. CJ, not CJK). + pub fn is_cj_script(&self) -> bool { + is_cj_script(self.c, self.script) + } + + pub fn is_cjk_punctuation(&self) -> bool { + self.is_cjk_left_aligned_punctuation(CjkPunctStyle::Gb) + || self.is_cjk_right_aligned_punctuation() + || self.is_cjk_center_aligned_punctuation(CjkPunctStyle::Gb) + } + + /// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment> + pub fn is_cjk_left_aligned_punctuation(&self, style: CjkPunctStyle) -> bool { + is_cjk_left_aligned_punctuation( + self.c, + self.x_advance, + self.stretchability(), + style, + ) + } + + /// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment> + pub fn is_cjk_right_aligned_punctuation(&self) -> bool { + is_cjk_right_aligned_punctuation(self.c, self.x_advance, self.stretchability()) + } + + /// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment> + pub fn is_cjk_center_aligned_punctuation(&self, style: CjkPunctStyle) -> bool { + is_cjk_center_aligned_punctuation(self.c, style) + } + + /// Whether the glyph is a western letter or number. + pub fn is_letter_or_number(&self) -> bool { + matches!(self.c.script(), Script::Latin | Script::Greek | Script::Cyrillic) + || matches!(self.c, '#' | '$' | '%' | '&') + || self.c.is_ascii_digit() + } + + pub fn base_adjustability(&self, style: CjkPunctStyle) -> Adjustability { + let width = self.x_advance; + if self.is_space() { + Adjustability { + // The number for spaces is from Knuth-Plass' paper + stretchability: (Em::zero(), width / 2.0), + shrinkability: (Em::zero(), width / 3.0), + } + } else if self.is_cjk_left_aligned_punctuation(style) { + Adjustability { + stretchability: (Em::zero(), Em::zero()), + shrinkability: (Em::zero(), width / 2.0), + } + } else if self.is_cjk_right_aligned_punctuation() { + Adjustability { + stretchability: (Em::zero(), Em::zero()), + shrinkability: (width / 2.0, Em::zero()), + } + } else if self.is_cjk_center_aligned_punctuation(style) { + Adjustability { + stretchability: (Em::zero(), Em::zero()), + shrinkability: (width / 4.0, width / 4.0), + } + } else { + Adjustability::default() + } + } + + /// The stretchability of the character. + pub fn stretchability(&self) -> (Em, Em) { + self.adjustability.stretchability + } + + /// The shrinkability of the character. + pub fn shrinkability(&self) -> (Em, Em) { + self.adjustability.shrinkability + } + + /// Shrink the width of glyph on the left side. + pub fn shrink_left(&mut self, amount: Em) { + self.x_offset -= amount; + self.x_advance -= amount; + self.adjustability.shrinkability.0 -= amount; + } + + /// Shrink the width of glyph on the right side. + pub fn shrink_right(&mut self, amount: Em) { + self.x_advance -= amount; + self.adjustability.shrinkability.1 -= amount; + } +} + +/// A side you can go toward. +enum Side { + /// To the left-hand side. + Left, + /// To the right-hand side. + Right, +} + +impl<'a> ShapedText<'a> { + /// Build the shaped text's frame. + /// + /// The `justification` defines how much extra advance width each + /// [justifiable glyph](ShapedGlyph::is_justifiable) will get. + pub fn build( + &self, + engine: &Engine, + spans: &SpanMapper, + justification_ratio: f64, + extra_justification: Abs, + ) -> Frame { + let (top, bottom) = self.measure(engine); + let size = Size::new(self.width, top + bottom); + + let mut offset = Abs::zero(); + let mut frame = Frame::soft(size); + frame.set_baseline(top); + + let shift = TextElem::baseline_in(self.styles); + let decos = TextElem::deco_in(self.styles); + let fill = TextElem::fill_in(self.styles); + let stroke = TextElem::stroke_in(self.styles); + let span_offset = TextElem::span_offset_in(self.styles); + + for ((font, y_offset), group) in + self.glyphs.as_ref().group_by_key(|g| (g.font.clone(), g.y_offset)) + { + let mut range = group[0].range.clone(); + for glyph in group { + range.start = range.start.min(glyph.range.start); + range.end = range.end.max(glyph.range.end); + } + + let pos = Point::new(offset, top + shift - y_offset.at(self.size)); + let glyphs: Vec<Glyph> = group + .iter() + .map(|shaped: &ShapedGlyph| { + let adjustability_left = if justification_ratio < 0.0 { + shaped.shrinkability().0 + } else { + shaped.stretchability().0 + }; + let adjustability_right = if justification_ratio < 0.0 { + shaped.shrinkability().1 + } else { + shaped.stretchability().1 + }; + + let justification_left = adjustability_left * justification_ratio; + let mut justification_right = + adjustability_right * justification_ratio; + if shaped.is_justifiable() { + justification_right += + Em::from_length(extra_justification, self.size) + } + + frame.size_mut().x += justification_left.at(self.size) + + justification_right.at(self.size); + + // We may not be able to reach the offset completely if + // it exceeds u16, but better to have a roughly correct + // span offset than nothing. + let mut span = spans.span_at(shaped.range.start); + span.1 = span.1.saturating_add(span_offset.saturating_as()); + + // |<---- a Glyph ---->| + // -->|ShapedGlyph|<-- + // +---+-----------+---+ + // | | *********| | + // | | * | | + // | | * ****| | + // | | * *| | + // | | *********| | + // +---+--+--------+---+ + // A B C D + // Note A, B, D could be positive, zero, or negative. + // A: justification_left + // B: ShapedGlyph's x_offset + // (though a small part of the glyph may go inside B) + // B+C: ShapedGlyph's x_advance + // D: justification_right + // A+B: Glyph's x_offset + // A+B+C+D: Glyph's x_advance + Glyph { + id: shaped.glyph_id, + x_advance: shaped.x_advance + + justification_left + + justification_right, + x_offset: shaped.x_offset + justification_left, + range: (shaped.range.start - range.start).saturating_as() + ..(shaped.range.end - range.start).saturating_as(), + span, + } + }) + .collect(); + + let item = TextItem { + font, + size: self.size, + lang: self.lang, + region: self.region, + fill: fill.clone(), + stroke: stroke.clone().map(|s| s.unwrap_or_default()), + text: self.text[range.start - self.base..range.end - self.base].into(), + glyphs, + }; + + let width = item.width(); + if decos.is_empty() { + frame.push(pos, FrameItem::Text(item)); + } else { + // Apply line decorations. + frame.push(pos, FrameItem::Text(item.clone())); + for deco in &decos { + decorate(&mut frame, deco, &item, width, shift, pos); + } + } + + offset += width; + } + + frame + } + + /// Measure the top and bottom extent of this text. + pub fn measure(&self, engine: &Engine) -> (Abs, Abs) { + let mut top = Abs::zero(); + let mut bottom = Abs::zero(); + + let top_edge = TextElem::top_edge_in(self.styles); + let bottom_edge = TextElem::bottom_edge_in(self.styles); + + // Expand top and bottom by reading the font's vertical metrics. + let mut expand = |font: &Font, bounds: TextEdgeBounds| { + let (t, b) = font.edges(top_edge, bottom_edge, self.size, bounds); + top.set_max(t); + bottom.set_max(b); + }; + + if self.glyphs.is_empty() { + // When there are no glyphs, we just use the vertical metrics of the + // first available font. + let world = engine.world; + for family in families(self.styles) { + if let Some(font) = world + .book() + .select(family, self.variant) + .and_then(|id| world.font(id)) + { + expand(&font, TextEdgeBounds::Zero); + break; + } + } + } else { + for g in self.glyphs.iter() { + expand(&g.font, TextEdgeBounds::Glyph(g.glyph_id)); + } + } + + (top, bottom) + } + + /// How many glyphs are in the text where we can insert additional + /// space when encountering underfull lines. + pub fn justifiables(&self) -> usize { + self.glyphs.iter().filter(|g| g.is_justifiable()).count() + } + + /// Whether the last glyph is a CJK character which should not be justified + /// on line end. + pub fn cjk_justifiable_at_last(&self) -> bool { + self.glyphs + .last() + .map(|g| g.is_cj_script() || g.is_cjk_punctuation()) + .unwrap_or(false) + } + + /// The stretchability of the text. + pub fn stretchability(&self) -> Abs { + self.glyphs + .iter() + .map(|g| g.stretchability().0 + g.stretchability().1) + .sum::<Em>() + .at(self.size) + } + + /// The shrinkability of the text + pub fn shrinkability(&self) -> Abs { + self.glyphs + .iter() + .map(|g| g.shrinkability().0 + g.shrinkability().1) + .sum::<Em>() + .at(self.size) + } + + /// Reshape a range of the shaped text, reusing information from this + /// shaping process if possible. + /// + /// The text `range` is relative to the whole paragraph. + pub fn reshape(&'a self, engine: &Engine, text_range: Range) -> ShapedText<'a> { + let text = &self.text[text_range.start - self.base..text_range.end - self.base]; + if let Some(glyphs) = self.slice_safe_to_break(text_range.clone()) { + #[cfg(debug_assertions)] + assert_all_glyphs_in_range(glyphs, text, text_range.clone()); + Self { + base: text_range.start, + text, + dir: self.dir, + lang: self.lang, + region: self.region, + styles: self.styles, + size: self.size, + variant: self.variant, + width: glyphs.iter().map(|g| g.x_advance).sum::<Em>().at(self.size), + glyphs: Cow::Borrowed(glyphs), + } + } else { + shape( + engine, + text_range.start, + text, + self.styles, + self.dir, + self.lang, + self.region, + ) + } + } + + /// Derive an empty text run with the same properties as this one. + pub fn empty(&self) -> Self { + Self { + text: "", + width: Abs::zero(), + glyphs: Cow::Borrowed(&[]), + ..*self + } + } + + /// Push a hyphen to end of the text. + pub fn push_hyphen(&mut self, engine: &Engine, fallback: bool) { + self.insert_hyphen(engine, fallback, Side::Right) + } + + /// Prepend a hyphen to start of the text. + pub fn prepend_hyphen(&mut self, engine: &Engine, fallback: bool) { + self.insert_hyphen(engine, fallback, Side::Left) + } + + fn insert_hyphen(&mut self, engine: &Engine, fallback: bool, side: Side) { + let world = engine.world; + let book = world.book(); + let fallback_func = if fallback { + Some(|| book.select_fallback(None, self.variant, "-")) + } else { + None + }; + let mut chain = families(self.styles) + .map(|family| book.select(family, self.variant)) + .chain(fallback_func.iter().map(|f| f())) + .flatten(); + + chain.find_map(|id| { + let font = world.font(id)?; + let ttf = font.ttf(); + let glyph_id = ttf.glyph_index('-')?; + let x_advance = font.to_em(ttf.glyph_hor_advance(glyph_id)?); + let range = match side { + Side::Left => self.glyphs.first().map(|g| g.range.start..g.range.start), + Side::Right => self.glyphs.last().map(|g| g.range.end..g.range.end), + } + // In the unlikely chance that we hyphenate after an empty line, + // ensure that the glyph range still falls after self.base so + // that subtracting either of the endpoints by self.base doesn't + // underflow. See <https://github.com/typst/typst/issues/2283>. + .unwrap_or_else(|| self.base..self.base); + self.width += x_advance.at(self.size); + let glyph = ShapedGlyph { + font, + glyph_id: glyph_id.0, + x_advance, + x_offset: Em::zero(), + y_offset: Em::zero(), + adjustability: Adjustability::default(), + range, + safe_to_break: true, + c: '-', + is_justifiable: false, + script: Script::Common, + }; + match side { + Side::Left => self.glyphs.to_mut().insert(0, glyph), + Side::Right => self.glyphs.to_mut().push(glyph), + } + Some(()) + }); + } + + /// Find the subslice of glyphs that represent the given text range if both + /// sides are safe to break. + fn slice_safe_to_break(&self, text_range: Range) -> Option<&[ShapedGlyph]> { + let Range { mut start, mut end } = text_range; + if !self.dir.is_positive() { + std::mem::swap(&mut start, &mut end); + } + + let left = self.find_safe_to_break(start)?; + let right = self.find_safe_to_break(end)?; + Some(&self.glyphs[left..right]) + } + + /// Find the glyph offset matching the text index that is most towards the + /// start of the text and safe-to-break. + fn find_safe_to_break(&self, text_index: usize) -> Option<usize> { + let ltr = self.dir.is_positive(); + + // Handle edge cases. + let len = self.glyphs.len(); + if text_index == self.base { + return Some(if ltr { 0 } else { len }); + } else if text_index == self.base + self.text.len() { + return Some(if ltr { len } else { 0 }); + } + + // Find any glyph with the text index. + let found = self.glyphs.binary_search_by(|g: &ShapedGlyph| { + let ordering = g.range.start.cmp(&text_index); + if ltr { + ordering + } else { + ordering.reverse() + } + }); + + let mut idx = match found { + Ok(idx) => idx, + Err(idx) => { + // Handle the special case where we break before a '\n' + // + // For example: (assume `a` is a CJK character with three bytes) + // text: " a \n b " + // index: 0 1 2 3 4 5 + // text_index: ^ + // glyphs: 0 . 1 + // + // We will get found = Err(1), because '\n' does not have a + // glyph. But it's safe to break here. Thus the following + // condition: + // - glyphs[0].end == text_index == 3 + // - text[3] == '\n' + return (idx > 0 + && self.glyphs[idx - 1].range.end == text_index + && self.text[text_index - self.base..].starts_with('\n')) + .then_some(idx); + } + }; + + // Search for the start-most glyph with the text index. This means + // we take empty range glyphs at the start and leave those at the end + // for the next line. + let dec = if ltr { usize::checked_sub } else { usize::checked_add }; + while let Some(next) = dec(idx, 1) { + if self.glyphs.get(next).map_or(true, |g| g.range.start != text_index) { + break; + } + idx = next; + } + + // RTL needs offset one because the left side of the range should be + // exclusive and the right side inclusive, contrary to the normal + // behaviour of ranges. + self.glyphs[idx].safe_to_break.then_some(idx + usize::from(!ltr)) + } +} + +impl Debug for ShapedText<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + self.text.fmt(f) + } +} + +/// Group a range of text by BiDi level and script, shape the runs and generate +/// items for them. +pub fn shape_range<'a>( + items: &mut Vec<(Range, Item<'a>)>, + engine: &Engine, + text: &'a str, + bidi: &BidiInfo<'a>, + range: Range, + styles: StyleChain<'a>, +) { + let script = TextElem::script_in(styles); + let lang = TextElem::lang_in(styles); + let region = TextElem::region_in(styles); + let mut process = |range: Range, level: BidiLevel| { + let dir = if level.is_ltr() { Dir::LTR } else { Dir::RTL }; + let shaped = + shape(engine, range.start, &text[range.clone()], styles, dir, lang, region); + items.push((range, Item::Text(shaped))); + }; + + let mut prev_level = BidiLevel::ltr(); + let mut prev_script = Script::Unknown; + let mut cursor = range.start; + + // Group by embedding level and script. If the text's script is explicitly + // set (rather than inferred from the glyphs), we keep the script at an + // unchanging `Script::Unknown` so that only level changes cause breaks. + for i in range.clone() { + if !text.is_char_boundary(i) { + continue; + } + + let level = bidi.levels[i]; + let curr_script = match script { + Smart::Auto => { + text[i..].chars().next().map_or(Script::Unknown, |c| c.script()) + } + Smart::Custom(_) => Script::Unknown, + }; + + if level != prev_level || !is_compatible(curr_script, prev_script) { + if cursor < i { + process(cursor..i, prev_level); + } + cursor = i; + prev_level = level; + prev_script = curr_script; + } else if is_generic_script(prev_script) { + prev_script = curr_script; + } + } + + process(cursor..range.end, prev_level); +} + +/// Whether this is not a specific script. +fn is_generic_script(script: Script) -> bool { + matches!(script, Script::Unknown | Script::Common | Script::Inherited) +} + +/// Whether these script can be part of the same shape run. +fn is_compatible(a: Script, b: Script) -> bool { + is_generic_script(a) || is_generic_script(b) || a == b +} + +/// Shape text into [`ShapedText`]. +#[allow(clippy::too_many_arguments)] +fn shape<'a>( + engine: &Engine, + base: usize, + text: &'a str, + styles: StyleChain<'a>, + dir: Dir, + lang: Lang, + region: Option<Region>, +) -> ShapedText<'a> { + let size = TextElem::size_in(styles); + let mut ctx = ShapingContext { + engine, + size, + glyphs: vec![], + used: vec![], + styles, + variant: variant(styles), + features: features(styles), + fallback: TextElem::fallback_in(styles), + dir, + }; + + if !text.is_empty() { + shape_segment(&mut ctx, base, text, families(styles)); + } + + track_and_space(&mut ctx); + calculate_adjustability(&mut ctx, lang, region); + + #[cfg(debug_assertions)] + assert_all_glyphs_in_range(&ctx.glyphs, text, base..(base + text.len())); + #[cfg(debug_assertions)] + assert_glyph_ranges_in_order(&ctx.glyphs, dir); + + ShapedText { + base, + text, + dir, + lang, + region, + styles, + variant: ctx.variant, + size, + width: ctx.glyphs.iter().map(|g| g.x_advance).sum::<Em>().at(size), + glyphs: Cow::Owned(ctx.glyphs), + } +} + +/// Holds shaping results and metadata common to all shaped segments. +struct ShapingContext<'a, 'v> { + engine: &'a Engine<'v>, + glyphs: Vec<ShapedGlyph>, + used: Vec<Font>, + styles: StyleChain<'a>, + size: Abs, + variant: FontVariant, + features: Vec<rustybuzz::Feature>, + fallback: bool, + dir: Dir, +} + +/// Shape text with font fallback using the `families` iterator. +fn shape_segment<'a>( + ctx: &mut ShapingContext, + base: usize, + text: &str, + mut families: impl Iterator<Item = &'a str> + Clone, +) { + // Don't try shaping newlines, tabs, or default ignorables. + if text + .chars() + .all(|c| c == '\n' || c == '\t' || is_default_ignorable(c)) + { + return; + } + + // Find the next available family. + let world = ctx.engine.world; + let book = world.book(); + let mut selection = families.find_map(|family| { + book.select(family, ctx.variant) + .and_then(|id| world.font(id)) + .filter(|font| !ctx.used.contains(font)) + }); + + // Do font fallback if the families are exhausted and fallback is enabled. + if selection.is_none() && ctx.fallback { + let first = ctx.used.first().map(Font::info); + selection = book + .select_fallback(first, ctx.variant, text) + .and_then(|id| world.font(id)) + .filter(|font| !ctx.used.contains(font)); + } + + // Extract the font id or shape notdef glyphs if we couldn't find any font. + let Some(font) = selection else { + if let Some(font) = ctx.used.first().cloned() { + shape_tofus(ctx, base, text, font); + } + return; + }; + + ctx.used.push(font.clone()); + + // Fill the buffer with our text. + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(text); + buffer.set_language(language(ctx.styles)); + if let Some(script) = TextElem::script_in(ctx.styles).custom().and_then(|script| { + rustybuzz::Script::from_iso15924_tag(Tag::from_bytes(script.as_bytes())) + }) { + buffer.set_script(script) + } + buffer.set_direction(match ctx.dir { + Dir::LTR => rustybuzz::Direction::LeftToRight, + Dir::RTL => rustybuzz::Direction::RightToLeft, + _ => unimplemented!("vertical text layout"), + }); + buffer.guess_segment_properties(); + + // By default, Harfbuzz will create zero-width space glyphs for default + // ignorables. This is probably useful for GUI apps that want noticable + // effects on the cursor for those, but for us it's not useful and hurts + // text extraction. + buffer.set_flags(BufferFlags::REMOVE_DEFAULT_IGNORABLES); + + // Prepare the shape plan. This plan depends on direction, script, language, + // and features, but is independent from the text and can thus be memoized. + let plan = create_shape_plan( + &font, + buffer.direction(), + buffer.script(), + buffer.language().as_ref(), + &ctx.features, + ); + + // Shape! + let buffer = rustybuzz::shape_with_plan(font.rusty(), &plan, buffer); + let infos = buffer.glyph_infos(); + let pos = buffer.glyph_positions(); + let ltr = ctx.dir.is_positive(); + + // Collect the shaped glyphs, doing fallback and shaping parts again with + // the next font if necessary. + let mut i = 0; + while i < infos.len() { + let info = &infos[i]; + let cluster = info.cluster as usize; + + // Add the glyph to the shaped output. + if info.glyph_id != 0 { + // Determine the text range of the glyph. + let start = base + cluster; + let end = base + + if ltr { i.checked_add(1) } else { i.checked_sub(1) } + .and_then(|last| infos.get(last)) + .map_or(text.len(), |info| info.cluster as usize); + + let c = text[cluster..].chars().next().unwrap(); + let script = c.script(); + let x_advance = font.to_em(pos[i].x_advance); + ctx.glyphs.push(ShapedGlyph { + font: font.clone(), + glyph_id: info.glyph_id as u16, + // TODO: Don't ignore y_advance. + x_advance, + x_offset: font.to_em(pos[i].x_offset), + y_offset: font.to_em(pos[i].y_offset), + adjustability: Adjustability::default(), + range: start..end, + safe_to_break: !info.unsafe_to_break(), + c, + is_justifiable: is_justifiable( + c, + script, + x_advance, + Adjustability::default().stretchability, + ), + script, + }); + } else { + // First, search for the end of the tofu sequence. + let k = i; + while infos.get(i + 1).is_some_and(|info| info.glyph_id == 0) { + i += 1; + } + + // Then, determine the start and end text index for the tofu + // sequence. + // + // Examples: + // Everything is shown in visual order. Tofus are written as "_". + // We want to find out that the tofus span the text `2..6`. + // Note that the clusters are longer than 1 char. + // + // Left-to-right: + // Text: h a l i h a l l o + // Glyphs: A _ _ C E + // Clusters: 0 2 4 6 8 + // k=1 i=2 + // + // Right-to-left: + // Text: O L L A H I L A H + // Glyphs: E C _ _ A + // Clusters: 8 6 4 2 0 + // k=2 i=3 + let start = infos[if ltr { k } else { i }].cluster as usize; + let end = if ltr { i.checked_add(1) } else { k.checked_sub(1) } + .and_then(|last| infos.get(last)) + .map_or(text.len(), |info| info.cluster as usize); + + // Trim half-baked cluster. + let remove = base + start..base + end; + while ctx.glyphs.last().is_some_and(|g| remove.contains(&g.range.start)) { + ctx.glyphs.pop(); + } + + // Recursively shape the tofu sequence with the next family. + shape_segment(ctx, base + start, &text[start..end], families.clone()); + } + + i += 1; + } + + ctx.used.pop(); +} + +/// Create a shape plan. +#[comemo::memoize] +fn create_shape_plan( + font: &Font, + direction: rustybuzz::Direction, + script: rustybuzz::Script, + language: Option<&rustybuzz::Language>, + features: &[rustybuzz::Feature], +) -> Arc<ShapePlan> { + Arc::new(rustybuzz::ShapePlan::new( + font.rusty(), + direction, + Some(script), + language, + features, + )) +} + +/// Shape the text with tofus from the given font. +fn shape_tofus(ctx: &mut ShapingContext, base: usize, text: &str, font: Font) { + let x_advance = font.advance(0).unwrap_or_default(); + let add_glyph = |(cluster, c): (usize, char)| { + let start = base + cluster; + let end = start + c.len_utf8(); + let script = c.script(); + ctx.glyphs.push(ShapedGlyph { + font: font.clone(), + glyph_id: 0, + x_advance, + x_offset: Em::zero(), + y_offset: Em::zero(), + adjustability: Adjustability::default(), + range: start..end, + safe_to_break: true, + c, + is_justifiable: is_justifiable( + c, + script, + x_advance, + Adjustability::default().stretchability, + ), + script, + }); + }; + if ctx.dir.is_positive() { + text.char_indices().for_each(add_glyph); + } else { + text.char_indices().rev().for_each(add_glyph); + } +} + +/// Apply tracking and spacing to the shaped glyphs. +fn track_and_space(ctx: &mut ShapingContext) { + let tracking = Em::from_length(TextElem::tracking_in(ctx.styles), ctx.size); + let spacing = + TextElem::spacing_in(ctx.styles).map(|abs| Em::from_length(abs, ctx.size)); + + let mut glyphs = ctx.glyphs.iter_mut().peekable(); + while let Some(glyph) = glyphs.next() { + // Make non-breaking space same width as normal space. + if glyph.c == '\u{00A0}' { + glyph.x_advance -= nbsp_delta(&glyph.font).unwrap_or_default(); + } + + if glyph.is_space() { + glyph.x_advance = spacing.relative_to(glyph.x_advance); + } + + if glyphs + .peek() + .is_some_and(|next| glyph.range.start != next.range.start) + { + glyph.x_advance += tracking; + } + } +} + +/// Calculate stretchability and shrinkability of each glyph, +/// and CJK punctuation adjustments according to Chinese Layout Requirements. +fn calculate_adjustability(ctx: &mut ShapingContext, lang: Lang, region: Option<Region>) { + let style = cjk_punct_style(lang, region); + + for glyph in &mut ctx.glyphs { + glyph.adjustability = glyph.base_adjustability(style); + } + + let mut glyphs = ctx.glyphs.iter_mut().peekable(); + while let Some(glyph) = glyphs.next() { + // CNS style needs not further adjustment. + if glyph.is_cjk_punctuation() && matches!(style, CjkPunctStyle::Cns) { + continue; + } + + // Now we apply consecutive punctuation adjustment, specified in Chinese Layout. + // Requirements, section 3.1.6.1 Punctuation Adjustment Space, and Japanese Layout + // Requirements, section 3.1 Line Composition Rules for Punctuation Marks + let Some(next) = glyphs.peek_mut() else { continue }; + let width = glyph.x_advance; + let delta = width / 2.0; + if glyph.is_cjk_punctuation() + && next.is_cjk_punctuation() + && (glyph.shrinkability().1 + next.shrinkability().0) >= delta + { + let left_delta = glyph.shrinkability().1.min(delta); + glyph.shrink_right(left_delta); + next.shrink_left(delta - left_delta); + } + } +} + +/// Difference between non-breaking and normal space. +fn nbsp_delta(font: &Font) -> Option<Em> { + let space = font.ttf().glyph_index(' ')?.0; + let nbsp = font.ttf().glyph_index('\u{00A0}')?.0; + Some(font.advance(nbsp)? - font.advance(space)?) +} + +/// Process the language and region of a style chain into a +/// rustybuzz-compatible BCP 47 language. +fn language(styles: StyleChain) -> rustybuzz::Language { + let mut bcp: EcoString = TextElem::lang_in(styles).as_str().into(); + if let Some(region) = TextElem::region_in(styles) { + bcp.push('-'); + bcp.push_str(region.as_str()); + } + rustybuzz::Language::from_str(&bcp).unwrap() +} + +/// Returns true if all glyphs in `glyphs` have ranges within the range `range`. +#[cfg(debug_assertions)] +fn assert_all_glyphs_in_range(glyphs: &[ShapedGlyph], text: &str, range: Range) { + if glyphs + .iter() + .any(|g| g.range.start < range.start || g.range.end > range.end) + { + panic!("one or more glyphs in {text:?} fell out of range"); + } +} + +/// Asserts that the ranges of `glyphs` is in the proper order according to +/// `dir`. +/// +/// This asserts instead of returning a bool in order to provide a more +/// informative message when the invariant is violated. +#[cfg(debug_assertions)] +fn assert_glyph_ranges_in_order(glyphs: &[ShapedGlyph], dir: Dir) { + if glyphs.is_empty() { + return; + } + + // Iterator::is_sorted and friends are unstable as of Rust 1.70.0 + for i in 0..(glyphs.len() - 1) { + let a = &glyphs[i]; + let b = &glyphs[i + 1]; + let ord = a.range.start.cmp(&b.range.start); + let ord = if dir.is_positive() { ord } else { ord.reverse() }; + if ord == std::cmp::Ordering::Greater { + panic!( + "glyph ranges should be monotonically {}, \ + but found glyphs out of order:\n\n\ + first: {a:#?}\nsecond: {b:#?}", + if dir.is_positive() { "increasing" } else { "decreasing" }, + ); + } + } +} + +// The CJK punctuation that can appear at the beginning or end of a line. +pub const BEGIN_PUNCT_PAT: &[char] = + &['“', '‘', '《', '〈', '(', '『', '「', '【', '〖', '〔', '[', '{']; +pub const END_PUNCT_PAT: &[char] = &[ + '”', '’', ',', '.', '。', '、', ':', ';', '》', '〉', ')', '』', '」', '】', + '〗', '〕', ']', '}', '?', '!', +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CjkPunctStyle { + /// Standard GB/T 15834-2011, used mostly in mainland China. + Gb, + /// Standard by Taiwan Ministry of Education, used in Taiwan and Hong Kong. + Cns, + /// Standard JIS X 4051, used in Japan. + Jis, +} + +pub fn cjk_punct_style(lang: Lang, region: Option<Region>) -> CjkPunctStyle { + match (lang, region.as_ref().map(Region::as_str)) { + (Lang::CHINESE, Some("TW" | "HK")) => CjkPunctStyle::Cns, + (Lang::JAPANESE, _) => CjkPunctStyle::Jis, + // zh-CN, zh-SG, zh-MY use GB-style punctuation, + _ => CjkPunctStyle::Gb, + } +} + +/// Whether the glyph is a space. +fn is_space(c: char) -> bool { + matches!(c, ' ' | '\u{00A0}' | ' ') +} + +/// Whether the glyph is part of Chinese or Japanese script (i.e. CJ, not CJK). +pub fn is_of_cj_script(c: char) -> bool { + is_cj_script(c, c.script()) +} + +/// Whether the glyph is part of Chinese or Japanese script (i.e. CJ, not CJK). +/// The function is dedicated to typesetting Chinese or Japanese, which do not +/// have spaces between words, so K is not checked here. +fn is_cj_script(c: char, script: Script) -> bool { + use Script::*; + // U+30FC: Katakana-Hiragana Prolonged Sound Mark + matches!(script, Hiragana | Katakana | Han) || c == '\u{30FC}' +} + +/// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment> +fn is_cjk_left_aligned_punctuation( + c: char, + x_advance: Em, + stretchability: (Em, Em), + style: CjkPunctStyle, +) -> bool { + use CjkPunctStyle::*; + + // CJK quotation marks shares codepoints with latin quotation marks. + // But only the CJK ones have full width. + if matches!(c, '”' | '’') && x_advance + stretchability.1 == Em::one() { + return true; + } + + if matches!(style, Gb | Jis) && matches!(c, ',' | '。' | '.' | '、' | ':' | ';') + { + return true; + } + + if matches!(style, Gb) && matches!(c, '?' | '!') { + // In GB style, exclamations and question marks are also left aligned + // and can be adjusted. Note that they are not adjustable in other + // styles. + return true; + } + + // See appendix A.3 https://www.w3.org/TR/clreq/#tables_of_chinese_punctuation_marks + matches!(c, '》' | ')' | '』' | '」' | '】' | '〗' | '〕' | '〉' | ']' | '}') +} + +/// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment> +fn is_cjk_right_aligned_punctuation( + c: char, + x_advance: Em, + stretchability: (Em, Em), +) -> bool { + // CJK quotation marks shares codepoints with latin quotation marks. + // But only the CJK ones have full width. + if matches!(c, '“' | '‘') && x_advance + stretchability.0 == Em::one() { + return true; + } + // See appendix A.3 https://www.w3.org/TR/clreq/#tables_of_chinese_punctuation_marks + matches!(c, '《' | '(' | '『' | '「' | '【' | '〖' | '〔' | '〈' | '[' | '{') +} + +/// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment> +fn is_cjk_center_aligned_punctuation(c: char, style: CjkPunctStyle) -> bool { + if matches!(style, CjkPunctStyle::Cns) + && matches!(c, ',' | '。' | '.' | '、' | ':' | ';') + { + return true; + } + + // U+30FB: Katakana Middle Dot + // U+00B7: Middle Dot + matches!(c, '\u{30FB}' | '\u{00B7}') +} + +/// Whether the glyph is justifiable. +/// +/// Quotations in latin script and CJK are unfortunately the same codepoint +/// (U+2018, U+2019, U+201C, U+201D), but quotations in Chinese must be +/// fullwidth. This heuristics can therefore fail for monospace latin fonts. +/// However, since monospace fonts are usually not justified this edge case +/// should be rare enough. +fn is_justifiable( + c: char, + script: Script, + x_advance: Em, + stretchability: (Em, Em), +) -> bool { + // punctuation style is not relevant here. + let style = CjkPunctStyle::Gb; + is_space(c) + || is_cj_script(c, script) + || is_cjk_left_aligned_punctuation(c, x_advance, stretchability, style) + || is_cjk_right_aligned_punctuation(c, x_advance, stretchability) + || is_cjk_center_aligned_punctuation(c, style) +} diff --git a/crates/typst-layout/src/lib.rs b/crates/typst-layout/src/lib.rs new file mode 100644 index 00000000..7069fc4d --- /dev/null +++ b/crates/typst-layout/src/lib.rs @@ -0,0 +1,30 @@ +//! Typst's layout engine. + +mod flow; +mod grid; +mod image; +mod inline; +mod lists; +mod math; +mod pad; +mod pages; +mod repeat; +mod shapes; +mod stack; +mod transforms; + +pub use self::flow::{layout_columns, layout_fragment, layout_frame}; +pub use self::grid::{layout_grid, layout_table}; +pub use self::image::layout_image; +pub use self::inline::{layout_box, layout_inline}; +pub use self::lists::{layout_enum, layout_list}; +pub use self::math::{layout_equation_block, layout_equation_inline}; +pub use self::pad::layout_pad; +pub use self::pages::layout_document; +pub use self::repeat::layout_repeat; +pub use self::shapes::{ + layout_circle, layout_ellipse, layout_line, layout_path, layout_polygon, layout_rect, + layout_square, +}; +pub use self::stack::layout_stack; +pub use self::transforms::{layout_move, layout_rotate, layout_scale, layout_skew}; diff --git a/crates/typst-layout/src/lists.rs b/crates/typst-layout/src/lists.rs new file mode 100644 index 00000000..08c2a2f4 --- /dev/null +++ b/crates/typst-layout/src/lists.rs @@ -0,0 +1,146 @@ +use comemo::Track; +use smallvec::smallvec; +use typst_library::diag::SourceResult; +use typst_library::engine::Engine; +use typst_library::foundations::{Content, Context, Depth, Packed, StyleChain}; +use typst_library::introspection::Locator; +use typst_library::layout::{Axes, Fragment, HAlignment, Regions, Sizing, VAlignment}; +use typst_library::model::{EnumElem, ListElem, Numbering, ParElem}; +use typst_library::text::TextElem; + +use crate::grid::{Cell, CellGrid, GridLayouter}; + +/// Layout the list. +#[typst_macros::time(span = elem.span())] +pub fn layout_list( + elem: &Packed<ListElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + regions: Regions, +) -> SourceResult<Fragment> { + let indent = elem.indent(styles); + let body_indent = elem.body_indent(styles); + let gutter = elem.spacing(styles).unwrap_or_else(|| { + if elem.tight(styles) { + ParElem::leading_in(styles).into() + } else { + ParElem::spacing_in(styles).into() + } + }); + + let Depth(depth) = ListElem::depth_in(styles); + let marker = elem + .marker(styles) + .resolve(engine, styles, depth)? + // avoid '#set align' interference with the list + .aligned(HAlignment::Start + VAlignment::Top); + + let mut cells = vec![]; + let mut locator = locator.split(); + + for item in elem.children() { + cells.push(Cell::new(Content::empty(), locator.next(&()))); + cells.push(Cell::new(marker.clone(), locator.next(&marker.span()))); + cells.push(Cell::new(Content::empty(), locator.next(&()))); + cells.push(Cell::new( + item.body.clone().styled(ListElem::set_depth(Depth(1))), + locator.next(&item.body.span()), + )); + } + + let grid = CellGrid::new( + Axes::with_x(&[ + Sizing::Rel(indent.into()), + Sizing::Auto, + Sizing::Rel(body_indent.into()), + Sizing::Auto, + ]), + Axes::with_y(&[gutter.into()]), + cells, + ); + let layouter = GridLayouter::new(&grid, regions, styles, elem.span()); + + layouter.layout(engine) +} + +/// Layout the enumeration. +#[typst_macros::time(span = elem.span())] +pub fn layout_enum( + elem: &Packed<EnumElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + regions: Regions, +) -> SourceResult<Fragment> { + let numbering = elem.numbering(styles); + let indent = elem.indent(styles); + let body_indent = elem.body_indent(styles); + let gutter = elem.spacing(styles).unwrap_or_else(|| { + if elem.tight(styles) { + ParElem::leading_in(styles).into() + } else { + ParElem::spacing_in(styles).into() + } + }); + + let mut cells = vec![]; + let mut locator = locator.split(); + let mut number = elem.start(styles); + let mut parents = EnumElem::parents_in(styles); + + let full = elem.full(styles); + + // Horizontally align based on the given respective parameter. + // Vertically align to the top to avoid inheriting `horizon` or `bottom` + // alignment from the context and having the number be displaced in + // relation to the item it refers to. + let number_align = elem.number_align(styles); + + for item in elem.children() { + number = item.number(styles).unwrap_or(number); + + let context = Context::new(None, Some(styles)); + let resolved = if full { + parents.push(number); + let content = numbering.apply(engine, context.track(), &parents)?.display(); + parents.pop(); + content + } else { + match numbering { + Numbering::Pattern(pattern) => { + TextElem::packed(pattern.apply_kth(parents.len(), number)) + } + other => other.apply(engine, context.track(), &[number])?.display(), + } + }; + + // Disable overhang as a workaround to end-aligned dots glitching + // and decreasing spacing between numbers and items. + let resolved = + resolved.aligned(number_align).styled(TextElem::set_overhang(false)); + + cells.push(Cell::new(Content::empty(), locator.next(&()))); + cells.push(Cell::new(resolved, locator.next(&()))); + cells.push(Cell::new(Content::empty(), locator.next(&()))); + cells.push(Cell::new( + item.body.clone().styled(EnumElem::set_parents(smallvec![number])), + locator.next(&item.body.span()), + )); + number = number.saturating_add(1); + } + + let grid = CellGrid::new( + Axes::with_x(&[ + Sizing::Rel(indent.into()), + Sizing::Auto, + Sizing::Rel(body_indent.into()), + Sizing::Auto, + ]), + Axes::with_y(&[gutter.into()]), + cells, + ); + let layouter = GridLayouter::new(&grid, regions, styles, elem.span()); + + layouter.layout(engine) +} diff --git a/crates/typst-layout/src/math/accent.rs b/crates/typst-layout/src/math/accent.rs new file mode 100644 index 00000000..9fa7a5a0 --- /dev/null +++ b/crates/typst-layout/src/math/accent.rs @@ -0,0 +1,75 @@ +use typst_library::diag::SourceResult; +use typst_library::foundations::{Packed, StyleChain}; +use typst_library::layout::{Em, Frame, Point, Rel, Size}; +use typst_library::math::{Accent, AccentElem}; + +use super::{ + scaled_font_size, style_cramped, FrameFragment, GlyphFragment, MathContext, + MathFragment, +}; + +/// How much the accent can be shorter than the base. +const ACCENT_SHORT_FALL: Em = Em::new(0.5); + +/// Lays out an [`AccentElem`]. +#[typst_macros::time(name = "math.accent", span = elem.span())] +pub fn layout_accent( + elem: &Packed<AccentElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let cramped = style_cramped(); + let base = ctx.layout_into_fragment(elem.base(), styles.chain(&cramped))?; + + // Preserve class to preserve automatic spacing. + let base_class = base.class(); + let base_attach = base.accent_attach(); + + let width = elem + .size(styles) + .unwrap_or(Rel::one()) + .at(scaled_font_size(ctx, styles)) + .relative_to(base.width()); + + // Forcing the accent to be at least as large as the base makes it too + // wide in many case. + let Accent(c) = elem.accent(); + let glyph = GlyphFragment::new(ctx, styles, *c, elem.span()); + let short_fall = ACCENT_SHORT_FALL.at(glyph.font_size); + let variant = glyph.stretch_horizontal(ctx, width, short_fall); + let accent = variant.frame; + let accent_attach = variant.accent_attach; + + // Descent is negative because the accent's ink bottom is above the + // baseline. Therefore, the default gap is the accent's negated descent + // minus the accent base height. Only if the base is very small, we need + // a larger gap so that the accent doesn't move too low. + let accent_base_height = scaled!(ctx, styles, accent_base_height); + let gap = -accent.descent() - base.height().min(accent_base_height); + let size = Size::new(base.width(), accent.height() + gap + base.height()); + let accent_pos = Point::with_x(base_attach - accent_attach); + let base_pos = Point::with_y(accent.height() + gap); + let baseline = base_pos.y + base.ascent(); + let base_italics_correction = base.italics_correction(); + let base_text_like = base.is_text_like(); + + let base_ascent = match &base { + MathFragment::Frame(frame) => frame.base_ascent, + _ => base.ascent(), + }; + + let mut frame = Frame::soft(size); + frame.set_baseline(baseline); + frame.push_frame(accent_pos, accent); + frame.push_frame(base_pos, base.into_frame()); + ctx.push( + FrameFragment::new(ctx, styles, frame) + .with_class(base_class) + .with_base_ascent(base_ascent) + .with_italics_correction(base_italics_correction) + .with_accent_attach(base_attach) + .with_text_like(base_text_like), + ); + + Ok(()) +} diff --git a/crates/typst-layout/src/math/attach.rs b/crates/typst-layout/src/math/attach.rs new file mode 100644 index 00000000..0f9090f7 --- /dev/null +++ b/crates/typst-layout/src/math/attach.rs @@ -0,0 +1,515 @@ +use typst_library::diag::SourceResult; +use typst_library::foundations::{Packed, Smart, StyleChain}; +use typst_library::layout::{Abs, Axis, Corner, Frame, Length, Point, Rel, Size}; +use typst_library::math::{ + AttachElem, EquationElem, LimitsElem, PrimesElem, ScriptsElem, StretchElem, +}; +use typst_library::text::TextElem; +use typst_utils::OptionExt; + +use super::{ + stretch_fragment, style_for_subscript, style_for_superscript, FrameFragment, Limits, + MathContext, MathFragment, +}; + +macro_rules! measure { + ($e: ident, $attr: ident) => { + $e.as_ref().map(|e| e.$attr()).unwrap_or_default() + }; +} + +/// Lays out an [`AttachElem`]. +#[typst_macros::time(name = "math.attach", span = elem.span())] +pub fn layout_attach( + elem: &Packed<AttachElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let merged = elem.merge_base(); + let elem = merged.as_ref().unwrap_or(elem); + let stretch = stretch_size(styles, elem); + + let mut base = ctx.layout_into_fragment(elem.base(), styles)?; + let sup_style = style_for_superscript(styles); + let sup_style_chain = styles.chain(&sup_style); + let tl = elem.tl(sup_style_chain); + let tr = elem.tr(sup_style_chain); + let primed = tr.as_ref().is_some_and(|content| content.is::<PrimesElem>()); + let t = elem.t(sup_style_chain); + + let sub_style = style_for_subscript(styles); + let sub_style_chain = styles.chain(&sub_style); + let bl = elem.bl(sub_style_chain); + let br = elem.br(sub_style_chain); + let b = elem.b(sub_style_chain); + + let limits = base.limits().active(styles); + let (t, tr) = match (t, tr) { + (Some(t), Some(tr)) if primed && !limits => (None, Some(tr + t)), + (Some(t), None) if !limits => (None, Some(t)), + (t, tr) => (t, tr), + }; + let (b, br) = if limits || br.is_some() { (b, br) } else { (None, b) }; + + macro_rules! layout { + ($content:ident, $style_chain:ident) => { + $content + .map(|elem| ctx.layout_into_fragment(&elem, $style_chain)) + .transpose() + }; + } + + // Layout the top and bottom attachments early so we can measure their + // widths, in order to calculate what the stretch size is relative to. + let t = layout!(t, sup_style_chain)?; + let b = layout!(b, sub_style_chain)?; + if let Some(stretch) = stretch { + let relative_to_width = measure!(t, width).max(measure!(b, width)); + stretch_fragment( + ctx, + styles, + &mut base, + Some(Axis::X), + Some(relative_to_width), + stretch, + Abs::zero(), + ); + } + + let fragments = [ + layout!(tl, sup_style_chain)?, + t, + layout!(tr, sup_style_chain)?, + layout!(bl, sub_style_chain)?, + b, + layout!(br, sub_style_chain)?, + ]; + + layout_attachments(ctx, styles, base, fragments) +} + +/// Lays out a [`PrimeElem`]. +#[typst_macros::time(name = "math.primes", span = elem.span())] +pub fn layout_primes( + elem: &Packed<PrimesElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + match *elem.count() { + count @ 1..=4 => { + let c = match count { + 1 => '′', + 2 => '″', + 3 => '‴', + 4 => '⁗', + _ => unreachable!(), + }; + let f = ctx.layout_into_fragment(&TextElem::packed(c), styles)?; + ctx.push(f); + } + count => { + // Custom amount of primes + let prime = + ctx.layout_into_fragment(&TextElem::packed('′'), styles)?.into_frame(); + let width = prime.width() * (count + 1) as f64 / 2.0; + let mut frame = Frame::soft(Size::new(width, prime.height())); + frame.set_baseline(prime.ascent()); + + for i in 0..count { + frame.push_frame( + Point::new(prime.width() * (i as f64 / 2.0), Abs::zero()), + prime.clone(), + ) + } + ctx.push(FrameFragment::new(ctx, styles, frame).with_text_like(true)); + } + } + Ok(()) +} + +/// Lays out a [`ScriptsElem`]. +#[typst_macros::time(name = "math.scripts", span = elem.span())] +pub fn layout_scripts( + elem: &Packed<ScriptsElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let mut fragment = ctx.layout_into_fragment(elem.body(), styles)?; + fragment.set_limits(Limits::Never); + ctx.push(fragment); + Ok(()) +} + +/// Lays out a [`LimitsElem`]. +#[typst_macros::time(name = "math.limits", span = elem.span())] +pub fn layout_limits( + elem: &Packed<LimitsElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let limits = if elem.inline(styles) { Limits::Always } else { Limits::Display }; + let mut fragment = ctx.layout_into_fragment(elem.body(), styles)?; + fragment.set_limits(limits); + ctx.push(fragment); + Ok(()) +} + +/// Get the size to stretch the base to, if the attach argument is true. +fn stretch_size( + styles: StyleChain, + elem: &Packed<AttachElem>, +) -> Option<Smart<Rel<Length>>> { + // Extract from an EquationElem. + let mut base = elem.base(); + if let Some(equation) = base.to_packed::<EquationElem>() { + base = equation.body(); + } + + base.to_packed::<StretchElem>().map(|stretch| stretch.size(styles)) +} + +/// Lay out the attachments. +fn layout_attachments( + ctx: &mut MathContext, + styles: StyleChain, + base: MathFragment, + [tl, t, tr, bl, b, br]: [Option<MathFragment>; 6], +) -> SourceResult<()> { + let base_class = base.class(); + + // Calculate the distance from the base's baseline to the superscripts' and + // subscripts' baseline. + let (tx_shift, bx_shift) = if [&tl, &tr, &bl, &br].iter().all(|e| e.is_none()) { + (Abs::zero(), Abs::zero()) + } else { + compute_script_shifts(ctx, styles, &base, [&tl, &tr, &bl, &br]) + }; + + // Calculate the distance from the base's baseline to the top attachment's + // and bottom attachment's baseline. + let (t_shift, b_shift) = + compute_limit_shifts(ctx, styles, &base, [t.as_ref(), b.as_ref()]); + + // Calculate the final frame height. + let ascent = base + .ascent() + .max(tx_shift + measure!(tr, ascent)) + .max(tx_shift + measure!(tl, ascent)) + .max(t_shift + measure!(t, ascent)); + let descent = base + .descent() + .max(bx_shift + measure!(br, descent)) + .max(bx_shift + measure!(bl, descent)) + .max(b_shift + measure!(b, descent)); + let height = ascent + descent; + + // Calculate the vertical position of each element in the final frame. + let base_y = ascent - base.ascent(); + let tx_y = |tx: &MathFragment| ascent - tx_shift - tx.ascent(); + let bx_y = |bx: &MathFragment| ascent + bx_shift - bx.ascent(); + let t_y = |t: &MathFragment| ascent - t_shift - t.ascent(); + let b_y = |b: &MathFragment| ascent + b_shift - b.ascent(); + + // Calculate the distance each limit extends to the left and right of the + // base's width. + let ((t_pre_width, t_post_width), (b_pre_width, b_post_width)) = + compute_limit_widths(&base, [t.as_ref(), b.as_ref()]); + + // `space_after_script` is extra spacing that is at the start before each + // pre-script, and at the end after each post-script (see the MathConstants + // table in the OpenType MATH spec). + let space_after_script = scaled!(ctx, styles, space_after_script); + + // Calculate the distance each pre-script extends to the left of the base's + // width. + let (tl_pre_width, bl_pre_width) = compute_pre_script_widths( + ctx, + &base, + [tl.as_ref(), bl.as_ref()], + (tx_shift, bx_shift), + space_after_script, + ); + + // Calculate the distance each post-script extends to the right of the + // base's width. Also calculate each post-script's kerning (we need this for + // its position later). + let ((tr_post_width, tr_kern), (br_post_width, br_kern)) = compute_post_script_widths( + ctx, + &base, + [tr.as_ref(), br.as_ref()], + (tx_shift, bx_shift), + space_after_script, + ); + + // Calculate the final frame width. + let pre_width = t_pre_width.max(b_pre_width).max(tl_pre_width).max(bl_pre_width); + let base_width = base.width(); + let post_width = t_post_width.max(b_post_width).max(tr_post_width).max(br_post_width); + let width = pre_width + base_width + post_width; + + // Calculate the horizontal position of each element in the final frame. + let base_x = pre_width; + let tl_x = pre_width - tl_pre_width + space_after_script; + let bl_x = pre_width - bl_pre_width + space_after_script; + let tr_x = pre_width + base_width + tr_kern; + let br_x = pre_width + base_width + br_kern; + let t_x = pre_width - t_pre_width; + let b_x = pre_width - b_pre_width; + + // Create the final frame. + let mut frame = Frame::soft(Size::new(width, height)); + frame.set_baseline(ascent); + frame.push_frame(Point::new(base_x, base_y), base.into_frame()); + + macro_rules! layout { + ($e: ident, $x: ident, $y: ident) => { + if let Some($e) = $e { + frame.push_frame(Point::new($x, $y(&$e)), $e.into_frame()); + } + }; + } + + layout!(tl, tl_x, tx_y); // pre-superscript + layout!(bl, bl_x, bx_y); // pre-subscript + layout!(tr, tr_x, tx_y); // post-superscript + layout!(br, br_x, bx_y); // post-subscript + layout!(t, t_x, t_y); // upper-limit + layout!(b, b_x, b_y); // lower-limit + + // Done! Note that we retain the class of the base. + ctx.push(FrameFragment::new(ctx, styles, frame).with_class(base_class)); + + Ok(()) +} + +/// Calculate the distance each post-script extends to the right of the base's +/// width, as well as its kerning value. Requires the distance from the base's +/// baseline to each post-script's baseline to obtain the correct kerning value. +/// Returns 2 tuples of two lengths, each first containing the distance the +/// post-script extends left of the base's width and second containing the +/// post-script's kerning value. The first tuple is for the post-superscript, +/// and the second is for the post-subscript. +fn compute_post_script_widths( + ctx: &MathContext, + base: &MathFragment, + [tr, br]: [Option<&MathFragment>; 2], + (tr_shift, br_shift): (Abs, Abs), + space_after_post_script: Abs, +) -> ((Abs, Abs), (Abs, Abs)) { + let tr_values = tr.map_or_default(|tr| { + let kern = math_kern(ctx, base, tr, tr_shift, Corner::TopRight); + (space_after_post_script + tr.width() + kern, kern) + }); + + // The base's bounding box already accounts for its italic correction, so we + // need to shift the post-subscript left by the base's italic correction + // (see the kerning algorithm as described in the OpenType MATH spec). + let br_values = br.map_or_default(|br| { + let kern = math_kern(ctx, base, br, br_shift, Corner::BottomRight) + - base.italics_correction(); + (space_after_post_script + br.width() + kern, kern) + }); + + (tr_values, br_values) +} + +/// Calculate the distance each pre-script extends to the left of the base's +/// width. Requires the distance from the base's baseline to each pre-script's +/// baseline to obtain the correct kerning value. +/// Returns two lengths, the first being the distance the pre-superscript +/// extends left of the base's width and the second being the distance the +/// pre-subscript extends left of the base's width. +fn compute_pre_script_widths( + ctx: &MathContext, + base: &MathFragment, + [tl, bl]: [Option<&MathFragment>; 2], + (tl_shift, bl_shift): (Abs, Abs), + space_before_pre_script: Abs, +) -> (Abs, Abs) { + let tl_pre_width = tl.map_or_default(|tl| { + let kern = math_kern(ctx, base, tl, tl_shift, Corner::TopLeft); + space_before_pre_script + tl.width() + kern + }); + + let bl_pre_width = bl.map_or_default(|bl| { + let kern = math_kern(ctx, base, bl, bl_shift, Corner::BottomLeft); + space_before_pre_script + bl.width() + kern + }); + + (tl_pre_width, bl_pre_width) +} + +/// Calculate the distance each limit extends beyond the base's width, in each +/// direction. Can be a negative value if the limit does not extend beyond the +/// base's width, indicating how far into the base's width the limit extends. +/// Returns 2 tuples of two lengths, each first containing the distance the +/// limit extends leftward beyond the base's width and second containing the +/// distance the limit extends rightward beyond the base's width. The first +/// tuple is for the upper-limit, and the second is for the lower-limit. +fn compute_limit_widths( + base: &MathFragment, + [t, b]: [Option<&MathFragment>; 2], +) -> ((Abs, Abs), (Abs, Abs)) { + // The upper- (lower-) limit is shifted to the right (left) of the base's + // center by half the base's italic correction. + let delta = base.italics_correction() / 2.0; + + let t_widths = t.map_or_default(|t| { + let half = (t.width() - base.width()) / 2.0; + (half - delta, half + delta) + }); + + let b_widths = b.map_or_default(|b| { + let half = (b.width() - base.width()) / 2.0; + (half + delta, half - delta) + }); + + (t_widths, b_widths) +} + +/// Calculate the distance from the base's baseline to each limit's baseline. +/// Returns two lengths, the first being the distance to the upper-limit's +/// baseline and the second being the distance to the lower-limit's baseline. +fn compute_limit_shifts( + ctx: &MathContext, + styles: StyleChain, + base: &MathFragment, + [t, b]: [Option<&MathFragment>; 2], +) -> (Abs, Abs) { + // `upper_gap_min` and `lower_gap_min` give gaps to the descender and + // ascender of the limits respectively, whereas `upper_rise_min` and + // `lower_drop_min` give gaps to each limit's baseline (see the + // MathConstants table in the OpenType MATH spec). + + let t_shift = t.map_or_default(|t| { + let upper_gap_min = scaled!(ctx, styles, upper_limit_gap_min); + let upper_rise_min = scaled!(ctx, styles, upper_limit_baseline_rise_min); + base.ascent() + upper_rise_min.max(upper_gap_min + t.descent()) + }); + + let b_shift = b.map_or_default(|b| { + let lower_gap_min = scaled!(ctx, styles, lower_limit_gap_min); + let lower_drop_min = scaled!(ctx, styles, lower_limit_baseline_drop_min); + base.descent() + lower_drop_min.max(lower_gap_min + b.ascent()) + }); + + (t_shift, b_shift) +} + +/// Calculate the distance from the base's baseline to each script's baseline. +/// Returns two lengths, the first being the distance to the superscripts' +/// baseline and the second being the distance to the subscripts' baseline. +fn compute_script_shifts( + ctx: &MathContext, + styles: StyleChain, + base: &MathFragment, + [tl, tr, bl, br]: [&Option<MathFragment>; 4], +) -> (Abs, Abs) { + let sup_shift_up = if EquationElem::cramped_in(styles) { + scaled!(ctx, styles, superscript_shift_up_cramped) + } else { + scaled!(ctx, styles, superscript_shift_up) + }; + + let sup_bottom_min = scaled!(ctx, styles, superscript_bottom_min); + let sup_bottom_max_with_sub = + scaled!(ctx, styles, superscript_bottom_max_with_subscript); + let sup_drop_max = scaled!(ctx, styles, superscript_baseline_drop_max); + let gap_min = scaled!(ctx, styles, sub_superscript_gap_min); + let sub_shift_down = scaled!(ctx, styles, subscript_shift_down); + let sub_top_max = scaled!(ctx, styles, subscript_top_max); + let sub_drop_min = scaled!(ctx, styles, subscript_baseline_drop_min); + + let mut shift_up = Abs::zero(); + let mut shift_down = Abs::zero(); + let is_text_like = base.is_text_like(); + + if tl.is_some() || tr.is_some() { + let ascent = match &base { + MathFragment::Frame(frame) => frame.base_ascent, + _ => base.ascent(), + }; + shift_up = shift_up + .max(sup_shift_up) + .max(if is_text_like { Abs::zero() } else { ascent - sup_drop_max }) + .max(sup_bottom_min + measure!(tl, descent)) + .max(sup_bottom_min + measure!(tr, descent)); + } + + if bl.is_some() || br.is_some() { + shift_down = shift_down + .max(sub_shift_down) + .max(if is_text_like { Abs::zero() } else { base.descent() + sub_drop_min }) + .max(measure!(bl, ascent) - sub_top_max) + .max(measure!(br, ascent) - sub_top_max); + } + + for (sup, sub) in [(tl, bl), (tr, br)] { + if let (Some(sup), Some(sub)) = (&sup, &sub) { + let sup_bottom = shift_up - sup.descent(); + let sub_top = sub.ascent() - shift_down; + let gap = sup_bottom - sub_top; + if gap >= gap_min { + continue; + } + + let increase = gap_min - gap; + let sup_only = + (sup_bottom_max_with_sub - sup_bottom).clamp(Abs::zero(), increase); + let rest = (increase - sup_only) / 2.0; + shift_up += sup_only + rest; + shift_down += rest; + } + } + + (shift_up, shift_down) +} + +/// Calculate the kerning value for a script with respect to the base. A +/// positive value means shifting the script further away from the base, whereas +/// a negative value means shifting the script closer to the base. Requires the +/// distance from the base's baseline to the script's baseline, as well as the +/// script's corner (tl, tr, bl, br). +fn math_kern( + ctx: &MathContext, + base: &MathFragment, + script: &MathFragment, + shift: Abs, + pos: Corner, +) -> Abs { + // This process is described under the MathKernInfo table in the OpenType + // MATH spec. + + let (corr_height_top, corr_height_bot) = match pos { + // Calculate two correction heights for superscripts: + // - The distance from the superscript's baseline to the top of the + // base's bounding box. + // - The distance from the base's baseline to the bottom of the + // superscript's bounding box. + Corner::TopLeft | Corner::TopRight => { + (base.ascent() - shift, shift - script.descent()) + } + // Calculate two correction heights for subscripts: + // - The distance from the base's baseline to the top of the + // subscript's bounding box. + // - The distance from the subscript's baseline to the bottom of the + // base's bounding box. + Corner::BottomLeft | Corner::BottomRight => { + (script.ascent() - shift, shift - base.descent()) + } + }; + + // Calculate the sum of kerning values for each correction height. + let summed_kern = |height| { + let base_kern = base.kern_at_height(ctx, pos, height); + let attach_kern = script.kern_at_height(ctx, pos.inv(), height); + base_kern + attach_kern + }; + + // Take the smaller kerning amount (and so the larger value). Note that + // there is a bug in the spec (as of 2024-08-15): it says to take the + // minimum of the two sums, but as the kerning value is usually negative it + // really means the smaller kern. The current wording of the spec could + // result in glyphs colliding. + summed_kern(corr_height_top).max(summed_kern(corr_height_bot)) +} diff --git a/crates/typst-layout/src/math/cancel.rs b/crates/typst-layout/src/math/cancel.rs new file mode 100644 index 00000000..994e0e2f --- /dev/null +++ b/crates/typst-layout/src/math/cancel.rs @@ -0,0 +1,144 @@ +use comemo::Track; +use typst_library::diag::{At, SourceResult}; +use typst_library::foundations::{Context, Packed, Smart, StyleChain}; +use typst_library::layout::{Abs, Angle, Frame, FrameItem, Point, Rel, Size, Transform}; +use typst_library::math::{CancelAngle, CancelElem}; +use typst_library::text::TextElem; +use typst_library::visualize::{FixedStroke, Geometry}; +use typst_syntax::Span; + +use super::{scaled_font_size, FrameFragment, MathContext}; + +/// Lays out a [`CancelElem`]. +#[typst_macros::time(name = "math.cancel", span = elem.span())] +pub fn layout_cancel( + elem: &Packed<CancelElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let body = ctx.layout_into_fragment(elem.body(), styles)?; + + // Preserve properties of body. + let body_class = body.class(); + let body_italics = body.italics_correction(); + let body_attach = body.accent_attach(); + let body_text_like = body.is_text_like(); + + let mut body = body.into_frame(); + let body_size = body.size(); + let span = elem.span(); + let length = elem.length(styles).at(scaled_font_size(ctx, styles)); + + let stroke = elem.stroke(styles).unwrap_or(FixedStroke { + paint: TextElem::fill_in(styles).as_decoration(), + ..Default::default() + }); + + let invert = elem.inverted(styles); + let cross = elem.cross(styles); + let angle = elem.angle(styles); + + let invert_first_line = !cross && invert; + let first_line = draw_cancel_line( + ctx, + length, + stroke.clone(), + invert_first_line, + &angle, + body_size, + styles, + span, + )?; + + // The origin of our line is the very middle of the element. + let center = body_size.to_point() / 2.0; + body.push_frame(center, first_line); + + if cross { + // Draw the second line. + let second_line = + draw_cancel_line(ctx, length, stroke, true, &angle, body_size, styles, span)?; + + body.push_frame(center, second_line); + } + + ctx.push( + FrameFragment::new(ctx, styles, body) + .with_class(body_class) + .with_italics_correction(body_italics) + .with_accent_attach(body_attach) + .with_text_like(body_text_like), + ); + + Ok(()) +} + +/// Draws a cancel line. +#[allow(clippy::too_many_arguments)] +fn draw_cancel_line( + ctx: &mut MathContext, + length_scale: Rel<Abs>, + stroke: FixedStroke, + invert: bool, + angle: &Smart<CancelAngle>, + body_size: Size, + styles: StyleChain, + span: Span, +) -> SourceResult<Frame> { + let default = default_angle(body_size); + let mut angle = match angle { + // Non specified angle defaults to the diagonal + Smart::Auto => default, + Smart::Custom(angle) => match angle { + // This specifies the absolute angle w.r.t y-axis clockwise. + CancelAngle::Angle(v) => *v, + // This specifies a function that takes the default angle as input. + CancelAngle::Func(func) => func + .call(ctx.engine, Context::new(None, Some(styles)).track(), [default])? + .cast() + .at(span)?, + }, + }; + + // invert means flipping along the y-axis + if invert { + angle *= -1.0; + } + + // same as above, the default length is the diagonal of the body box. + let default_length = body_size.to_point().hypot(); + let length = length_scale.relative_to(default_length); + + // Draw a vertical line of length and rotate it by angle + let start = Point::new(Abs::zero(), length / 2.0); + let delta = Point::new(Abs::zero(), -length); + + let mut frame = Frame::soft(body_size); + frame.push(start, FrameItem::Shape(Geometry::Line(delta).stroked(stroke), span)); + + // Having the middle of the line at the origin is convenient here. + frame.transform(Transform::rotate(angle)); + Ok(frame) +} + +/// The default line angle for a body of the given size. +fn default_angle(body: Size) -> Angle { + // The default cancel line is the diagonal. + // We infer the default angle from + // the diagonal w.r.t to the body box. + // + // The returned angle is in the range of [0, Pi/2] + // + // Note that the angle is computed w.r.t to the y-axis + // + // B + // /| + // diagonal / | height + // / | + // / | + // O ---- + // width + let (width, height) = (body.x, body.y); + let default_angle = (width / height).atan(); // arctangent (in the range [0, Pi/2]) + Angle::rad(default_angle) +} diff --git a/crates/typst-layout/src/math/frac.rs b/crates/typst-layout/src/math/frac.rs new file mode 100644 index 00000000..50686333 --- /dev/null +++ b/crates/typst-layout/src/math/frac.rs @@ -0,0 +1,136 @@ +use typst_library::diag::SourceResult; +use typst_library::foundations::{Content, Packed, StyleChain}; +use typst_library::layout::{Em, Frame, FrameItem, Point, Size}; +use typst_library::math::{BinomElem, FracElem}; +use typst_library::text::TextElem; +use typst_library::visualize::{FixedStroke, Geometry}; +use typst_syntax::Span; + +use super::{ + scaled_font_size, style_for_denominator, style_for_numerator, FrameFragment, + GlyphFragment, MathContext, DELIM_SHORT_FALL, +}; + +const FRAC_AROUND: Em = Em::new(0.1); + +/// Lays out a [`FracElem`]. +#[typst_macros::time(name = "math.frac", span = elem.span())] +pub fn layout_frac( + elem: &Packed<FracElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_frac_like( + ctx, + styles, + elem.num(), + std::slice::from_ref(elem.denom()), + false, + elem.span(), + ) +} + +/// Lays out a [`BinomElem`]. +#[typst_macros::time(name = "math.binom", span = elem.span())] +pub fn layout_binom( + elem: &Packed<BinomElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_frac_like(ctx, styles, elem.upper(), elem.lower(), true, elem.span()) +} + +/// Layout a fraction or binomial. +fn layout_frac_like( + ctx: &mut MathContext, + styles: StyleChain, + num: &Content, + denom: &[Content], + binom: bool, + span: Span, +) -> SourceResult<()> { + let font_size = scaled_font_size(ctx, styles); + let short_fall = DELIM_SHORT_FALL.at(font_size); + let axis = scaled!(ctx, styles, axis_height); + let thickness = scaled!(ctx, styles, fraction_rule_thickness); + let shift_up = scaled!( + ctx, styles, + text: fraction_numerator_shift_up, + display: fraction_numerator_display_style_shift_up, + ); + let shift_down = scaled!( + ctx, styles, + text: fraction_denominator_shift_down, + display: fraction_denominator_display_style_shift_down, + ); + let num_min = scaled!( + ctx, styles, + text: fraction_numerator_gap_min, + display: fraction_num_display_style_gap_min, + ); + let denom_min = scaled!( + ctx, styles, + text: fraction_denominator_gap_min, + display: fraction_denom_display_style_gap_min, + ); + + let num_style = style_for_numerator(styles); + let num = ctx.layout_into_frame(num, styles.chain(&num_style))?; + + let denom_style = style_for_denominator(styles); + let denom = ctx.layout_into_frame( + &Content::sequence( + // Add a comma between each element. + denom.iter().flat_map(|a| [TextElem::packed(','), a.clone()]).skip(1), + ), + styles.chain(&denom_style), + )?; + + let around = FRAC_AROUND.at(font_size); + let num_gap = (shift_up - (axis + thickness / 2.0) - num.descent()).max(num_min); + let denom_gap = + (shift_down + (axis - thickness / 2.0) - denom.ascent()).max(denom_min); + + let line_width = num.width().max(denom.width()); + let width = line_width + 2.0 * around; + let height = num.height() + num_gap + thickness + denom_gap + denom.height(); + let size = Size::new(width, height); + let num_pos = Point::with_x((width - num.width()) / 2.0); + let line_pos = + Point::new((width - line_width) / 2.0, num.height() + num_gap + thickness / 2.0); + let denom_pos = Point::new((width - denom.width()) / 2.0, height - denom.height()); + let baseline = line_pos.y + axis; + + let mut frame = Frame::soft(size); + frame.set_baseline(baseline); + frame.push_frame(num_pos, num); + frame.push_frame(denom_pos, denom); + + if binom { + let mut left = GlyphFragment::new(ctx, styles, '(', span) + .stretch_vertical(ctx, height, short_fall); + left.center_on_axis(ctx); + ctx.push(left); + ctx.push(FrameFragment::new(ctx, styles, frame)); + let mut right = GlyphFragment::new(ctx, styles, ')', span) + .stretch_vertical(ctx, height, short_fall); + right.center_on_axis(ctx); + ctx.push(right); + } else { + frame.push( + line_pos, + FrameItem::Shape( + Geometry::Line(Point::with_x(line_width)).stroked( + FixedStroke::from_pair( + TextElem::fill_in(styles).as_decoration(), + thickness, + ), + ), + span, + ), + ); + ctx.push(FrameFragment::new(ctx, styles, frame)); + } + + Ok(()) +} diff --git a/crates/typst-layout/src/math/fragment.rs b/crates/typst-layout/src/math/fragment.rs new file mode 100644 index 00000000..19a4494e --- /dev/null +++ b/crates/typst-layout/src/math/fragment.rs @@ -0,0 +1,704 @@ +use std::fmt::{self, Debug, Formatter}; + +use rustybuzz::Feature; +use smallvec::SmallVec; +use ttf_parser::gsub::{ + AlternateSet, AlternateSubstitution, SingleSubstitution, SubstitutionSubtable, +}; +use ttf_parser::opentype_layout::LayoutTable; +use ttf_parser::{GlyphId, Rect}; +use typst_library::foundations::StyleChain; +use typst_library::introspection::Tag; +use typst_library::layout::{ + Abs, Axis, Corner, Em, Frame, FrameItem, HideElem, Point, Size, VAlignment, +}; +use typst_library::math::{EquationElem, MathSize}; +use typst_library::model::{Destination, LinkElem}; +use typst_library::text::{Font, Glyph, Lang, Region, TextElem, TextItem}; +use typst_library::visualize::Paint; +use typst_syntax::Span; +use unicode_math_class::MathClass; + +use super::{scaled_font_size, stretch_glyph, MathContext, Scaled}; + +#[derive(Debug, Clone)] +pub enum MathFragment { + Glyph(GlyphFragment), + Variant(VariantFragment), + Frame(FrameFragment), + Spacing(Abs, bool), + Space(Abs), + Linebreak, + Align, + Tag(Tag), +} + +impl MathFragment { + pub fn size(&self) -> Size { + Size::new(self.width(), self.height()) + } + + pub fn width(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.width, + Self::Variant(variant) => variant.frame.width(), + Self::Frame(fragment) => fragment.frame.width(), + Self::Spacing(amount, _) => *amount, + Self::Space(amount) => *amount, + _ => Abs::zero(), + } + } + + pub fn height(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.height(), + Self::Variant(variant) => variant.frame.height(), + Self::Frame(fragment) => fragment.frame.height(), + _ => Abs::zero(), + } + } + + pub fn ascent(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.ascent, + Self::Variant(variant) => variant.frame.ascent(), + Self::Frame(fragment) => fragment.frame.baseline(), + _ => Abs::zero(), + } + } + + pub fn descent(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.descent, + Self::Variant(variant) => variant.frame.descent(), + Self::Frame(fragment) => fragment.frame.descent(), + _ => Abs::zero(), + } + } + + pub fn is_ignorant(&self) -> bool { + match self { + Self::Frame(fragment) => fragment.ignorant, + Self::Tag(_) => true, + _ => false, + } + } + + pub fn class(&self) -> MathClass { + match self { + Self::Glyph(glyph) => glyph.class, + Self::Variant(variant) => variant.class, + Self::Frame(fragment) => fragment.class, + Self::Spacing(_, _) => MathClass::Space, + Self::Space(_) => MathClass::Space, + Self::Linebreak => MathClass::Space, + Self::Align => MathClass::Special, + Self::Tag(_) => MathClass::Special, + } + } + + pub fn math_size(&self) -> Option<MathSize> { + match self { + Self::Glyph(glyph) => Some(glyph.math_size), + Self::Variant(variant) => Some(variant.math_size), + Self::Frame(fragment) => Some(fragment.math_size), + _ => None, + } + } + + pub fn font_size(&self) -> Option<Abs> { + match self { + Self::Glyph(glyph) => Some(glyph.font_size), + Self::Variant(variant) => Some(variant.font_size), + Self::Frame(fragment) => Some(fragment.font_size), + _ => None, + } + } + + pub fn set_class(&mut self, class: MathClass) { + match self { + Self::Glyph(glyph) => glyph.class = class, + Self::Variant(variant) => variant.class = class, + Self::Frame(fragment) => fragment.class = class, + _ => {} + } + } + + pub fn set_limits(&mut self, limits: Limits) { + match self { + Self::Glyph(glyph) => glyph.limits = limits, + Self::Variant(variant) => variant.limits = limits, + Self::Frame(fragment) => fragment.limits = limits, + _ => {} + } + } + + pub fn is_spaced(&self) -> bool { + if self.class() == MathClass::Fence { + return true; + } + + matches!( + self, + MathFragment::Frame(FrameFragment { + spaced: true, + class: MathClass::Normal | MathClass::Alphabetic, + .. + }) + ) + } + + pub fn is_text_like(&self) -> bool { + match self { + Self::Glyph(_) | Self::Variant(_) => self.class() != MathClass::Large, + MathFragment::Frame(frame) => frame.text_like, + _ => false, + } + } + + pub fn italics_correction(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.italics_correction, + Self::Variant(variant) => variant.italics_correction, + Self::Frame(fragment) => fragment.italics_correction, + _ => Abs::zero(), + } + } + + pub fn accent_attach(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.accent_attach, + Self::Variant(variant) => variant.accent_attach, + Self::Frame(fragment) => fragment.accent_attach, + _ => self.width() / 2.0, + } + } + + pub fn into_frame(self) -> Frame { + match self { + Self::Glyph(glyph) => glyph.into_frame(), + Self::Variant(variant) => variant.frame, + Self::Frame(fragment) => fragment.frame, + Self::Tag(tag) => { + let mut frame = Frame::soft(Size::zero()); + frame.push(Point::zero(), FrameItem::Tag(tag)); + frame + } + _ => Frame::soft(self.size()), + } + } + + pub fn limits(&self) -> Limits { + match self { + MathFragment::Glyph(glyph) => glyph.limits, + MathFragment::Variant(variant) => variant.limits, + MathFragment::Frame(fragment) => fragment.limits, + _ => Limits::Never, + } + } + + /// If no kern table is provided for a corner, a kerning amount of zero is + /// assumed. + pub fn kern_at_height(&self, ctx: &MathContext, corner: Corner, height: Abs) -> Abs { + match self { + Self::Glyph(glyph) => { + kern_at_height(ctx, glyph.font_size, glyph.id, corner, height) + .unwrap_or_default() + } + _ => Abs::zero(), + } + } +} + +impl From<GlyphFragment> for MathFragment { + fn from(glyph: GlyphFragment) -> Self { + Self::Glyph(glyph) + } +} + +impl From<VariantFragment> for MathFragment { + fn from(variant: VariantFragment) -> Self { + Self::Variant(variant) + } +} + +impl From<FrameFragment> for MathFragment { + fn from(fragment: FrameFragment) -> Self { + Self::Frame(fragment) + } +} + +#[derive(Clone)] +pub struct GlyphFragment { + pub id: GlyphId, + pub c: char, + pub font: Font, + pub lang: Lang, + pub region: Option<Region>, + pub fill: Paint, + pub shift: Abs, + pub width: Abs, + pub ascent: Abs, + pub descent: Abs, + pub italics_correction: Abs, + pub accent_attach: Abs, + pub font_size: Abs, + pub class: MathClass, + pub math_size: MathSize, + pub span: Span, + pub dests: SmallVec<[Destination; 1]>, + pub hidden: bool, + pub limits: Limits, +} + +impl GlyphFragment { + pub fn new(ctx: &MathContext, styles: StyleChain, c: char, span: Span) -> Self { + let id = ctx.ttf.glyph_index(c).unwrap_or_default(); + let id = Self::adjust_glyph_index(ctx, id); + Self::with_id(ctx, styles, c, id, span) + } + + pub fn try_new( + ctx: &MathContext, + styles: StyleChain, + c: char, + span: Span, + ) -> Option<Self> { + let id = ctx.ttf.glyph_index(c)?; + let id = Self::adjust_glyph_index(ctx, id); + Some(Self::with_id(ctx, styles, c, id, span)) + } + + pub fn with_id( + ctx: &MathContext, + styles: StyleChain, + c: char, + id: GlyphId, + span: Span, + ) -> Self { + let class = EquationElem::class_in(styles) + .or_else(|| match c { + ':' => Some(MathClass::Relation), + '.' | '/' | '⋯' | '⋱' | '⋰' | '⋮' => Some(MathClass::Normal), + _ => unicode_math_class::class(c), + }) + .unwrap_or(MathClass::Normal); + + let mut fragment = Self { + id, + c, + font: ctx.font.clone(), + lang: TextElem::lang_in(styles), + region: TextElem::region_in(styles), + fill: TextElem::fill_in(styles).as_decoration(), + shift: TextElem::baseline_in(styles), + font_size: scaled_font_size(ctx, styles), + math_size: EquationElem::size_in(styles), + width: Abs::zero(), + ascent: Abs::zero(), + descent: Abs::zero(), + limits: Limits::for_char(c), + italics_correction: Abs::zero(), + accent_attach: Abs::zero(), + class, + span, + dests: LinkElem::dests_in(styles), + hidden: HideElem::hidden_in(styles), + }; + fragment.set_id(ctx, id); + fragment + } + + /// Apply GSUB substitutions. + fn adjust_glyph_index(ctx: &MathContext, id: GlyphId) -> GlyphId { + if let Some(glyphwise_tables) = &ctx.glyphwise_tables { + glyphwise_tables.iter().fold(id, |id, table| table.apply(id)) + } else { + id + } + } + + /// Sets element id and boxes in appropriate way without changing other + /// styles. This is used to replace the glyph with a stretch variant. + pub fn set_id(&mut self, ctx: &MathContext, id: GlyphId) { + let advance = ctx.ttf.glyph_hor_advance(id).unwrap_or_default(); + let italics = italics_correction(ctx, id, self.font_size).unwrap_or_default(); + let bbox = ctx.ttf.glyph_bounding_box(id).unwrap_or(Rect { + x_min: 0, + y_min: 0, + x_max: 0, + y_max: 0, + }); + + let mut width = advance.scaled(ctx, self.font_size); + let accent_attach = + accent_attach(ctx, id, self.font_size).unwrap_or((width + italics) / 2.0); + + if !is_extended_shape(ctx, id) { + width += italics; + } + + self.id = id; + self.width = width; + self.ascent = bbox.y_max.scaled(ctx, self.font_size); + self.descent = -bbox.y_min.scaled(ctx, self.font_size); + self.italics_correction = italics; + self.accent_attach = accent_attach; + } + + pub fn height(&self) -> Abs { + self.ascent + self.descent + } + + pub fn into_variant(self) -> VariantFragment { + VariantFragment { + c: self.c, + font_size: self.font_size, + italics_correction: self.italics_correction, + accent_attach: self.accent_attach, + class: self.class, + math_size: self.math_size, + span: self.span, + limits: self.limits, + frame: self.into_frame(), + mid_stretched: None, + } + } + + pub fn into_frame(self) -> Frame { + let item = TextItem { + font: self.font.clone(), + size: self.font_size, + fill: self.fill, + lang: self.lang, + region: self.region, + text: self.c.into(), + stroke: None, + glyphs: vec![Glyph { + id: self.id.0, + x_advance: Em::from_length(self.width, self.font_size), + x_offset: Em::zero(), + range: 0..self.c.len_utf8() as u16, + span: (self.span, 0), + }], + }; + let size = Size::new(self.width, self.ascent + self.descent); + let mut frame = Frame::soft(size); + frame.set_baseline(self.ascent); + frame.push(Point::with_y(self.ascent + self.shift), FrameItem::Text(item)); + frame.post_process_raw(self.dests, self.hidden); + frame + } + + pub fn make_scriptsize(&mut self, ctx: &MathContext) { + let alt_id = + script_alternatives(ctx, self.id).and_then(|alts| alts.alternates.get(0)); + + if let Some(alt_id) = alt_id { + self.set_id(ctx, alt_id); + } + } + + pub fn make_scriptscriptsize(&mut self, ctx: &MathContext) { + let alts = script_alternatives(ctx, self.id); + let alt_id = alts + .and_then(|alts| alts.alternates.get(1).or_else(|| alts.alternates.get(0))); + + if let Some(alt_id) = alt_id { + self.set_id(ctx, alt_id); + } + } + + /// Try to stretch a glyph to a desired height. + pub fn stretch_vertical( + self, + ctx: &MathContext, + height: Abs, + short_fall: Abs, + ) -> VariantFragment { + stretch_glyph(ctx, self, height, short_fall, Axis::Y) + } + + /// Try to stretch a glyph to a desired width. + pub fn stretch_horizontal( + self, + ctx: &MathContext, + width: Abs, + short_fall: Abs, + ) -> VariantFragment { + stretch_glyph(ctx, self, width, short_fall, Axis::X) + } +} + +impl Debug for GlyphFragment { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "GlyphFragment({:?})", self.c) + } +} + +#[derive(Clone)] +pub struct VariantFragment { + pub c: char, + pub italics_correction: Abs, + pub accent_attach: Abs, + pub frame: Frame, + pub font_size: Abs, + pub class: MathClass, + pub math_size: MathSize, + pub span: Span, + pub limits: Limits, + pub mid_stretched: Option<bool>, +} + +impl VariantFragment { + /// Vertically adjust the fragment's frame so that it is centered + /// on the axis. + pub fn center_on_axis(&mut self, ctx: &MathContext) { + self.align_on_axis(ctx, VAlignment::Horizon) + } + + /// Vertically adjust the fragment's frame so that it is aligned + /// to the given alignment on the axis. + pub fn align_on_axis(&mut self, ctx: &MathContext, align: VAlignment) { + let h = self.frame.height(); + let axis = ctx.constants.axis_height().scaled(ctx, self.font_size); + self.frame.set_baseline(align.inv().position(h + axis * 2.0)); + } +} + +impl Debug for VariantFragment { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "VariantFragment({:?})", self.c) + } +} + +#[derive(Debug, Clone)] +pub struct FrameFragment { + pub frame: Frame, + pub font_size: Abs, + pub class: MathClass, + pub math_size: MathSize, + pub limits: Limits, + pub spaced: bool, + pub base_ascent: Abs, + pub italics_correction: Abs, + pub accent_attach: Abs, + pub text_like: bool, + pub ignorant: bool, +} + +impl FrameFragment { + pub fn new(ctx: &MathContext, styles: StyleChain, frame: Frame) -> Self { + let base_ascent = frame.ascent(); + let accent_attach = frame.width() / 2.0; + Self { + frame: frame.post_processed(styles), + font_size: scaled_font_size(ctx, styles), + class: EquationElem::class_in(styles).unwrap_or(MathClass::Normal), + math_size: EquationElem::size_in(styles), + limits: Limits::Never, + spaced: false, + base_ascent, + italics_correction: Abs::zero(), + accent_attach, + text_like: false, + ignorant: false, + } + } + + pub fn with_class(self, class: MathClass) -> Self { + Self { class, ..self } + } + + pub fn with_limits(self, limits: Limits) -> Self { + Self { limits, ..self } + } + + pub fn with_spaced(self, spaced: bool) -> Self { + Self { spaced, ..self } + } + + pub fn with_base_ascent(self, base_ascent: Abs) -> Self { + Self { base_ascent, ..self } + } + + pub fn with_italics_correction(self, italics_correction: Abs) -> Self { + Self { italics_correction, ..self } + } + + pub fn with_accent_attach(self, accent_attach: Abs) -> Self { + Self { accent_attach, ..self } + } + + pub fn with_text_like(self, text_like: bool) -> Self { + Self { text_like, ..self } + } + + pub fn with_ignorant(self, ignorant: bool) -> Self { + Self { ignorant, ..self } + } +} + +/// Look up the italics correction for a glyph. +fn italics_correction(ctx: &MathContext, id: GlyphId, font_size: Abs) -> Option<Abs> { + Some( + ctx.table + .glyph_info? + .italic_corrections? + .get(id)? + .scaled(ctx, font_size), + ) +} + +/// Loop up the top accent attachment position for a glyph. +fn accent_attach(ctx: &MathContext, id: GlyphId, font_size: Abs) -> Option<Abs> { + Some( + ctx.table + .glyph_info? + .top_accent_attachments? + .get(id)? + .scaled(ctx, font_size), + ) +} + +/// Look up the script/scriptscript alternates for a glyph +fn script_alternatives<'a>( + ctx: &MathContext<'a, '_, '_>, + id: GlyphId, +) -> Option<AlternateSet<'a>> { + ctx.ssty_table.and_then(|ssty| { + ssty.coverage.get(id).and_then(|index| ssty.alternate_sets.get(index)) + }) +} + +/// Look up whether a glyph is an extended shape. +fn is_extended_shape(ctx: &MathContext, id: GlyphId) -> bool { + ctx.table + .glyph_info + .and_then(|info| info.extended_shapes) + .and_then(|info| info.get(id)) + .is_some() +} + +/// Look up a kerning value at a specific corner and height. +fn kern_at_height( + ctx: &MathContext, + font_size: Abs, + id: GlyphId, + corner: Corner, + height: Abs, +) -> Option<Abs> { + let kerns = ctx.table.glyph_info?.kern_infos?.get(id)?; + let kern = match corner { + Corner::TopLeft => kerns.top_left, + Corner::TopRight => kerns.top_right, + Corner::BottomRight => kerns.bottom_right, + Corner::BottomLeft => kerns.bottom_left, + }?; + + let mut i = 0; + while i < kern.count() && height > kern.height(i)?.scaled(ctx, font_size) { + i += 1; + } + + Some(kern.kern(i)?.scaled(ctx, font_size)) +} + +/// Describes in which situation a frame should use limits for attachments. +#[derive(Debug, Copy, Clone)] +pub enum Limits { + /// Always scripts. + Never, + /// Display limits only in `display` math. + Display, + /// Always limits. + Always, +} + +impl Limits { + /// The default limit configuration if the given character is the base. + pub fn for_char(c: char) -> Self { + match unicode_math_class::class(c) { + Some(MathClass::Large) => { + if is_integral_char(c) { + Limits::Never + } else { + Limits::Display + } + } + Some(MathClass::Relation) => Limits::Always, + _ => Limits::Never, + } + } + + /// The default limit configuration for a math class. + pub fn for_class(class: MathClass) -> Self { + match class { + MathClass::Large => Self::Display, + MathClass::Relation => Self::Always, + _ => Self::Never, + } + } + + /// Whether limits should be displayed in this context. + pub fn active(&self, styles: StyleChain) -> bool { + match self { + Self::Always => true, + Self::Display => EquationElem::size_in(styles) == MathSize::Display, + Self::Never => false, + } + } +} + +/// Determines if the character is one of a variety of integral signs. +fn is_integral_char(c: char) -> bool { + ('∫'..='∳').contains(&c) || ('⨋'..='⨜').contains(&c) +} + +/// An OpenType substitution table that is applicable to glyph-wise substitutions. +pub enum GlyphwiseSubsts<'a> { + Single(SingleSubstitution<'a>), + Alternate(AlternateSubstitution<'a>, u32), +} + +impl<'a> GlyphwiseSubsts<'a> { + pub fn new(gsub: LayoutTable<'a>, feature: Feature) -> Option<Self> { + let table = gsub + .features + .find(ttf_parser::Tag(feature.tag.0)) + .and_then(|feature| feature.lookup_indices.get(0)) + .and_then(|index| gsub.lookups.get(index))?; + let table = table.subtables.get::<SubstitutionSubtable>(0)?; + match table { + SubstitutionSubtable::Single(single_glyphs) => { + Some(Self::Single(single_glyphs)) + } + SubstitutionSubtable::Alternate(alt_glyphs) => { + Some(Self::Alternate(alt_glyphs, feature.value)) + } + _ => None, + } + } + + pub fn try_apply(&self, glyph_id: GlyphId) -> Option<GlyphId> { + match self { + Self::Single(single) => match single { + SingleSubstitution::Format1 { coverage, delta } => coverage + .get(glyph_id) + .map(|_| GlyphId(glyph_id.0.wrapping_add(*delta as u16))), + SingleSubstitution::Format2 { coverage, substitutes } => { + coverage.get(glyph_id).and_then(|idx| substitutes.get(idx)) + } + }, + Self::Alternate(alternate, value) => alternate + .coverage + .get(glyph_id) + .and_then(|idx| alternate.alternate_sets.get(idx)) + .and_then(|set| set.alternates.get(*value as u16)), + } + } + + pub fn apply(&self, glyph_id: GlyphId) -> GlyphId { + self.try_apply(glyph_id).unwrap_or(glyph_id) + } +} diff --git a/crates/typst-layout/src/math/lr.rs b/crates/typst-layout/src/math/lr.rs new file mode 100644 index 00000000..aba9012f --- /dev/null +++ b/crates/typst-layout/src/math/lr.rs @@ -0,0 +1,135 @@ +use typst_library::diag::SourceResult; +use typst_library::foundations::{Packed, Smart, StyleChain}; +use typst_library::layout::{Abs, Axis, Length, Rel}; +use typst_library::math::{EquationElem, LrElem, MidElem}; +use unicode_math_class::MathClass; + +use super::{stretch_fragment, MathContext, MathFragment, DELIM_SHORT_FALL}; + +/// Lays out an [`LrElem`]. +#[typst_macros::time(name = "math.lr", span = elem.span())] +pub fn layout_lr( + elem: &Packed<LrElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let mut body = elem.body(); + + // Extract from an EquationElem. + if let Some(equation) = body.to_packed::<EquationElem>() { + body = equation.body(); + } + + // Extract implicit LrElem. + if let Some(lr) = body.to_packed::<LrElem>() { + if lr.size(styles).is_auto() { + body = lr.body(); + } + } + + let mut fragments = ctx.layout_into_fragments(body, styles)?; + let axis = scaled!(ctx, styles, axis_height); + let max_extent = fragments + .iter() + .map(|fragment| (fragment.ascent() - axis).max(fragment.descent() + axis)) + .max() + .unwrap_or_default(); + + let relative_to = 2.0 * max_extent; + let height = elem.size(styles); + + // Scale up fragments at both ends. + match fragments.as_mut_slice() { + [one] => scale(ctx, styles, one, relative_to, height, None), + [first, .., last] => { + scale(ctx, styles, first, relative_to, height, Some(MathClass::Opening)); + scale(ctx, styles, last, relative_to, height, Some(MathClass::Closing)); + } + _ => {} + } + + // Handle MathFragment::Variant fragments that should be scaled up. + for fragment in &mut fragments { + if let MathFragment::Variant(ref mut variant) = fragment { + if variant.mid_stretched == Some(false) { + variant.mid_stretched = Some(true); + scale(ctx, styles, fragment, relative_to, height, Some(MathClass::Large)); + } + } + } + + // Remove weak SpacingFragment immediately after the opening or immediately + // before the closing. + let original_len = fragments.len(); + let mut index = 0; + fragments.retain(|fragment| { + index += 1; + (index != 2 && index + 1 != original_len) + || !matches!(fragment, MathFragment::Spacing(_, true)) + }); + + ctx.extend(fragments); + + Ok(()) +} + +/// Lays out a [`MidElem`]. +#[typst_macros::time(name = "math.mid", span = elem.span())] +pub fn layout_mid( + elem: &Packed<MidElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let mut fragments = ctx.layout_into_fragments(elem.body(), styles)?; + + for fragment in &mut fragments { + match fragment { + MathFragment::Glyph(glyph) => { + let mut new = glyph.clone().into_variant(); + new.mid_stretched = Some(false); + new.class = MathClass::Fence; + *fragment = MathFragment::Variant(new); + } + MathFragment::Variant(variant) => { + variant.mid_stretched = Some(false); + variant.class = MathClass::Fence; + } + _ => {} + } + } + + ctx.extend(fragments); + Ok(()) +} + +/// Scale a math fragment to a height. +fn scale( + ctx: &mut MathContext, + styles: StyleChain, + fragment: &mut MathFragment, + relative_to: Abs, + height: Smart<Rel<Length>>, + apply: Option<MathClass>, +) { + if matches!( + fragment.class(), + MathClass::Opening | MathClass::Closing | MathClass::Fence + ) { + // This unwrap doesn't really matter. If it is None, then the fragment + // won't be stretchable anyways. + let short_fall = DELIM_SHORT_FALL.at(fragment.font_size().unwrap_or_default()); + stretch_fragment( + ctx, + styles, + fragment, + Some(Axis::Y), + Some(relative_to), + height, + short_fall, + ); + + if let Some(class) = apply { + fragment.set_class(class); + } + } +} diff --git a/crates/typst-layout/src/math/mat.rs b/crates/typst-layout/src/math/mat.rs new file mode 100644 index 00000000..6c8b0455 --- /dev/null +++ b/crates/typst-layout/src/math/mat.rs @@ -0,0 +1,333 @@ +use typst_library::diag::{bail, SourceResult}; +use typst_library::foundations::{Content, Packed, StyleChain}; +use typst_library::layout::{ + Abs, Axes, Em, FixedAlignment, Frame, FrameItem, Point, Ratio, Rel, Size, +}; +use typst_library::math::{Augment, AugmentOffsets, CasesElem, MatElem, VecElem}; +use typst_library::text::TextElem; +use typst_library::visualize::{FillRule, FixedStroke, Geometry, LineCap, Shape}; +use typst_syntax::Span; + +use super::{ + alignments, delimiter_alignment, scaled_font_size, stack, style_for_denominator, + AlignmentResult, FrameFragment, GlyphFragment, LeftRightAlternator, MathContext, + Scaled, DELIM_SHORT_FALL, +}; + +const VERTICAL_PADDING: Ratio = Ratio::new(0.1); +const DEFAULT_STROKE_THICKNESS: Em = Em::new(0.05); + +/// Lays out a [`VecElem`]. +#[typst_macros::time(name = "math.vec", span = elem.span())] +pub fn layout_vec( + elem: &Packed<VecElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let delim = elem.delim(styles); + let frame = layout_vec_body( + ctx, + styles, + elem.children(), + elem.align(styles), + elem.gap(styles).at(scaled_font_size(ctx, styles)), + LeftRightAlternator::Right, + )?; + + layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), elem.span()) +} + +/// Lays out a [`MatElem`]. +#[typst_macros::time(name = "math.mat", span = elem.span())] +pub fn layout_mat( + elem: &Packed<MatElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let augment = elem.augment(styles); + let rows = elem.rows(); + + if let Some(aug) = &augment { + for &offset in &aug.hline.0 { + if offset == 0 || offset.unsigned_abs() >= rows.len() { + bail!( + elem.span(), + "cannot draw a horizontal line after row {} of a matrix with {} rows", + if offset < 0 { rows.len() as isize + offset } else { offset }, + rows.len() + ); + } + } + + let ncols = elem.rows().first().map_or(0, |row| row.len()); + + for &offset in &aug.vline.0 { + if offset == 0 || offset.unsigned_abs() >= ncols { + bail!( + elem.span(), + "cannot draw a vertical line after column {} of a matrix with {} columns", + if offset < 0 { ncols as isize + offset } else { offset }, + ncols + ); + } + } + } + + let font_size = scaled_font_size(ctx, styles); + let column_gap = elem.column_gap(styles).at(font_size); + let row_gap = elem.row_gap(styles).at(font_size); + let delim = elem.delim(styles); + let frame = layout_mat_body( + ctx, + styles, + rows, + elem.align(styles), + augment, + Axes::new(column_gap, row_gap), + elem.span(), + )?; + + layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), elem.span()) +} + +/// Lays out a [`CasesElem`]. +#[typst_macros::time(name = "math.cases", span = elem.span())] +pub fn layout_cases( + elem: &Packed<CasesElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let delim = elem.delim(styles); + let frame = layout_vec_body( + ctx, + styles, + elem.children(), + FixedAlignment::Start, + elem.gap(styles).at(scaled_font_size(ctx, styles)), + LeftRightAlternator::None, + )?; + + let (open, close) = + if elem.reverse(styles) { (None, delim.close()) } else { (delim.open(), None) }; + + layout_delimiters(ctx, styles, frame, open, close, elem.span()) +} + +/// Layout the inner contents of a vector. +fn layout_vec_body( + ctx: &mut MathContext, + styles: StyleChain, + column: &[Content], + align: FixedAlignment, + row_gap: Rel<Abs>, + alternator: LeftRightAlternator, +) -> SourceResult<Frame> { + let gap = row_gap.relative_to(ctx.region.size.y); + + let denom_style = style_for_denominator(styles); + let mut flat = vec![]; + for child in column { + flat.push(ctx.layout_into_run(child, styles.chain(&denom_style))?); + } + // We pad ascent and descent with the ascent and descent of the paren + // to ensure that normal vectors are aligned with others unless they are + // way too big. + let paren = + GlyphFragment::new(ctx, styles.chain(&denom_style), '(', Span::detached()); + Ok(stack(flat, align, gap, 0, alternator, Some((paren.ascent, paren.descent)))) +} + +/// Layout the inner contents of a matrix. +fn layout_mat_body( + ctx: &mut MathContext, + styles: StyleChain, + rows: &[Vec<Content>], + align: FixedAlignment, + augment: Option<Augment<Abs>>, + gap: Axes<Rel<Abs>>, + span: Span, +) -> SourceResult<Frame> { + let ncols = rows.first().map_or(0, |row| row.len()); + let nrows = rows.len(); + if ncols == 0 || nrows == 0 { + return Ok(Frame::soft(Size::zero())); + } + + let gap = gap.zip_map(ctx.region.size, Rel::relative_to); + let half_gap = gap * 0.5; + + // We provide a default stroke thickness that scales + // with font size to ensure that augmentation lines + // look correct by default at all matrix sizes. + // The line cap is also set to square because it looks more "correct". + let font_size = scaled_font_size(ctx, styles); + let default_stroke_thickness = DEFAULT_STROKE_THICKNESS.at(font_size); + let default_stroke = FixedStroke { + thickness: default_stroke_thickness, + paint: TextElem::fill_in(styles).as_decoration(), + cap: LineCap::Square, + ..Default::default() + }; + + let (hline, vline, stroke) = match augment { + Some(augment) => { + // We need to get stroke here for ownership. + let stroke = augment.stroke.unwrap_or_default().unwrap_or(default_stroke); + (augment.hline, augment.vline, stroke) + } + _ => (AugmentOffsets::default(), AugmentOffsets::default(), default_stroke), + }; + + // Before the full matrix body can be laid out, the + // individual cells must first be independently laid out + // so we can ensure alignment across rows and columns. + + // This variable stores the maximum ascent and descent for each row. + let mut heights = vec![(Abs::zero(), Abs::zero()); nrows]; + + // We want to transpose our data layout to columns + // before final layout. For efficiency, the columns + // variable is set up here and newly generated + // individual cells are then added to it. + let mut cols = vec![vec![]; ncols]; + + let denom_style = style_for_denominator(styles); + // We pad ascent and descent with the ascent and descent of the paren + // to ensure that normal matrices are aligned with others unless they are + // way too big. + let paren = + GlyphFragment::new(ctx, styles.chain(&denom_style), '(', Span::detached()); + + for (row, (ascent, descent)) in rows.iter().zip(&mut heights) { + for (cell, col) in row.iter().zip(&mut cols) { + let cell = ctx.layout_into_run(cell, styles.chain(&denom_style))?; + + ascent.set_max(cell.ascent().max(paren.ascent)); + descent.set_max(cell.descent().max(paren.descent)); + + col.push(cell); + } + } + + // For each row, combine maximum ascent and descent into a row height. + // Sum the row heights, then add the total height of the gaps between rows. + let total_height = + heights.iter().map(|&(a, b)| a + b).sum::<Abs>() + gap.y * (nrows - 1) as f64; + + // Width starts at zero because it can't be calculated until later + let mut frame = Frame::soft(Size::new(Abs::zero(), total_height)); + + let mut x = Abs::zero(); + + for (index, col) in cols.into_iter().enumerate() { + let AlignmentResult { points, width: rcol } = alignments(&col); + + let mut y = Abs::zero(); + + for (cell, &(ascent, descent)) in col.into_iter().zip(&heights) { + let cell = cell.into_line_frame(&points, LeftRightAlternator::Right); + let pos = Point::new( + if points.is_empty() { + x + align.position(rcol - cell.width()) + } else { + x + }, + y + ascent - cell.ascent(), + ); + + frame.push_frame(pos, cell); + + y += ascent + descent + gap.y; + } + + // Advance to the end of the column + x += rcol; + + // If a vertical line should be inserted after this column + if vline.0.contains(&(index as isize + 1)) + || vline.0.contains(&(1 - ((ncols - index) as isize))) + { + frame.push( + Point::with_x(x + half_gap.x), + line_item(total_height, true, stroke.clone(), span), + ); + } + + // Advance to the start of the next column + x += gap.x; + } + + // Once all the columns are laid out, the total width can be calculated + let total_width = x - gap.x; + + // This allows the horizontal lines to be laid out + for line in hline.0 { + let real_line = + if line < 0 { nrows - line.unsigned_abs() } else { line as usize }; + let offset = (heights[0..real_line].iter().map(|&(a, b)| a + b).sum::<Abs>() + + gap.y * (real_line - 1) as f64) + + half_gap.y; + + frame.push( + Point::with_y(offset), + line_item(total_width, false, stroke.clone(), span), + ); + } + + frame.size_mut().x = total_width; + + Ok(frame) +} + +fn line_item(length: Abs, vertical: bool, stroke: FixedStroke, span: Span) -> FrameItem { + let line_geom = if vertical { + Geometry::Line(Point::with_y(length)) + } else { + Geometry::Line(Point::with_x(length)) + }; + + FrameItem::Shape( + Shape { + geometry: line_geom, + fill: None, + fill_rule: FillRule::default(), + stroke: Some(stroke), + }, + span, + ) +} + +/// Layout the outer wrapper around the body of a vector or matrix. +fn layout_delimiters( + ctx: &mut MathContext, + styles: StyleChain, + mut frame: Frame, + left: Option<char>, + right: Option<char>, + span: Span, +) -> SourceResult<()> { + let font_size = scaled_font_size(ctx, styles); + let short_fall = DELIM_SHORT_FALL.at(font_size); + let axis = ctx.constants.axis_height().scaled(ctx, font_size); + let height = frame.height(); + let target = height + VERTICAL_PADDING.of(height); + frame.set_baseline(height / 2.0 + axis); + + if let Some(left) = left { + let mut left = GlyphFragment::new(ctx, styles, left, span) + .stretch_vertical(ctx, target, short_fall); + left.align_on_axis(ctx, delimiter_alignment(left.c)); + ctx.push(left); + } + + ctx.push(FrameFragment::new(ctx, styles, frame)); + + if let Some(right) = right { + let mut right = GlyphFragment::new(ctx, styles, right, span) + .stretch_vertical(ctx, target, short_fall); + right.align_on_axis(ctx, delimiter_alignment(right.c)); + ctx.push(right); + } + + Ok(()) +} diff --git a/crates/typst-layout/src/math/mod.rs b/crates/typst-layout/src/math/mod.rs new file mode 100644 index 00000000..b3dde977 --- /dev/null +++ b/crates/typst-layout/src/math/mod.rs @@ -0,0 +1,703 @@ +#[macro_use] +mod shared; +mod accent; +mod attach; +mod cancel; +mod frac; +mod fragment; +mod lr; +mod mat; +mod root; +mod run; +mod stretch; +mod text; +mod underover; + +use ttf_parser::gsub::SubstitutionSubtable; +use typst_library::diag::{bail, SourceResult}; +use typst_library::engine::Engine; +use typst_library::foundations::{Content, NativeElement, Packed, Resolve, StyleChain}; +use typst_library::introspection::{Counter, Locator, SplitLocator, TagElem}; +use typst_library::layout::{ + Abs, AlignElem, Axes, BlockElem, BoxElem, Em, FixedAlignment, Fragment, Frame, HElem, + InlineItem, OuterHAlignment, PlaceElem, Point, Region, Regions, Size, Spacing, + SpecificAlignment, VAlignment, +}; +use typst_library::math::*; +use typst_library::model::ParElem; +use typst_library::routines::{Arenas, RealizationKind}; +use typst_library::text::{ + families, features, variant, Font, LinebreakElem, SpaceElem, TextEdgeBounds, + TextElem, TextSize, +}; +use typst_library::World; +use typst_syntax::Span; +use typst_utils::Numeric; +use unicode_math_class::MathClass; + +use self::fragment::{ + FrameFragment, GlyphFragment, GlyphwiseSubsts, Limits, MathFragment, VariantFragment, +}; +use self::run::{LeftRightAlternator, MathRun, MathRunFrameBuilder}; +use self::shared::*; +use self::stretch::{stretch_fragment, stretch_glyph}; + +/// Layout an inline equation (in a paragraph). +#[typst_macros::time(span = elem.span())] +pub fn layout_equation_inline( + elem: &Packed<EquationElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Size, +) -> SourceResult<Vec<InlineItem>> { + assert!(!elem.block(styles)); + + let font = find_math_font(engine, styles, elem.span())?; + + let mut locator = locator.split(); + let mut ctx = MathContext::new(engine, &mut locator, styles, region, &font); + let run = ctx.layout_into_run(&elem.body, styles)?; + + let mut items = if run.row_count() == 1 { + run.into_par_items() + } else { + vec![InlineItem::Frame(run.into_fragment(&ctx, styles).into_frame())] + }; + + // An empty equation should have a height, so we still create a frame + // (which is then resized in the loop). + if items.is_empty() { + items.push(InlineItem::Frame(Frame::soft(Size::zero()))); + } + + for item in &mut items { + let InlineItem::Frame(frame) = item else { continue }; + + let font_size = scaled_font_size(&ctx, styles); + let slack = ParElem::leading_in(styles) * 0.7; + + let (t, b) = font.edges( + TextElem::top_edge_in(styles), + TextElem::bottom_edge_in(styles), + font_size, + TextEdgeBounds::Frame(frame), + ); + + let ascent = t.max(frame.ascent() - slack); + let descent = b.max(frame.descent() - slack); + frame.translate(Point::with_y(ascent - frame.baseline())); + frame.size_mut().y = ascent + descent; + } + + Ok(items) +} + +/// Layout a block-level equation (in a flow). +#[typst_macros::time(span = elem.span())] +pub fn layout_equation_block( + elem: &Packed<EquationElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + regions: Regions, +) -> SourceResult<Fragment> { + assert!(elem.block(styles)); + + let span = elem.span(); + let font = find_math_font(engine, styles, span)?; + + let mut locator = locator.split(); + let mut ctx = MathContext::new(engine, &mut locator, styles, regions.base(), &font); + let full_equation_builder = ctx + .layout_into_run(&elem.body, styles)? + .multiline_frame_builder(&ctx, styles); + let width = full_equation_builder.size.x; + + let equation_builders = if BlockElem::breakable_in(styles) { + let mut rows = full_equation_builder.frames.into_iter().peekable(); + let mut equation_builders = vec![]; + let mut last_first_pos = Point::zero(); + let mut regions = regions; + + loop { + // Keep track of the position of the first row in this region, + // so that the offset can be reverted later. + let Some(&(_, first_pos)) = rows.peek() else { break }; + last_first_pos = first_pos; + + let mut frames = vec![]; + let mut height = Abs::zero(); + while let Some((sub, pos)) = rows.peek() { + let mut pos = *pos; + pos.y -= first_pos.y; + + // Finish this region if the line doesn't fit. Only do it if + // we placed at least one line _or_ we still have non-last + // regions. Crucially, we don't want to infinitely create + // new regions which are too small. + if !regions.size.y.fits(sub.height() + pos.y) + && (regions.may_progress() + || (regions.may_break() && !frames.is_empty())) + { + break; + } + + let (sub, _) = rows.next().unwrap(); + height = height.max(pos.y + sub.height()); + frames.push((sub, pos)); + } + + equation_builders + .push(MathRunFrameBuilder { frames, size: Size::new(width, height) }); + regions.next(); + } + + // Append remaining rows to the equation builder of the last region. + if let Some(equation_builder) = equation_builders.last_mut() { + equation_builder.frames.extend(rows.map(|(frame, mut pos)| { + pos.y -= last_first_pos.y; + (frame, pos) + })); + + let height = equation_builder + .frames + .iter() + .map(|(frame, pos)| frame.height() + pos.y) + .max() + .unwrap_or(equation_builder.size.y); + + equation_builder.size.y = height; + } + + // Ensure that there is at least one frame, even for empty equations. + if equation_builders.is_empty() { + equation_builders + .push(MathRunFrameBuilder { frames: vec![], size: Size::zero() }); + } + + equation_builders + } else { + vec![full_equation_builder] + }; + + let Some(numbering) = (**elem).numbering(styles) else { + let frames = equation_builders + .into_iter() + .map(MathRunFrameBuilder::build) + .collect(); + return Ok(Fragment::frames(frames)); + }; + + let pod = Region::new(regions.base(), Axes::splat(false)); + let counter = Counter::of(EquationElem::elem()) + .display_at_loc(engine, elem.location().unwrap(), styles, numbering)? + .spanned(span); + let number = + (engine.routines.layout_frame)(engine, &counter, locator.next(&()), styles, pod)?; + + static NUMBER_GUTTER: Em = Em::new(0.5); + let full_number_width = number.width() + NUMBER_GUTTER.resolve(styles); + + let number_align = match elem.number_align(styles) { + SpecificAlignment::H(h) => SpecificAlignment::Both(h, VAlignment::Horizon), + SpecificAlignment::V(v) => SpecificAlignment::Both(OuterHAlignment::End, v), + SpecificAlignment::Both(h, v) => SpecificAlignment::Both(h, v), + }; + + // Add equation numbers to each equation region. + let region_count = equation_builders.len(); + let frames = equation_builders + .into_iter() + .map(|builder| { + if builder.frames.is_empty() && region_count > 1 { + // Don't number empty regions, but do number empty equations. + return builder.build(); + } + add_equation_number( + builder, + number.clone(), + number_align.resolve(styles), + AlignElem::alignment_in(styles).resolve(styles).x, + regions.size.x, + full_number_width, + ) + }) + .collect(); + + Ok(Fragment::frames(frames)) +} + +fn find_math_font( + engine: &mut Engine<'_>, + styles: StyleChain, + span: Span, +) -> SourceResult<Font> { + let variant = variant(styles); + let world = engine.world; + let Some(font) = families(styles).find_map(|family| { + let id = world.book().select(family, variant)?; + let font = world.font(id)?; + let _ = font.ttf().tables().math?.constants?; + Some(font) + }) else { + bail!(span, "current font does not support math"); + }; + Ok(font) +} + +fn add_equation_number( + equation_builder: MathRunFrameBuilder, + number: Frame, + number_align: Axes<FixedAlignment>, + equation_align: FixedAlignment, + region_size_x: Abs, + full_number_width: Abs, +) -> Frame { + let first = + equation_builder.frames.first().map_or( + (equation_builder.size, Point::zero(), Abs::zero()), + |(frame, pos)| (frame.size(), *pos, frame.baseline()), + ); + let last = + equation_builder.frames.last().map_or( + (equation_builder.size, Point::zero(), Abs::zero()), + |(frame, pos)| (frame.size(), *pos, frame.baseline()), + ); + let line_count = equation_builder.frames.len(); + let mut equation = equation_builder.build(); + + let width = if region_size_x.is_finite() { + region_size_x + } else { + equation.width() + 2.0 * full_number_width + }; + + let is_multiline = line_count >= 2; + let resizing_offset = resize_equation( + &mut equation, + &number, + number_align, + equation_align, + width, + is_multiline, + [first, last], + ); + equation.translate(Point::with_x(match (equation_align, number_align.x) { + (FixedAlignment::Start, FixedAlignment::Start) => full_number_width, + (FixedAlignment::End, FixedAlignment::End) => -full_number_width, + _ => Abs::zero(), + })); + + let x = match number_align.x { + FixedAlignment::Start => Abs::zero(), + FixedAlignment::End => equation.width() - number.width(), + _ => unreachable!(), + }; + let y = { + let align_baselines = |(_, pos, baseline): (_, Point, Abs), number: &Frame| { + resizing_offset.y + pos.y + baseline - number.baseline() + }; + match number_align.y { + FixedAlignment::Start => align_baselines(first, &number), + FixedAlignment::Center if !is_multiline => align_baselines(first, &number), + // In this case, the center lines (not baselines) of the number frame + // and the equation frame shall be aligned. + FixedAlignment::Center => (equation.height() - number.height()) / 2.0, + FixedAlignment::End => align_baselines(last, &number), + } + }; + + equation.push_frame(Point::new(x, y), number); + equation +} + +/// Resize the equation's frame accordingly so that it encompasses the number. +fn resize_equation( + equation: &mut Frame, + number: &Frame, + number_align: Axes<FixedAlignment>, + equation_align: FixedAlignment, + width: Abs, + is_multiline: bool, + [first, last]: [(Axes<Abs>, Point, Abs); 2], +) -> Point { + if matches!(number_align.y, FixedAlignment::Center if is_multiline) { + // In this case, the center lines (not baselines) of the number frame + // and the equation frame shall be aligned. + return equation.resize( + Size::new(width, equation.height().max(number.height())), + Axes::<FixedAlignment>::new(equation_align, FixedAlignment::Center), + ); + } + + let excess_above = Abs::zero().max({ + if !is_multiline || matches!(number_align.y, FixedAlignment::Start) { + let (.., baseline) = first; + number.baseline() - baseline + } else { + Abs::zero() + } + }); + let excess_below = Abs::zero().max({ + if !is_multiline || matches!(number_align.y, FixedAlignment::End) { + let (size, .., baseline) = last; + (number.height() - number.baseline()) - (size.y - baseline) + } else { + Abs::zero() + } + }); + + // The vertical expansion is asymmetric on the top and bottom edges, so we + // first align at the top then translate the content downward later. + let resizing_offset = equation.resize( + Size::new(width, equation.height() + excess_above + excess_below), + Axes::<FixedAlignment>::new(equation_align, FixedAlignment::Start), + ); + equation.translate(Point::with_y(excess_above)); + resizing_offset + Point::with_y(excess_above) +} + +/// The context for math layout. +struct MathContext<'a, 'v, 'e> { + // External. + engine: &'v mut Engine<'e>, + locator: &'v mut SplitLocator<'a>, + region: Region, + // Font-related. + font: &'a Font, + ttf: &'a ttf_parser::Face<'a>, + table: ttf_parser::math::Table<'a>, + constants: ttf_parser::math::Constants<'a>, + ssty_table: Option<ttf_parser::gsub::AlternateSubstitution<'a>>, + glyphwise_tables: Option<Vec<GlyphwiseSubsts<'a>>>, + space_width: Em, + // Mutable. + fragments: Vec<MathFragment>, +} + +impl<'a, 'v, 'e> MathContext<'a, 'v, 'e> { + /// Create a new math context. + fn new( + engine: &'v mut Engine<'e>, + locator: &'v mut SplitLocator<'a>, + styles: StyleChain<'a>, + base: Size, + font: &'a Font, + ) -> Self { + let math_table = font.ttf().tables().math.unwrap(); + let gsub_table = font.ttf().tables().gsub; + let constants = math_table.constants.unwrap(); + + let ssty_table = gsub_table + .and_then(|gsub| { + gsub.features + .find(ttf_parser::Tag::from_bytes(b"ssty")) + .and_then(|feature| feature.lookup_indices.get(0)) + .and_then(|index| gsub.lookups.get(index)) + }) + .and_then(|ssty| ssty.subtables.get::<SubstitutionSubtable>(0)) + .and_then(|ssty| match ssty { + SubstitutionSubtable::Alternate(alt_glyphs) => Some(alt_glyphs), + _ => None, + }); + + let features = features(styles); + let glyphwise_tables = gsub_table.map(|gsub| { + features + .into_iter() + .filter_map(|feature| GlyphwiseSubsts::new(gsub, feature)) + .collect() + }); + + let ttf = font.ttf(); + let space_width = ttf + .glyph_index(' ') + .and_then(|id| ttf.glyph_hor_advance(id)) + .map(|advance| font.to_em(advance)) + .unwrap_or(THICK); + + Self { + engine, + locator, + region: Region::new(base, Axes::splat(false)), + font, + ttf: font.ttf(), + table: math_table, + constants, + ssty_table, + glyphwise_tables, + space_width, + fragments: vec![], + } + } + + /// Push a fragment. + fn push(&mut self, fragment: impl Into<MathFragment>) { + self.fragments.push(fragment.into()); + } + + /// Push multiple fragments. + fn extend(&mut self, fragments: impl IntoIterator<Item = MathFragment>) { + self.fragments.extend(fragments); + } + + /// Layout the given element and return the result as a [`MathRun`]. + fn layout_into_run( + &mut self, + elem: &Content, + styles: StyleChain, + ) -> SourceResult<MathRun> { + Ok(MathRun::new(self.layout_into_fragments(elem, styles)?)) + } + + /// Layout the given element and return the resulting [`MathFragment`]s. + fn layout_into_fragments( + &mut self, + elem: &Content, + styles: StyleChain, + ) -> SourceResult<Vec<MathFragment>> { + // The element's layout_math() changes the fragments held in this + // MathContext object, but for convenience this function shouldn't change + // them, so we restore the MathContext's fragments after obtaining the + // layout result. + let prev = std::mem::take(&mut self.fragments); + self.layout_into_self(elem, styles)?; + Ok(std::mem::replace(&mut self.fragments, prev)) + } + + /// Layout the given element and return the result as a + /// unified [`MathFragment`]. + fn layout_into_fragment( + &mut self, + elem: &Content, + styles: StyleChain, + ) -> SourceResult<MathFragment> { + Ok(self.layout_into_run(elem, styles)?.into_fragment(self, styles)) + } + + /// Layout the given element and return the result as a [`Frame`]. + fn layout_into_frame( + &mut self, + elem: &Content, + styles: StyleChain, + ) -> SourceResult<Frame> { + Ok(self.layout_into_fragment(elem, styles)?.into_frame()) + } + + /// Layout arbitrary content. + fn layout_into_self( + &mut self, + content: &Content, + styles: StyleChain, + ) -> SourceResult<()> { + let arenas = Arenas::default(); + let pairs = (self.engine.routines.realize)( + RealizationKind::Math, + self.engine, + self.locator, + &arenas, + content, + styles, + )?; + + let outer = styles; + for (elem, styles) in pairs { + // Hack because the font is fixed in math. + if styles != outer && TextElem::font_in(styles) != TextElem::font_in(outer) { + let frame = layout_external(elem, self, styles)?; + self.push(FrameFragment::new(self, styles, frame).with_spaced(true)); + continue; + } + + layout_realized(elem, self, styles)?; + } + + Ok(()) + } +} + +/// Lays out a leaf element resulting from realization. +fn layout_realized( + elem: &Content, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + if let Some(elem) = elem.to_packed::<TagElem>() { + ctx.push(MathFragment::Tag(elem.tag.clone())); + } else if elem.is::<SpaceElem>() { + let font_size = scaled_font_size(ctx, styles); + ctx.push(MathFragment::Space(ctx.space_width.at(font_size))); + } else if elem.is::<LinebreakElem>() { + ctx.push(MathFragment::Linebreak); + } else if let Some(elem) = elem.to_packed::<HElem>() { + layout_h(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<TextElem>() { + self::text::layout_text(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<BoxElem>() { + layout_box(elem, ctx, styles)?; + } else if elem.is::<AlignPointElem>() { + ctx.push(MathFragment::Align); + } else if let Some(elem) = elem.to_packed::<ClassElem>() { + layout_class(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<AccentElem>() { + self::accent::layout_accent(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<AttachElem>() { + self::attach::layout_attach(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<PrimesElem>() { + self::attach::layout_primes(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<ScriptsElem>() { + self::attach::layout_scripts(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<LimitsElem>() { + self::attach::layout_limits(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<CancelElem>() { + self::cancel::layout_cancel(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<FracElem>() { + self::frac::layout_frac(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<BinomElem>() { + self::frac::layout_binom(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<LrElem>() { + self::lr::layout_lr(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<MidElem>() { + self::lr::layout_mid(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<VecElem>() { + self::mat::layout_vec(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<MatElem>() { + self::mat::layout_mat(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<CasesElem>() { + self::mat::layout_cases(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<OpElem>() { + layout_op(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<RootElem>() { + self::root::layout_root(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<StretchElem>() { + self::stretch::layout_stretch(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<UnderlineElem>() { + self::underover::layout_underline(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<OverlineElem>() { + self::underover::layout_overline(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<UnderbraceElem>() { + self::underover::layout_underbrace(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<OverbraceElem>() { + self::underover::layout_overbrace(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<UnderbracketElem>() { + self::underover::layout_underbracket(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<OverbracketElem>() { + self::underover::layout_overbracket(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<UnderparenElem>() { + self::underover::layout_underparen(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<OverparenElem>() { + self::underover::layout_overparen(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<UndershellElem>() { + self::underover::layout_undershell(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<OvershellElem>() { + self::underover::layout_overshell(elem, ctx, styles)? + } else { + let mut frame = layout_external(elem, ctx, styles)?; + if !frame.has_baseline() { + let axis = scaled!(ctx, styles, axis_height); + frame.set_baseline(frame.height() / 2.0 + axis); + } + ctx.push( + FrameFragment::new(ctx, styles, frame) + .with_spaced(true) + .with_ignorant(elem.is::<PlaceElem>()), + ); + } + + Ok(()) +} + +/// Lays out an [`BoxElem`]. +fn layout_box( + elem: &Packed<BoxElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let local = TextElem::set_size(TextSize(scaled_font_size(ctx, styles).into())).wrap(); + let frame = (ctx.engine.routines.layout_box)( + elem, + ctx.engine, + ctx.locator.next(&elem.span()), + styles.chain(&local), + ctx.region.size, + )?; + ctx.push(FrameFragment::new(ctx, styles, frame).with_spaced(true)); + Ok(()) +} + +/// Lays out an [`HElem`]. +fn layout_h( + elem: &Packed<HElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + if let Spacing::Rel(rel) = elem.amount() { + if rel.rel.is_zero() { + ctx.push(MathFragment::Spacing( + rel.abs.at(scaled_font_size(ctx, styles)), + elem.weak(styles), + )); + } + } + Ok(()) +} + +/// Lays out a [`ClassElem`]. +#[typst_macros::time(name = "math.op", span = elem.span())] +fn layout_class( + elem: &Packed<ClassElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let class = *elem.class(); + let style = EquationElem::set_class(Some(class)).wrap(); + let mut fragment = ctx.layout_into_fragment(elem.body(), styles.chain(&style))?; + fragment.set_class(class); + fragment.set_limits(Limits::for_class(class)); + ctx.push(fragment); + Ok(()) +} + +/// Lays out an [`OpElem`]. +#[typst_macros::time(name = "math.op", span = elem.span())] +fn layout_op( + elem: &Packed<OpElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let fragment = ctx.layout_into_fragment(elem.text(), styles)?; + let italics = fragment.italics_correction(); + let accent_attach = fragment.accent_attach(); + let text_like = fragment.is_text_like(); + + ctx.push( + FrameFragment::new(ctx, styles, fragment.into_frame()) + .with_class(MathClass::Large) + .with_italics_correction(italics) + .with_accent_attach(accent_attach) + .with_text_like(text_like) + .with_limits(if elem.limits(styles) { + Limits::Display + } else { + Limits::Never + }), + ); + Ok(()) +} + +/// Layout into a frame with normal layout. +fn layout_external( + content: &Content, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<Frame> { + let local = TextElem::set_size(TextSize(scaled_font_size(ctx, styles).into())).wrap(); + (ctx.engine.routines.layout_frame)( + ctx.engine, + content, + ctx.locator.next(&content.span()), + styles.chain(&local), + ctx.region, + ) +} diff --git a/crates/typst-layout/src/math/root.rs b/crates/typst-layout/src/math/root.rs new file mode 100644 index 00000000..0bb2f539 --- /dev/null +++ b/crates/typst-layout/src/math/root.rs @@ -0,0 +1,126 @@ +use typst_library::diag::SourceResult; +use typst_library::foundations::{Packed, StyleChain}; +use typst_library::layout::{Abs, Frame, FrameItem, Point, Size}; +use typst_library::math::{EquationElem, MathSize, RootElem}; +use typst_library::text::TextElem; +use typst_library::visualize::{FixedStroke, Geometry}; + +use super::{style_cramped, FrameFragment, GlyphFragment, MathContext}; + +/// Lays out a [`RootElem`]. +/// +/// TeXbook page 443, page 360 +/// See also: <https://www.w3.org/TR/mathml-core/#radicals-msqrt-mroot> +#[typst_macros::time(name = "math.root", span = elem.span())] +pub fn layout_root( + elem: &Packed<RootElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let index = elem.index(styles); + let radicand = elem.radicand(); + let span = elem.span(); + + let gap = scaled!( + ctx, styles, + text: radical_vertical_gap, + display: radical_display_style_vertical_gap, + ); + let thickness = scaled!(ctx, styles, radical_rule_thickness); + let extra_ascender = scaled!(ctx, styles, radical_extra_ascender); + let kern_before = scaled!(ctx, styles, radical_kern_before_degree); + let kern_after = scaled!(ctx, styles, radical_kern_after_degree); + let raise_factor = percent!(ctx, radical_degree_bottom_raise_percent); + + // Layout radicand. + let radicand = { + let cramped = style_cramped(); + let styles = styles.chain(&cramped); + let run = ctx.layout_into_run(radicand, styles)?; + let multiline = run.is_multiline(); + let mut radicand = run.into_fragment(ctx, styles).into_frame(); + if multiline { + // Align the frame center line with the math axis. + radicand.set_baseline( + radicand.height() / 2.0 + scaled!(ctx, styles, axis_height), + ); + } + radicand + }; + + // Layout root symbol. + let target = radicand.height() + thickness + gap; + let sqrt = GlyphFragment::new(ctx, styles, '√', span) + .stretch_vertical(ctx, target, Abs::zero()) + .frame; + + // Layout the index. + let sscript = EquationElem::set_size(MathSize::ScriptScript).wrap(); + let index = index + .as_ref() + .map(|elem| ctx.layout_into_frame(elem, styles.chain(&sscript))) + .transpose()?; + + // TeXbook, page 443, item 11 + // Keep original gap, and then distribute any remaining free space + // equally above and below. + let gap = gap.max((sqrt.height() - thickness - radicand.height() + gap) / 2.0); + + let sqrt_ascent = radicand.ascent() + gap + thickness; + let descent = sqrt.height() - sqrt_ascent; + let inner_ascent = sqrt_ascent + extra_ascender; + + let mut sqrt_offset = Abs::zero(); + let mut shift_up = Abs::zero(); + let mut ascent = inner_ascent; + + if let Some(index) = &index { + sqrt_offset = kern_before + index.width() + kern_after; + // The formula below for how much raise the index by comes from + // the TeXbook, page 360, in the definition of `\root`. + // However, the `+ index.descent()` part is different from TeX. + // Without it, descenders can collide with the surd, a rarity + // in practice, but possible. MS Word also adjusts index positions + // for descenders. + shift_up = raise_factor * (inner_ascent - descent) + index.descent(); + ascent.set_max(shift_up + index.ascent()); + } + + let radicand_x = sqrt_offset + sqrt.width(); + let radicand_y = ascent - radicand.ascent(); + let width = radicand_x + radicand.width(); + let size = Size::new(width, ascent + descent); + + // The extra "- thickness" comes from the fact that the sqrt is placed + // in `push_frame` with respect to its top, not its baseline. + let sqrt_pos = Point::new(sqrt_offset, radicand_y - gap - thickness); + let line_pos = Point::new(radicand_x, radicand_y - gap - (thickness / 2.0)); + let radicand_pos = Point::new(radicand_x, radicand_y); + + let mut frame = Frame::soft(size); + frame.set_baseline(ascent); + + if let Some(index) = index { + let index_pos = Point::new(kern_before, ascent - index.ascent() - shift_up); + frame.push_frame(index_pos, index); + } + + frame.push_frame(sqrt_pos, sqrt); + frame.push( + line_pos, + FrameItem::Shape( + Geometry::Line(Point::with_x(radicand.width())).stroked( + FixedStroke::from_pair( + TextElem::fill_in(styles).as_decoration(), + thickness, + ), + ), + span, + ), + ); + + frame.push_frame(radicand_pos, radicand); + ctx.push(FrameFragment::new(ctx, styles, frame)); + + Ok(()) +} diff --git a/crates/typst-layout/src/math/run.rs b/crates/typst-layout/src/math/run.rs new file mode 100644 index 00000000..8f12c509 --- /dev/null +++ b/crates/typst-layout/src/math/run.rs @@ -0,0 +1,469 @@ +use std::iter::once; + +use typst_library::foundations::{Resolve, StyleChain}; +use typst_library::layout::{Abs, AlignElem, Em, Frame, InlineItem, Point, Size}; +use typst_library::math::{EquationElem, MathSize, MEDIUM, THICK, THIN}; +use typst_library::model::ParElem; +use unicode_math_class::MathClass; + +use super::{alignments, scaled_font_size, FrameFragment, MathContext, MathFragment}; + +const TIGHT_LEADING: Em = Em::new(0.25); + +/// A linear collection of [`MathFragment`]s. +#[derive(Debug, Default, Clone)] +pub struct MathRun(Vec<MathFragment>); + +impl MathRun { + /// Takes the given [`MathFragment`]s and do some basic processing. + pub fn new(fragments: Vec<MathFragment>) -> Self { + let iter = fragments.into_iter().peekable(); + let mut last: Option<usize> = None; + let mut space: Option<MathFragment> = None; + let mut resolved: Vec<MathFragment> = vec![]; + + for mut fragment in iter { + match fragment { + // Keep space only if supported by spaced fragments. + MathFragment::Space(_) => { + if last.is_some() { + space = Some(fragment); + } + continue; + } + + // Explicit spacing disables automatic spacing. + MathFragment::Spacing(width, weak) => { + last = None; + space = None; + + if weak { + match resolved.last_mut() { + None => continue, + Some(MathFragment::Spacing(prev, true)) => { + *prev = (*prev).max(width); + continue; + } + Some(_) => {} + } + } + + resolved.push(fragment); + continue; + } + + // Alignment points are resolved later. + MathFragment::Align => { + resolved.push(fragment); + continue; + } + + // New line, new things. + MathFragment::Linebreak => { + resolved.push(fragment); + space = None; + last = None; + continue; + } + + _ => {} + } + + // Convert variable operators into binary operators if something + // precedes them and they are not preceded by a operator or comparator. + if fragment.class() == MathClass::Vary + && matches!( + last.map(|i| resolved[i].class()), + Some( + MathClass::Normal + | MathClass::Alphabetic + | MathClass::Closing + | MathClass::Fence + ) + ) + { + fragment.set_class(MathClass::Binary); + } + + // Insert spacing between the last and this non-ignorant item. + if !fragment.is_ignorant() { + if let Some(i) = last { + if let Some(s) = spacing(&resolved[i], space.take(), &fragment) { + resolved.insert(i + 1, s); + } + } + + last = Some(resolved.len()); + } + + resolved.push(fragment); + } + + if let Some(MathFragment::Spacing(_, true)) = resolved.last() { + resolved.pop(); + } + + Self(resolved) + } + + pub fn iter(&self) -> std::slice::Iter<'_, MathFragment> { + self.0.iter() + } + + /// Split by linebreaks, and copy [`MathFragment`]s into rows. + pub fn rows(&self) -> Vec<Self> { + self.0 + .split(|frag| matches!(frag, MathFragment::Linebreak)) + .map(|slice| Self(slice.to_vec())) + .collect() + } + + pub fn row_count(&self) -> usize { + let mut count = + 1 + self.0.iter().filter(|f| matches!(f, MathFragment::Linebreak)).count(); + + // A linebreak at the very end does not introduce an extra row. + if let Some(f) = self.0.last() { + if matches!(f, MathFragment::Linebreak) { + count -= 1 + } + } + count + } + + pub fn ascent(&self) -> Abs { + self.iter() + .filter(|e| affects_row_height(e)) + .map(|e| e.ascent()) + .max() + .unwrap_or_default() + } + + pub fn descent(&self) -> Abs { + self.iter() + .filter(|e| affects_row_height(e)) + .map(|e| e.descent()) + .max() + .unwrap_or_default() + } + + pub fn class(&self) -> MathClass { + // Predict the class of the output of 'into_fragment' + if self.0.len() == 1 { + self.0 + .first() + .map(|fragment| fragment.class()) + .unwrap_or(MathClass::Normal) + } else { + // FrameFragment::new() (inside 'into_fragment' in this branch) defaults + // to MathClass::Normal for its class. + MathClass::Normal + } + } + + pub fn into_frame(self, ctx: &MathContext, styles: StyleChain) -> Frame { + if !self.is_multiline() { + self.into_line_frame(&[], LeftRightAlternator::Right) + } else { + self.multiline_frame_builder(ctx, styles).build() + } + } + + pub fn into_fragment(self, ctx: &MathContext, styles: StyleChain) -> MathFragment { + if self.0.len() == 1 { + return self.0.into_iter().next().unwrap(); + } + + // Fragments without a math_size are ignored: the notion of size do not + // apply to them, so their text-likeness is meaningless. + let text_like = self + .iter() + .filter(|e| e.math_size().is_some()) + .all(|e| e.is_text_like()); + + FrameFragment::new(ctx, styles, self.into_frame(ctx, styles)) + .with_text_like(text_like) + .into() + } + + /// Returns a builder that lays out the [`MathFragment`]s into a possibly + /// multi-row [`Frame`]. The rows are aligned using the same set of alignment + /// points computed from them as a whole. + pub fn multiline_frame_builder( + self, + ctx: &MathContext, + styles: StyleChain, + ) -> MathRunFrameBuilder { + let rows: Vec<_> = self.rows(); + let row_count = rows.len(); + let alignments = alignments(&rows); + + let leading = if EquationElem::size_in(styles) >= MathSize::Text { + ParElem::leading_in(styles) + } else { + let font_size = scaled_font_size(ctx, styles); + TIGHT_LEADING.at(font_size) + }; + + let align = AlignElem::alignment_in(styles).resolve(styles).x; + let mut frames: Vec<(Frame, Point)> = vec![]; + let mut size = Size::zero(); + for (i, row) in rows.into_iter().enumerate() { + if i == row_count - 1 && row.0.is_empty() { + continue; + } + + let sub = row.into_line_frame(&alignments.points, LeftRightAlternator::Right); + if i > 0 { + size.y += leading; + } + + let mut pos = Point::with_y(size.y); + if alignments.points.is_empty() { + pos.x = align.position(alignments.width - sub.width()); + } + size.x.set_max(sub.width()); + size.y += sub.height(); + frames.push((sub, pos)); + } + + MathRunFrameBuilder { size, frames } + } + + /// Lay out [`MathFragment`]s into a one-row [`Frame`], using the + /// caller-provided alignment points. + pub fn into_line_frame( + self, + points: &[Abs], + mut alternator: LeftRightAlternator, + ) -> Frame { + let ascent = self.ascent(); + let mut frame = Frame::soft(Size::new(Abs::zero(), ascent + self.descent())); + frame.set_baseline(ascent); + + let mut next_x = { + let widths: Vec<Abs> = if points.is_empty() { + vec![] + } else { + self.iter() + .as_slice() + .split(|e| matches!(e, MathFragment::Align)) + .map(|chunk| chunk.iter().map(|e| e.width()).sum()) + .collect() + }; + + let mut prev_points = once(Abs::zero()).chain(points.iter().copied()); + let mut point_widths = points.iter().copied().zip(widths); + move || { + point_widths + .next() + .zip(prev_points.next()) + .zip(alternator.next()) + .map(|(((point, width), prev_point), alternator)| match alternator { + LeftRightAlternator::Right => point - width, + _ => prev_point, + }) + } + }; + let mut x = next_x().unwrap_or_default(); + + for fragment in self.0.into_iter() { + if matches!(fragment, MathFragment::Align) { + x = next_x().unwrap_or(x); + continue; + } + + let y = ascent - fragment.ascent(); + let pos = Point::new(x, y); + x += fragment.width(); + frame.push_frame(pos, fragment.into_frame()); + } + + frame.size_mut().x = x; + frame + } + + pub fn into_par_items(self) -> Vec<InlineItem> { + let mut items = vec![]; + + let mut x = Abs::zero(); + let mut ascent = Abs::zero(); + let mut descent = Abs::zero(); + let mut frame = Frame::soft(Size::zero()); + let mut empty = true; + + let finalize_frame = |frame: &mut Frame, x, ascent, descent| { + frame.set_size(Size::new(x, ascent + descent)); + frame.set_baseline(Abs::zero()); + frame.translate(Point::with_y(ascent)); + }; + + let mut space_is_visible = false; + + let is_relation = |f: &MathFragment| matches!(f.class(), MathClass::Relation); + let is_space = |f: &MathFragment| { + matches!(f, MathFragment::Space(_) | MathFragment::Spacing(_, _)) + }; + + let mut iter = self.0.into_iter().peekable(); + while let Some(fragment) = iter.next() { + if space_is_visible { + match fragment { + MathFragment::Space(width) | MathFragment::Spacing(width, _) => { + items.push(InlineItem::Space(width, true)); + continue; + } + _ => {} + } + } + + let class = fragment.class(); + let y = fragment.ascent(); + + ascent.set_max(y); + descent.set_max(fragment.descent()); + + let pos = Point::new(x, -y); + x += fragment.width(); + frame.push_frame(pos, fragment.into_frame()); + empty = false; + + if class == MathClass::Binary + || (class == MathClass::Relation + && !iter.peek().map(is_relation).unwrap_or_default()) + { + let mut frame_prev = + std::mem::replace(&mut frame, Frame::soft(Size::zero())); + + finalize_frame(&mut frame_prev, x, ascent, descent); + items.push(InlineItem::Frame(frame_prev)); + empty = true; + + x = Abs::zero(); + ascent = Abs::zero(); + descent = Abs::zero(); + + space_is_visible = true; + if let Some(f_next) = iter.peek() { + if !is_space(f_next) { + items.push(InlineItem::Space(Abs::zero(), true)); + } + } + } else { + space_is_visible = false; + } + } + + // Don't use `frame.is_empty()` because even an empty frame can + // contribute width (if it had hidden content). + if !empty { + finalize_frame(&mut frame, x, ascent, descent); + items.push(InlineItem::Frame(frame)); + } + + items + } + + pub fn is_multiline(&self) -> bool { + self.iter().any(|frag| matches!(frag, MathFragment::Linebreak)) + } +} + +impl<T: Into<MathFragment>> From<T> for MathRun { + fn from(fragment: T) -> Self { + Self(vec![fragment.into()]) + } +} + +/// An iterator that alternates between the `Left` and `Right` values, if the +/// initial value is not `None`. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum LeftRightAlternator { + None, + Left, + Right, +} + +impl Iterator for LeftRightAlternator { + type Item = LeftRightAlternator; + + fn next(&mut self) -> Option<Self::Item> { + let r = Some(*self); + match self { + Self::None => {} + Self::Left => *self = Self::Right, + Self::Right => *self = Self::Left, + } + r + } +} + +/// How the rows from the [`MathRun`] should be aligned and merged into a [`Frame`]. +pub struct MathRunFrameBuilder { + /// The size of the resulting frame. + pub size: Size, + /// Each row's frame, and the position where the frame should + /// be pushed into the resulting frame. + pub frames: Vec<(Frame, Point)>, +} + +impl MathRunFrameBuilder { + /// Consumes the builder and returns a [`Frame`]. + pub fn build(self) -> Frame { + let mut frame = Frame::soft(self.size); + for (sub, pos) in self.frames.into_iter() { + frame.push_frame(pos, sub); + } + frame + } +} + +fn affects_row_height(fragment: &MathFragment) -> bool { + !matches!(fragment, MathFragment::Align | MathFragment::Linebreak) +} + +/// Create the spacing between two fragments in a given style. +fn spacing( + l: &MathFragment, + space: Option<MathFragment>, + r: &MathFragment, +) -> Option<MathFragment> { + use MathClass::*; + + let resolve = |v: Em, size_ref: &MathFragment| -> Option<MathFragment> { + let width = size_ref.font_size().map_or(Abs::zero(), |size| v.at(size)); + Some(MathFragment::Spacing(width, false)) + }; + let script = |f: &MathFragment| f.math_size().is_some_and(|s| s <= MathSize::Script); + + match (l.class(), r.class()) { + // No spacing before punctuation; thin spacing after punctuation, unless + // in script size. + (_, Punctuation) => None, + (Punctuation, _) if !script(l) => resolve(THIN, l), + + // No spacing after opening delimiters and before closing delimiters. + (Opening, _) | (_, Closing) => None, + + // Thick spacing around relations, unless followed by a another relation + // or in script size. + (Relation, Relation) => None, + (Relation, _) if !script(l) => resolve(THICK, l), + (_, Relation) if !script(r) => resolve(THICK, r), + + // Medium spacing around binary operators, unless in script size. + (Binary, _) if !script(l) => resolve(MEDIUM, l), + (_, Binary) if !script(r) => resolve(MEDIUM, r), + + // Thin spacing around large operators, unless to the left of + // an opening delimiter. TeXBook, p170 + (Large, Opening | Fence) => None, + (Large, _) => resolve(THIN, l), + (_, Large) => resolve(THIN, r), + + // Spacing around spaced frames. + _ if (l.is_spaced() || r.is_spaced()) => space, + + _ => None, + } +} diff --git a/crates/typst-layout/src/math/shared.rs b/crates/typst-layout/src/math/shared.rs new file mode 100644 index 00000000..13477c10 --- /dev/null +++ b/crates/typst-layout/src/math/shared.rs @@ -0,0 +1,207 @@ +use ttf_parser::math::MathValue; +use typst_library::foundations::{Style, StyleChain}; +use typst_library::layout::{Abs, Em, FixedAlignment, Frame, Point, Size, VAlignment}; +use typst_library::math::{EquationElem, MathSize}; +use typst_library::text::TextElem; +use typst_utils::LazyHash; + +use super::{LeftRightAlternator, MathContext, MathFragment, MathRun}; + +macro_rules! scaled { + ($ctx:expr, $styles:expr, text: $text:ident, display: $display:ident $(,)?) => { + match typst_library::math::EquationElem::size_in($styles) { + typst_library::math::MathSize::Display => scaled!($ctx, $styles, $display), + _ => scaled!($ctx, $styles, $text), + } + }; + ($ctx:expr, $styles:expr, $name:ident) => { + $crate::math::Scaled::scaled( + $ctx.constants.$name(), + $ctx, + $crate::math::scaled_font_size($ctx, $styles), + ) + }; +} + +macro_rules! percent { + ($ctx:expr, $name:ident) => { + $ctx.constants.$name() as f64 / 100.0 + }; +} + +/// How much less high scaled delimiters can be than what they wrap. +pub const DELIM_SHORT_FALL: Em = Em::new(0.1); + +/// Converts some unit to an absolute length with the current font & font size. +pub trait Scaled { + fn scaled(self, ctx: &MathContext, font_size: Abs) -> Abs; +} + +impl Scaled for i16 { + fn scaled(self, ctx: &MathContext, font_size: Abs) -> Abs { + ctx.font.to_em(self).at(font_size) + } +} + +impl Scaled for u16 { + fn scaled(self, ctx: &MathContext, font_size: Abs) -> Abs { + ctx.font.to_em(self).at(font_size) + } +} + +impl Scaled for MathValue<'_> { + fn scaled(self, ctx: &MathContext, font_size: Abs) -> Abs { + self.value.scaled(ctx, font_size) + } +} + +/// Get the font size scaled with the `MathSize`. +pub fn scaled_font_size(ctx: &MathContext, styles: StyleChain) -> Abs { + let factor = match EquationElem::size_in(styles) { + MathSize::Display | MathSize::Text => 1.0, + MathSize::Script => percent!(ctx, script_percent_scale_down), + MathSize::ScriptScript => percent!(ctx, script_script_percent_scale_down), + }; + factor * TextElem::size_in(styles) +} + +/// Styles something as cramped. +pub fn style_cramped() -> LazyHash<Style> { + EquationElem::set_cramped(true).wrap() +} + +/// The style for subscripts in the current style. +pub fn style_for_subscript(styles: StyleChain) -> [LazyHash<Style>; 2] { + [style_for_superscript(styles), EquationElem::set_cramped(true).wrap()] +} + +/// The style for superscripts in the current style. +pub fn style_for_superscript(styles: StyleChain) -> LazyHash<Style> { + EquationElem::set_size(match EquationElem::size_in(styles) { + MathSize::Display | MathSize::Text => MathSize::Script, + MathSize::Script | MathSize::ScriptScript => MathSize::ScriptScript, + }) + .wrap() +} + +/// The style for numerators in the current style. +pub fn style_for_numerator(styles: StyleChain) -> LazyHash<Style> { + EquationElem::set_size(match EquationElem::size_in(styles) { + MathSize::Display => MathSize::Text, + MathSize::Text => MathSize::Script, + MathSize::Script | MathSize::ScriptScript => MathSize::ScriptScript, + }) + .wrap() +} + +/// The style for denominators in the current style. +pub fn style_for_denominator(styles: StyleChain) -> [LazyHash<Style>; 2] { + [style_for_numerator(styles), EquationElem::set_cramped(true).wrap()] +} + +/// How a delimieter should be aligned when scaling. +pub fn delimiter_alignment(delimiter: char) -> VAlignment { + match delimiter { + '⌜' | '⌝' => VAlignment::Top, + '⌞' | '⌟' => VAlignment::Bottom, + _ => VAlignment::Horizon, + } +} + +/// Stack rows on top of each other. +/// +/// Add a `gap` between each row and uses the baseline of the `baseline`-th +/// row for the whole frame. `alternator` controls the left/right alternating +/// alignment behavior of `AlignPointElem` in the rows. +pub fn stack( + rows: Vec<MathRun>, + align: FixedAlignment, + gap: Abs, + baseline: usize, + alternator: LeftRightAlternator, + minimum_ascent_descent: Option<(Abs, Abs)>, +) -> Frame { + let rows: Vec<_> = rows.into_iter().flat_map(|r| r.rows()).collect(); + let AlignmentResult { points, width } = alignments(&rows); + let rows: Vec<_> = rows + .into_iter() + .map(|row| row.into_line_frame(&points, alternator)) + .collect(); + + let padded_height = |height: Abs| { + height.max(minimum_ascent_descent.map_or(Abs::zero(), |(a, d)| a + d)) + }; + + let mut frame = Frame::soft(Size::new( + width, + rows.iter().map(|row| padded_height(row.height())).sum::<Abs>() + + rows.len().saturating_sub(1) as f64 * gap, + )); + + let mut y = Abs::zero(); + for (i, row) in rows.into_iter().enumerate() { + let x = if points.is_empty() { + align.position(width - row.width()) + } else { + Abs::zero() + }; + let ascent_padded_part = minimum_ascent_descent + .map_or(Abs::zero(), |(a, _)| (a - row.ascent())) + .max(Abs::zero()); + let pos = Point::new(x, y + ascent_padded_part); + if i == baseline { + frame.set_baseline(y + row.baseline() + ascent_padded_part); + } + y += padded_height(row.height()) + gap; + frame.push_frame(pos, row); + } + + frame +} + +/// Determine the positions of the alignment points, according to the input rows combined. +pub fn alignments(rows: &[MathRun]) -> AlignmentResult { + let mut widths = Vec::<Abs>::new(); + + let mut pending_width = Abs::zero(); + for row in rows { + let mut width = Abs::zero(); + let mut alignment_index = 0; + + for fragment in row.iter() { + if matches!(fragment, MathFragment::Align) { + if alignment_index < widths.len() { + widths[alignment_index].set_max(width); + } else { + widths.push(width.max(pending_width)); + } + width = Abs::zero(); + alignment_index += 1; + } else { + width += fragment.width(); + } + } + if widths.is_empty() { + pending_width.set_max(width); + } else if alignment_index < widths.len() { + widths[alignment_index].set_max(width); + } else { + widths.push(width.max(pending_width)); + } + } + + let mut points = widths; + for i in 1..points.len() { + let prev = points[i - 1]; + points[i] += prev; + } + AlignmentResult { + width: points.last().copied().unwrap_or(pending_width), + points, + } +} + +pub struct AlignmentResult { + pub points: Vec<Abs>, + pub width: Abs, +} diff --git a/crates/typst-layout/src/math/stretch.rs b/crates/typst-layout/src/math/stretch.rs new file mode 100644 index 00000000..6dc82014 --- /dev/null +++ b/crates/typst-layout/src/math/stretch.rs @@ -0,0 +1,291 @@ +use ttf_parser::math::{GlyphAssembly, GlyphConstruction, GlyphPart}; +use ttf_parser::LazyArray16; +use typst_library::diag::SourceResult; +use typst_library::foundations::{Packed, Smart, StyleChain}; +use typst_library::layout::{Abs, Axis, Frame, Length, Point, Rel, Size}; +use typst_library::math::StretchElem; +use typst_utils::Get; + +use super::{ + delimiter_alignment, scaled_font_size, GlyphFragment, MathContext, MathFragment, + Scaled, VariantFragment, +}; + +/// Maximum number of times extenders can be repeated. +const MAX_REPEATS: usize = 1024; + +/// Lays out a [`StretchElem`]. +#[typst_macros::time(name = "math.stretch", span = elem.span())] +pub fn layout_stretch( + elem: &Packed<StretchElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let mut fragment = ctx.layout_into_fragment(elem.body(), styles)?; + stretch_fragment( + ctx, + styles, + &mut fragment, + None, + None, + elem.size(styles), + Abs::zero(), + ); + ctx.push(fragment); + Ok(()) +} + +/// Attempts to stretch the given fragment by/to the amount given in stretch. +pub fn stretch_fragment( + ctx: &mut MathContext, + styles: StyleChain, + fragment: &mut MathFragment, + axis: Option<Axis>, + relative_to: Option<Abs>, + stretch: Smart<Rel<Length>>, + short_fall: Abs, +) { + let glyph = match fragment { + MathFragment::Glyph(glyph) => glyph.clone(), + MathFragment::Variant(variant) => { + GlyphFragment::new(ctx, styles, variant.c, variant.span) + } + _ => return, + }; + + // Return if we attempt to stretch along an axis which isn't stretchable, + // so that the original fragment isn't modified. + let Some(stretch_axis) = stretch_axis(ctx, &glyph) else { return }; + let axis = axis.unwrap_or(stretch_axis); + if axis != stretch_axis { + return; + } + + let relative_to_size = relative_to.unwrap_or_else(|| fragment.size().get(axis)); + + let mut variant = stretch_glyph( + ctx, + glyph, + stretch + .unwrap_or(Rel::one()) + .at(scaled_font_size(ctx, styles)) + .relative_to(relative_to_size), + short_fall, + axis, + ); + + if axis == Axis::Y { + variant.align_on_axis(ctx, delimiter_alignment(variant.c)); + } + + *fragment = MathFragment::Variant(variant); +} + +/// Try to stretch a glyph to a desired width or height. +/// +/// The resulting frame may not have the exact desired width. +pub fn stretch_glyph( + ctx: &MathContext, + mut base: GlyphFragment, + target: Abs, + short_fall: Abs, + axis: Axis, +) -> VariantFragment { + // If the base glyph is good enough, use it. + let advance = match axis { + Axis::X => base.width, + Axis::Y => base.height(), + }; + let short_target = target - short_fall; + if short_target <= advance { + return base.into_variant(); + } + + let mut min_overlap = Abs::zero(); + let construction = ctx + .table + .variants + .and_then(|variants| { + min_overlap = variants.min_connector_overlap.scaled(ctx, base.font_size); + match axis { + Axis::X => variants.horizontal_constructions, + Axis::Y => variants.vertical_constructions, + } + .get(base.id) + }) + .unwrap_or(GlyphConstruction { assembly: None, variants: LazyArray16::new(&[]) }); + + // Search for a pre-made variant with a good advance. + let mut best_id = base.id; + let mut best_advance = base.width; + for variant in construction.variants { + best_id = variant.variant_glyph; + best_advance = base.font.to_em(variant.advance_measurement).at(base.font_size); + if short_target <= best_advance { + break; + } + } + + // This is either good or the best we've got. + if short_target <= best_advance || construction.assembly.is_none() { + base.set_id(ctx, best_id); + return base.into_variant(); + } + + // Assemble from parts. + let assembly = construction.assembly.unwrap(); + assemble(ctx, base, assembly, min_overlap, target, axis) +} + +/// Return whether the glyph is stretchable and if it is, along which axis it +/// can be stretched. +fn stretch_axis(ctx: &MathContext, base: &GlyphFragment) -> Option<Axis> { + let base_id = base.id; + let vertical = ctx + .table + .variants + .and_then(|variants| variants.vertical_constructions.get(base_id)) + .map(|_| Axis::Y); + let horizontal = ctx + .table + .variants + .and_then(|variants| variants.horizontal_constructions.get(base_id)) + .map(|_| Axis::X); + + match (vertical, horizontal) { + (vertical, None) => vertical, + (None, horizontal) => horizontal, + _ => { + // As far as we know, there aren't any glyphs that have both + // vertical and horizontal constructions. So for the time being, we + // will assume that a glyph cannot have both. + panic!("glyph {:?} has both vertical and horizontal constructions", base.c); + } + } +} + +/// Assemble a glyph from parts. +fn assemble( + ctx: &MathContext, + base: GlyphFragment, + assembly: GlyphAssembly, + min_overlap: Abs, + target: Abs, + axis: Axis, +) -> VariantFragment { + // Determine the number of times the extenders need to be repeated as well + // as a ratio specifying how much to spread the parts apart + // (0 = maximal overlap, 1 = minimal overlap). + let mut full; + let mut ratio; + let mut repeat = 0; + loop { + full = Abs::zero(); + ratio = 0.0; + + let mut parts = parts(assembly, repeat).peekable(); + let mut growable = Abs::zero(); + + while let Some(part) = parts.next() { + let mut advance = part.full_advance.scaled(ctx, base.font_size); + if let Some(next) = parts.peek() { + let max_overlap = part + .end_connector_length + .min(next.start_connector_length) + .scaled(ctx, base.font_size); + + advance -= max_overlap; + growable += max_overlap - min_overlap; + } + + full += advance; + } + + if full < target { + let delta = target - full; + ratio = (delta / growable).min(1.0); + full += ratio * growable; + } + + if target <= full || repeat >= MAX_REPEATS { + break; + } + + repeat += 1; + } + + let mut selected = vec![]; + let mut parts = parts(assembly, repeat).peekable(); + while let Some(part) = parts.next() { + let mut advance = part.full_advance.scaled(ctx, base.font_size); + if let Some(next) = parts.peek() { + let max_overlap = part + .end_connector_length + .min(next.start_connector_length) + .scaled(ctx, base.font_size); + advance -= max_overlap; + advance += ratio * (max_overlap - min_overlap); + } + + let mut fragment = base.clone(); + fragment.set_id(ctx, part.glyph_id); + selected.push((fragment, advance)); + } + + let size; + let baseline; + match axis { + Axis::X => { + let height = base.ascent + base.descent; + size = Size::new(full, height); + baseline = base.ascent; + } + Axis::Y => { + let axis = ctx.constants.axis_height().scaled(ctx, base.font_size); + let width = selected.iter().map(|(f, _)| f.width).max().unwrap_or_default(); + size = Size::new(width, full); + baseline = full / 2.0 + axis; + } + } + + let mut frame = Frame::soft(size); + let mut offset = Abs::zero(); + frame.set_baseline(baseline); + frame.post_process_raw(base.dests, base.hidden); + + for (fragment, advance) in selected { + let pos = match axis { + Axis::X => Point::new(offset, frame.baseline() - fragment.ascent), + Axis::Y => Point::with_y(full - offset - fragment.height()), + }; + frame.push_frame(pos, fragment.into_frame()); + offset += advance; + } + + let accent_attach = match axis { + Axis::X => frame.width() / 2.0, + Axis::Y => base.accent_attach, + }; + + VariantFragment { + c: base.c, + frame, + font_size: base.font_size, + italics_correction: Abs::zero(), + accent_attach, + class: base.class, + math_size: base.math_size, + span: base.span, + limits: base.limits, + mid_stretched: None, + } +} + +/// Return an iterator over the assembly's parts with extenders repeated the +/// specified number of times. +fn parts(assembly: GlyphAssembly, repeat: usize) -> impl Iterator<Item = GlyphPart> + '_ { + assembly.parts.into_iter().flat_map(move |part| { + let count = if part.part_flags.extender() { repeat } else { 1 }; + std::iter::repeat(part).take(count) + }) +} diff --git a/crates/typst-layout/src/math/text.rs b/crates/typst-layout/src/math/text.rs new file mode 100644 index 00000000..df80b45a --- /dev/null +++ b/crates/typst-layout/src/math/text.rs @@ -0,0 +1,344 @@ +use std::f64::consts::SQRT_2; + +use ecow::{eco_vec, EcoString}; +use typst_library::diag::SourceResult; +use typst_library::foundations::{Packed, StyleChain, StyleVec}; +use typst_library::layout::{Abs, Size}; +use typst_library::math::{EquationElem, MathSize, MathVariant}; +use typst_library::text::{ + BottomEdge, BottomEdgeMetric, TextElem, TextSize, TopEdge, TopEdgeMetric, +}; +use typst_syntax::{is_newline, Span}; +use unicode_math_class::MathClass; +use unicode_segmentation::UnicodeSegmentation; + +use super::{ + scaled_font_size, FrameFragment, GlyphFragment, MathContext, MathFragment, MathRun, +}; + +/// Lays out a [`TextElem`]. +pub fn layout_text( + elem: &Packed<TextElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let text = elem.text(); + let span = elem.span(); + let mut chars = text.chars(); + let math_size = EquationElem::size_in(styles); + + let fragment: MathFragment = if let Some(mut glyph) = chars + .next() + .filter(|_| chars.next().is_none()) + .map(|c| styled_char(styles, c, true)) + .and_then(|c| GlyphFragment::try_new(ctx, styles, c, span)) + { + // A single letter that is available in the math font. + match math_size { + MathSize::Script => { + glyph.make_scriptsize(ctx); + } + MathSize::ScriptScript => { + glyph.make_scriptscriptsize(ctx); + } + _ => (), + } + + if glyph.class == MathClass::Large { + let mut variant = if math_size == MathSize::Display { + let height = scaled!(ctx, styles, display_operator_min_height) + .max(SQRT_2 * glyph.height()); + glyph.stretch_vertical(ctx, height, Abs::zero()) + } else { + glyph.into_variant() + }; + // TeXbook p 155. Large operators are always vertically centered on the axis. + variant.center_on_axis(ctx); + variant.into() + } else { + glyph.into() + } + } else if text.chars().all(|c| c.is_ascii_digit() || c == '.') { + // Numbers aren't that difficult. + let mut fragments = vec![]; + for c in text.chars() { + let c = styled_char(styles, c, false); + fragments.push(GlyphFragment::new(ctx, styles, c, span).into()); + } + let frame = MathRun::new(fragments).into_frame(ctx, styles); + FrameFragment::new(ctx, styles, frame).with_text_like(true).into() + } else { + let local = [ + TextElem::set_top_edge(TopEdge::Metric(TopEdgeMetric::Bounds)), + TextElem::set_bottom_edge(BottomEdge::Metric(BottomEdgeMetric::Bounds)), + TextElem::set_size(TextSize(scaled_font_size(ctx, styles).into())), + ] + .map(|p| p.wrap()); + + // Anything else is handled by Typst's standard text layout. + let styles = styles.chain(&local); + let text: EcoString = + text.chars().map(|c| styled_char(styles, c, false)).collect(); + if text.contains(is_newline) { + let mut fragments = vec![]; + for (i, piece) in text.split(is_newline).enumerate() { + if i != 0 { + fragments.push(MathFragment::Linebreak); + } + if !piece.is_empty() { + fragments.push(layout_complex_text(piece, ctx, span, styles)?.into()); + } + } + let mut frame = MathRun::new(fragments).into_frame(ctx, styles); + let axis = scaled!(ctx, styles, axis_height); + frame.set_baseline(frame.height() / 2.0 + axis); + FrameFragment::new(ctx, styles, frame).into() + } else { + layout_complex_text(&text, ctx, span, styles)?.into() + } + }; + + ctx.push(fragment); + Ok(()) +} + +/// Layout the given text string into a [`FrameFragment`]. +fn layout_complex_text( + text: &str, + ctx: &mut MathContext, + span: Span, + styles: StyleChain, +) -> SourceResult<FrameFragment> { + // There isn't a natural width for a paragraph in a math environment; + // because it will be placed somewhere probably not at the left margin + // it will overflow. So emulate an `hbox` instead and allow the paragraph + // to extend as far as needed. + let spaced = text.graphemes(true).nth(1).is_some(); + let elem = TextElem::packed(text).spanned(span); + let frame = (ctx.engine.routines.layout_inline)( + ctx.engine, + &StyleVec::wrap(eco_vec![elem]), + ctx.locator.next(&span), + styles, + false, + Size::splat(Abs::inf()), + false, + )? + .into_frame(); + + Ok(FrameFragment::new(ctx, styles, frame) + .with_class(MathClass::Alphabetic) + .with_text_like(true) + .with_spaced(spaced)) +} + +/// Select the correct styled math letter. +/// +/// <https://www.w3.org/TR/mathml-core/#new-text-transform-mappings> +/// <https://en.wikipedia.org/wiki/Mathematical_Alphanumeric_Symbols> +fn styled_char(styles: StyleChain, c: char, auto_italic: bool) -> char { + use MathVariant::*; + + let variant = EquationElem::variant_in(styles); + let bold = EquationElem::bold_in(styles); + let italic = EquationElem::italic_in(styles).unwrap_or( + auto_italic + && matches!( + c, + 'a'..='z' | 'ı' | 'ȷ' | 'A'..='Z' | 'α'..='ω' | + '∂' | 'ϵ' | 'ϑ' | 'ϰ' | 'ϕ' | 'ϱ' | 'ϖ' + ) + && matches!(variant, Sans | Serif), + ); + + if let Some(c) = basic_exception(c) { + return c; + } + + if let Some(c) = latin_exception(c, variant, bold, italic) { + return c; + } + + if let Some(c) = greek_exception(c, variant, bold, italic) { + return c; + } + + let base = match c { + 'A'..='Z' => 'A', + 'a'..='z' => 'a', + 'Α'..='Ω' => 'Α', + 'α'..='ω' => 'α', + '0'..='9' => '0', + // Hebrew Alef -> Dalet. + '\u{05D0}'..='\u{05D3}' => '\u{05D0}', + _ => return c, + }; + + let tuple = (variant, bold, italic); + let start = match c { + // Latin upper. + 'A'..='Z' => match tuple { + (Serif, false, false) => 0x0041, + (Serif, true, false) => 0x1D400, + (Serif, false, true) => 0x1D434, + (Serif, true, true) => 0x1D468, + (Sans, false, false) => 0x1D5A0, + (Sans, true, false) => 0x1D5D4, + (Sans, false, true) => 0x1D608, + (Sans, true, true) => 0x1D63C, + (Cal, false, _) => 0x1D49C, + (Cal, true, _) => 0x1D4D0, + (Frak, false, _) => 0x1D504, + (Frak, true, _) => 0x1D56C, + (Mono, _, _) => 0x1D670, + (Bb, _, _) => 0x1D538, + }, + + // Latin lower. + 'a'..='z' => match tuple { + (Serif, false, false) => 0x0061, + (Serif, true, false) => 0x1D41A, + (Serif, false, true) => 0x1D44E, + (Serif, true, true) => 0x1D482, + (Sans, false, false) => 0x1D5BA, + (Sans, true, false) => 0x1D5EE, + (Sans, false, true) => 0x1D622, + (Sans, true, true) => 0x1D656, + (Cal, false, _) => 0x1D4B6, + (Cal, true, _) => 0x1D4EA, + (Frak, false, _) => 0x1D51E, + (Frak, true, _) => 0x1D586, + (Mono, _, _) => 0x1D68A, + (Bb, _, _) => 0x1D552, + }, + + // Greek upper. + 'Α'..='Ω' => match tuple { + (Serif, false, false) => 0x0391, + (Serif, true, false) => 0x1D6A8, + (Serif, false, true) => 0x1D6E2, + (Serif, true, true) => 0x1D71C, + (Sans, _, false) => 0x1D756, + (Sans, _, true) => 0x1D790, + (Cal | Frak | Mono | Bb, _, _) => return c, + }, + + // Greek lower. + 'α'..='ω' => match tuple { + (Serif, false, false) => 0x03B1, + (Serif, true, false) => 0x1D6C2, + (Serif, false, true) => 0x1D6FC, + (Serif, true, true) => 0x1D736, + (Sans, _, false) => 0x1D770, + (Sans, _, true) => 0x1D7AA, + (Cal | Frak | Mono | Bb, _, _) => return c, + }, + + // Hebrew Alef -> Dalet. + '\u{05D0}'..='\u{05D3}' => 0x2135, + + // Numbers. + '0'..='9' => match tuple { + (Serif, false, _) => 0x0030, + (Serif, true, _) => 0x1D7CE, + (Bb, _, _) => 0x1D7D8, + (Sans, false, _) => 0x1D7E2, + (Sans, true, _) => 0x1D7EC, + (Mono, _, _) => 0x1D7F6, + (Cal | Frak, _, _) => return c, + }, + + _ => unreachable!(), + }; + + std::char::from_u32(start + (c as u32 - base as u32)).unwrap() +} + +fn basic_exception(c: char) -> Option<char> { + Some(match c { + '〈' => '⟨', + '〉' => '⟩', + '《' => '⟪', + '》' => '⟫', + _ => return None, + }) +} + +fn latin_exception( + c: char, + variant: MathVariant, + bold: bool, + italic: bool, +) -> Option<char> { + use MathVariant::*; + Some(match (c, variant, bold, italic) { + ('B', Cal, false, _) => 'ℬ', + ('E', Cal, false, _) => 'ℰ', + ('F', Cal, false, _) => 'ℱ', + ('H', Cal, false, _) => 'ℋ', + ('I', Cal, false, _) => 'ℐ', + ('L', Cal, false, _) => 'ℒ', + ('M', Cal, false, _) => 'ℳ', + ('R', Cal, false, _) => 'ℛ', + ('C', Frak, false, _) => 'ℭ', + ('H', Frak, false, _) => 'ℌ', + ('I', Frak, false, _) => 'ℑ', + ('R', Frak, false, _) => 'ℜ', + ('Z', Frak, false, _) => 'ℨ', + ('C', Bb, ..) => 'ℂ', + ('H', Bb, ..) => 'ℍ', + ('N', Bb, ..) => 'ℕ', + ('P', Bb, ..) => 'ℙ', + ('Q', Bb, ..) => 'ℚ', + ('R', Bb, ..) => 'ℝ', + ('Z', Bb, ..) => 'ℤ', + ('D', Bb, _, true) => 'ⅅ', + ('d', Bb, _, true) => 'ⅆ', + ('e', Bb, _, true) => 'ⅇ', + ('i', Bb, _, true) => 'ⅈ', + ('j', Bb, _, true) => 'ⅉ', + ('h', Serif, false, true) => 'ℎ', + ('e', Cal, false, _) => 'ℯ', + ('g', Cal, false, _) => 'ℊ', + ('o', Cal, false, _) => 'ℴ', + ('ı', Serif, .., true) => '𝚤', + ('ȷ', Serif, .., true) => '𝚥', + _ => return None, + }) +} + +fn greek_exception( + c: char, + variant: MathVariant, + bold: bool, + italic: bool, +) -> Option<char> { + use MathVariant::*; + let list = match c { + 'ϴ' => ['𝚹', '𝛳', '𝜭', '𝝧', '𝞡', 'ϴ'], + '∇' => ['𝛁', '𝛻', '𝜵', '𝝯', '𝞩', '∇'], + '∂' => ['𝛛', '𝜕', '𝝏', '𝞉', '𝟃', '∂'], + 'ϵ' => ['𝛜', '𝜖', '𝝐', '𝞊', '𝟄', 'ϵ'], + 'ϑ' => ['𝛝', '𝜗', '𝝑', '𝞋', '𝟅', 'ϑ'], + 'ϰ' => ['𝛞', '𝜘', '𝝒', '𝞌', '𝟆', 'ϰ'], + 'ϕ' => ['𝛟', '𝜙', '𝝓', '𝞍', '𝟇', 'ϕ'], + 'ϱ' => ['𝛠', '𝜚', '𝝔', '𝞎', '𝟈', 'ϱ'], + 'ϖ' => ['𝛡', '𝜛', '𝝕', '𝞏', '𝟉', 'ϖ'], + 'Γ' => ['𝚪', '𝛤', '𝜞', '𝝘', '𝞒', 'ℾ'], + 'γ' => ['𝛄', '𝛾', '𝜸', '𝝲', '𝞬', 'ℽ'], + 'Π' => ['𝚷', '𝛱', '𝜫', '𝝥', '𝞟', 'ℿ'], + 'π' => ['𝛑', '𝜋', '𝝅', '𝝿', '𝞹', 'ℼ'], + '∑' => ['∑', '∑', '∑', '∑', '∑', '⅀'], + _ => return None, + }; + + Some(match (variant, bold, italic) { + (Serif, true, false) => list[0], + (Serif, false, true) => list[1], + (Serif, true, true) => list[2], + (Sans, _, false) => list[3], + (Sans, _, true) => list[4], + (Bb, ..) => list[5], + _ => return None, + }) +} diff --git a/crates/typst-layout/src/math/underover.rs b/crates/typst-layout/src/math/underover.rs new file mode 100644 index 00000000..b1d4825b --- /dev/null +++ b/crates/typst-layout/src/math/underover.rs @@ -0,0 +1,327 @@ +use typst_library::diag::SourceResult; +use typst_library::foundations::{Content, Packed, StyleChain}; +use typst_library::layout::{Abs, Em, FixedAlignment, Frame, FrameItem, Point, Size}; +use typst_library::math::{ + OverbraceElem, OverbracketElem, OverlineElem, OverparenElem, OvershellElem, + UnderbraceElem, UnderbracketElem, UnderlineElem, UnderparenElem, UndershellElem, +}; +use typst_library::text::TextElem; +use typst_library::visualize::{FixedStroke, Geometry}; +use typst_syntax::Span; + +use super::{ + scaled_font_size, stack, style_cramped, style_for_subscript, style_for_superscript, + FrameFragment, GlyphFragment, LeftRightAlternator, MathContext, MathRun, +}; + +const BRACE_GAP: Em = Em::new(0.25); +const BRACKET_GAP: Em = Em::new(0.25); +const PAREN_GAP: Em = Em::new(0.25); +const SHELL_GAP: Em = Em::new(0.25); + +/// A marker to distinguish under- and overlines. +enum Position { + Under, + Over, +} + +/// Lays out an [`UnderlineElem`]. +#[typst_macros::time(name = "math.underline", span = elem.span())] +pub fn layout_underline( + elem: &Packed<UnderlineElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverline(ctx, styles, elem.body(), elem.span(), Position::Under) +} + +/// Lays out an [`OverlineElem`]. +#[typst_macros::time(name = "math.overline", span = elem.span())] +pub fn layout_overline( + elem: &Packed<OverlineElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverline(ctx, styles, elem.body(), elem.span(), Position::Over) +} + +/// Lays out an [`UnderbraceElem`]. +#[typst_macros::time(name = "math.underbrace", span = elem.span())] +pub fn layout_underbrace( + elem: &Packed<UnderbraceElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⏟', + BRACE_GAP, + Position::Under, + elem.span(), + ) +} + +/// Lays out an [`OverbraceElem`]. +#[typst_macros::time(name = "math.overbrace", span = elem.span())] +pub fn layout_overbrace( + elem: &Packed<OverbraceElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⏞', + BRACE_GAP, + Position::Over, + elem.span(), + ) +} + +/// Lays out an [`UnderbracketElem`]. +#[typst_macros::time(name = "math.underbracket", span = elem.span())] +pub fn layout_underbracket( + elem: &Packed<UnderbracketElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⎵', + BRACKET_GAP, + Position::Under, + elem.span(), + ) +} + +/// Lays out an [`OverbracketElem`]. +#[typst_macros::time(name = "math.overbracket", span = elem.span())] +pub fn layout_overbracket( + elem: &Packed<OverbracketElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⎴', + BRACKET_GAP, + Position::Over, + elem.span(), + ) +} + +/// Lays out an [`UnderparenElem`]. +#[typst_macros::time(name = "math.underparen", span = elem.span())] +pub fn layout_underparen( + elem: &Packed<UnderparenElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⏝', + PAREN_GAP, + Position::Under, + elem.span(), + ) +} + +/// Lays out an [`OverparenElem`]. +#[typst_macros::time(name = "math.overparen", span = elem.span())] +pub fn layout_overparen( + elem: &Packed<OverparenElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⏜', + PAREN_GAP, + Position::Over, + elem.span(), + ) +} + +/// Lays out an [`UndershellElem`]. +#[typst_macros::time(name = "math.undershell", span = elem.span())] +pub fn layout_undershell( + elem: &Packed<UndershellElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⏡', + SHELL_GAP, + Position::Under, + elem.span(), + ) +} + +/// Lays out an [`OvershellElem`]. +#[typst_macros::time(name = "math.overshell", span = elem.span())] +pub fn layout_overshell( + elem: &Packed<OvershellElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⏠', + SHELL_GAP, + Position::Over, + elem.span(), + ) +} + +/// layout under- or overlined content. +fn layout_underoverline( + ctx: &mut MathContext, + styles: StyleChain, + body: &Content, + span: Span, + position: Position, +) -> SourceResult<()> { + let (extra_height, content, line_pos, content_pos, baseline, bar_height, line_adjust); + match position { + Position::Under => { + let sep = scaled!(ctx, styles, underbar_extra_descender); + bar_height = scaled!(ctx, styles, underbar_rule_thickness); + let gap = scaled!(ctx, styles, underbar_vertical_gap); + extra_height = sep + bar_height + gap; + + content = ctx.layout_into_fragment(body, styles)?; + + line_pos = Point::with_y(content.height() + gap + bar_height / 2.0); + content_pos = Point::zero(); + baseline = content.ascent(); + line_adjust = -content.italics_correction(); + } + Position::Over => { + let sep = scaled!(ctx, styles, overbar_extra_ascender); + bar_height = scaled!(ctx, styles, overbar_rule_thickness); + let gap = scaled!(ctx, styles, overbar_vertical_gap); + extra_height = sep + bar_height + gap; + + let cramped = style_cramped(); + content = ctx.layout_into_fragment(body, styles.chain(&cramped))?; + + line_pos = Point::with_y(sep + bar_height / 2.0); + content_pos = Point::with_y(extra_height); + baseline = content.ascent() + extra_height; + line_adjust = Abs::zero(); + } + } + + let width = content.width(); + let height = content.height() + extra_height; + let size = Size::new(width, height); + let line_width = width + line_adjust; + + let content_class = content.class(); + let content_is_text_like = content.is_text_like(); + let content_italics_correction = content.italics_correction(); + let mut frame = Frame::soft(size); + frame.set_baseline(baseline); + frame.push_frame(content_pos, content.into_frame()); + frame.push( + line_pos, + FrameItem::Shape( + Geometry::Line(Point::with_x(line_width)).stroked(FixedStroke { + paint: TextElem::fill_in(styles).as_decoration(), + thickness: bar_height, + ..FixedStroke::default() + }), + span, + ), + ); + + ctx.push( + FrameFragment::new(ctx, styles, frame) + .with_class(content_class) + .with_text_like(content_is_text_like) + .with_italics_correction(content_italics_correction), + ); + + Ok(()) +} + +/// Layout an over- or underbrace-like object. +#[allow(clippy::too_many_arguments)] +fn layout_underoverspreader( + ctx: &mut MathContext, + styles: StyleChain, + body: &Content, + annotation: &Option<Content>, + c: char, + gap: Em, + position: Position, + span: Span, +) -> SourceResult<()> { + let font_size = scaled_font_size(ctx, styles); + let gap = gap.at(font_size); + let body = ctx.layout_into_run(body, styles)?; + let body_class = body.class(); + let body = body.into_fragment(ctx, styles); + let glyph = GlyphFragment::new(ctx, styles, c, span); + let stretched = glyph.stretch_horizontal(ctx, body.width(), Abs::zero()); + + let mut rows = vec![]; + let baseline = match position { + Position::Under => { + rows.push(MathRun::new(vec![body])); + rows.push(stretched.into()); + if let Some(annotation) = annotation { + let under_style = style_for_subscript(styles); + let annotation_styles = styles.chain(&under_style); + rows.push(ctx.layout_into_run(annotation, annotation_styles)?); + } + 0 + } + Position::Over => { + if let Some(annotation) = annotation { + let over_style = style_for_superscript(styles); + let annotation_styles = styles.chain(&over_style); + rows.push(ctx.layout_into_run(annotation, annotation_styles)?); + } + rows.push(stretched.into()); + rows.push(MathRun::new(vec![body])); + rows.len() - 1 + } + }; + + let frame = stack( + rows, + FixedAlignment::Center, + gap, + baseline, + LeftRightAlternator::Right, + None, + ); + ctx.push(FrameFragment::new(ctx, styles, frame).with_class(body_class)); + + Ok(()) +} diff --git a/crates/typst-layout/src/pad.rs b/crates/typst-layout/src/pad.rs new file mode 100644 index 00000000..00badcdb --- /dev/null +++ b/crates/typst-layout/src/pad.rs @@ -0,0 +1,93 @@ +use typst_library::diag::SourceResult; +use typst_library::engine::Engine; +use typst_library::foundations::{Packed, Resolve, StyleChain}; +use typst_library::introspection::Locator; +use typst_library::layout::{ + Abs, Fragment, Frame, PadElem, Point, Regions, Rel, Sides, Size, +}; + +/// Layout the padded content. +#[typst_macros::time(span = elem.span())] +pub fn layout_pad( + elem: &Packed<PadElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + regions: Regions, +) -> SourceResult<Fragment> { + let padding = Sides::new( + elem.left(styles).resolve(styles), + elem.top(styles).resolve(styles), + elem.right(styles).resolve(styles), + elem.bottom(styles).resolve(styles), + ); + + let mut backlog = vec![]; + let pod = regions.map(&mut backlog, |size| shrink(size, &padding)); + + // Layout child into padded regions. + let mut fragment = crate::layout_fragment(engine, &elem.body, locator, styles, pod)?; + + for frame in &mut fragment { + grow(frame, &padding); + } + + Ok(fragment) +} + +/// Shrink a region size by an inset relative to the size itself. +pub fn shrink(size: Size, inset: &Sides<Rel<Abs>>) -> Size { + size - inset.sum_by_axis().relative_to(size) +} + +/// Shrink the components of possibly multiple `Regions` by an inset relative to +/// the regions themselves. +pub fn shrink_multiple( + size: &mut Size, + full: &mut Abs, + backlog: &mut [Abs], + last: &mut Option<Abs>, + inset: &Sides<Rel<Abs>>, +) { + let summed = inset.sum_by_axis(); + *size -= summed.relative_to(*size); + *full -= summed.y.relative_to(*full); + for item in backlog { + *item -= summed.y.relative_to(*item); + } + *last = last.map(|v| v - summed.y.relative_to(v)); +} + +/// Grow a frame's size by an inset relative to the grown size. +/// This is the inverse operation to `shrink()`. +/// +/// For the horizontal axis the derivation looks as follows. +/// (Vertical axis is analogous.) +/// +/// Let w be the grown target width, +/// s be the given width, +/// l be the left inset, +/// r be the right inset, +/// p = l + r. +/// +/// We want that: w - l.resolve(w) - r.resolve(w) = s +/// +/// Thus: w - l.resolve(w) - r.resolve(w) = s +/// <=> w - p.resolve(w) = s +/// <=> w - p.rel * w - p.abs = s +/// <=> (1 - p.rel) * w = s + p.abs +/// <=> w = (s + p.abs) / (1 - p.rel) +pub fn grow(frame: &mut Frame, inset: &Sides<Rel<Abs>>) { + // Apply the padding inversely such that the grown size padded + // yields the frame's size. + let padded = frame + .size() + .zip_map(inset.sum_by_axis(), |s, p| (s + p.abs) / (1.0 - p.rel.get())); + + let inset = inset.relative_to(padded); + let offset = Point::new(inset.left, inset.top); + + // Grow the frame and translate everything in the frame inwards. + frame.set_size(padded); + frame.translate(offset); +} diff --git a/crates/typst-layout/src/pages/collect.rs b/crates/typst-layout/src/pages/collect.rs new file mode 100644 index 00000000..1903d6ac --- /dev/null +++ b/crates/typst-layout/src/pages/collect.rs @@ -0,0 +1,165 @@ +use std::collections::HashSet; + +use typst_library::foundations::StyleChain; +use typst_library::introspection::{Locator, SplitLocator, Tag, TagElem}; +use typst_library::layout::{PagebreakElem, Parity}; +use typst_library::routines::Pair; + +/// An item in page layout. +pub enum Item<'a> { + /// A page run containing content. All runs will be layouted in parallel. + Run(&'a [Pair<'a>], StyleChain<'a>, Locator<'a>), + /// Tags in between pages. These will be prepended to the first start of + /// the next page, or appended at the very end of the final page if there is + /// no next page. + Tags(&'a [Pair<'a>]), + /// An instruction to possibly add a page to bring the page number parity to + /// the desired state. Can only be done at the end, sequentially, because it + /// requires knowledge of the concrete page number. + Parity(Parity, StyleChain<'a>, Locator<'a>), +} + +/// Slices up the children into logical parts, processing styles and handling +/// things like tags and weak pagebreaks. +pub fn collect<'a>( + mut children: &'a mut [Pair<'a>], + mut locator: SplitLocator<'a>, + mut initial: StyleChain<'a>, +) -> Vec<Item<'a>> { + // The collected page-level items. + let mut items: Vec<Item<'a>> = vec![]; + // When this is true, an empty page should be added to `pages` at the end. + let mut staged_empty_page = true; + + // The `children` are a flat list of flow-level items and pagebreaks. This + // loops splits it up into pagebreaks and consecutive slices of + // non-pagebreaks. From these pieces, we build page items that we can then + // layout in parallel. + while let Some(&(elem, styles)) = children.first() { + if let Some(pagebreak) = elem.to_packed::<PagebreakElem>() { + // Add a blank page if we encounter a strong pagebreak and there was + // a staged empty page. + let strong = !pagebreak.weak(styles); + if strong && staged_empty_page { + let locator = locator.next(&elem.span()); + items.push(Item::Run(&[], initial, locator)); + } + + // Add an instruction to adjust the page parity if requested. + if let Some(parity) = pagebreak.to(styles) { + let locator = locator.next(&elem.span()); + items.push(Item::Parity(parity, styles, locator)); + } + + // The initial styles for the next page are ours unless this is a + // "boundary" pagebreak. Such a pagebreak is generated at the end of + // the scope of a page set rule to ensure a page boundary. It's + // styles correspond to the styles _before_ the page set rule, so we + // don't want to apply it to a potential empty page. + if !pagebreak.boundary(styles) { + initial = styles; + } + + // Stage an empty page after a strong pagebreak. + staged_empty_page |= strong; + + // Advance to the next child. + children = &mut children[1..]; + } else { + // Find the end of the consecutive non-pagebreak run. + let end = + children.iter().take_while(|(c, _)| !c.is::<PagebreakElem>()).count(); + + // Migrate start tags without accompanying end tags from before a + // pagebreak to after it. + let end = migrate_unterminated_tags(children, end); + if end == 0 { + continue; + } + + // Advance to the rest of the children. + let (group, rest) = children.split_at_mut(end); + children = rest; + + // If all that is left now are tags, then we don't want to add a + // page just for them (since no group would have been detected in a + // tagless layout and tags should never affect the layout). For this + // reason, we remember them in a `PageItem::Tags` and later insert + // them at the _very start_ of the next page, even before the + // header. + // + // We don't do this if all that's left is end boundary pagebreaks + // and if an empty page is still staged, since then we can just + // conceptually replace that final page with us. + if group.iter().all(|(c, _)| c.is::<TagElem>()) + && !(staged_empty_page + && children.iter().all(|&(c, s)| { + c.to_packed::<PagebreakElem>().is_some_and(|c| c.boundary(s)) + })) + { + items.push(Item::Tags(group)); + continue; + } + + // Record a page run and then disregard a staged empty page because + // we have real content now. + let locator = locator.next(&elem.span()); + items.push(Item::Run(group, initial, locator)); + staged_empty_page = false; + } + } + + // Flush a staged empty page. + if staged_empty_page { + items.push(Item::Run(&[], initial, locator.next(&()))); + } + + items +} + +/// Migrates trailing start tags without accompanying end tags from before +/// a pagebreak to after it. Returns the position right after the last +/// non-migrated tag. +/// +/// This is important because we want the positions of introspectable elements +/// that technically started before a pagebreak, but have no visible content +/// yet, to be after the pagebreak. A typical case where this happens is `show +/// heading: it => pagebreak() + it`. +fn migrate_unterminated_tags(children: &mut [Pair], mid: usize) -> usize { + // Compute the range from before the first trailing tag to after the last + // following pagebreak. + let (before, after) = children.split_at(mid); + let start = mid - before.iter().rev().take_while(|&(c, _)| c.is::<TagElem>()).count(); + let end = mid + after.iter().take_while(|&(c, _)| c.is::<PagebreakElem>()).count(); + + // Determine the set of tag locations which we won't migrate (because they + // are terminated). + let excluded: HashSet<_> = children[start..mid] + .iter() + .filter_map(|(c, _)| match c.to_packed::<TagElem>()?.tag { + Tag::Start(_) => None, + Tag::End(loc, _) => Some(loc), + }) + .collect(); + + // A key function that partitions the area of interest into three groups: + // Excluded tags (-1) | Pagebreaks (0) | Migrated tags (1). + let key = |(c, _): &Pair| match c.to_packed::<TagElem>() { + Some(elem) => { + if excluded.contains(&elem.tag.location()) { + -1 + } else { + 1 + } + } + None => 0, + }; + + // Partition the children using a *stable* sort. While it would be possible + // to write a more efficient direct algorithm for this, the sort version is + // less likely to have bugs and this is absolutely not on a hot path. + children[start..end].sort_by_key(key); + + // Compute the new end index, right before the pagebreaks. + start + children[start..end].iter().take_while(|pair| key(pair) == -1).count() +} diff --git a/crates/typst-layout/src/pages/finalize.rs b/crates/typst-layout/src/pages/finalize.rs new file mode 100644 index 00000000..8df6cb16 --- /dev/null +++ b/crates/typst-layout/src/pages/finalize.rs @@ -0,0 +1,73 @@ +use typst_library::diag::SourceResult; +use typst_library::engine::Engine; +use typst_library::introspection::{ManualPageCounter, Tag}; +use typst_library::layout::{Frame, FrameItem, Page, Point}; + +use super::LayoutedPage; + +/// Piece together the inner page frame and the marginals. We can only do this +/// at the very end because inside/outside margins require knowledge of the +/// physical page number, which is unknown during parallel layout. +pub fn finalize( + engine: &mut Engine, + counter: &mut ManualPageCounter, + tags: &mut Vec<Tag>, + LayoutedPage { + inner, + mut margin, + binding, + two_sided, + header, + footer, + background, + foreground, + fill, + numbering, + }: LayoutedPage, +) -> SourceResult<Page> { + // If two sided, left becomes inside and right becomes outside. + // Thus, for left-bound pages, we want to swap on even pages and + // for right-bound pages, we want to swap on odd pages. + if two_sided && binding.swap(counter.physical()) { + std::mem::swap(&mut margin.left, &mut margin.right); + } + + // Create a frame for the full page. + let mut frame = Frame::hard(inner.size() + margin.sum_by_axis()); + + // Add tags. + for tag in tags.drain(..) { + frame.push(Point::zero(), FrameItem::Tag(tag)); + } + + // Add the "before" marginals. The order in which we push things here is + // important as it affects the relative ordering of introspectable elements + // and thus how counters resolve. + if let Some(background) = background { + frame.push_frame(Point::zero(), background); + } + if let Some(header) = header { + frame.push_frame(Point::with_x(margin.left), header); + } + + // Add the inner contents. + frame.push_frame(Point::new(margin.left, margin.top), inner); + + // Add the "after" marginals. + if let Some(footer) = footer { + let y = frame.height() - footer.height(); + frame.push_frame(Point::new(margin.left, y), footer); + } + if let Some(foreground) = foreground { + frame.push_frame(Point::zero(), foreground); + } + + // Apply counter updates from within the page to the manual page counter. + counter.visit(engine, &frame)?; + + // Get this page's number and then bump the counter for the next page. + let number = counter.logical(); + counter.step(); + + Ok(Page { frame, fill, numbering, number }) +} diff --git a/crates/typst-layout/src/pages/mod.rs b/crates/typst-layout/src/pages/mod.rs new file mode 100644 index 00000000..b969749a --- /dev/null +++ b/crates/typst-layout/src/pages/mod.rs @@ -0,0 +1,159 @@ +//! Layout of content into a [`Document`]. + +mod collect; +mod finalize; +mod run; + +use comemo::{Tracked, TrackedMut}; +use typst_library::diag::SourceResult; +use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::foundations::{Content, StyleChain}; +use typst_library::introspection::{ + Introspector, Locator, ManualPageCounter, SplitLocator, TagElem, +}; +use typst_library::layout::{FrameItem, Page, Point}; +use typst_library::model::{Document, DocumentInfo}; +use typst_library::routines::{Arenas, Pair, RealizationKind, Routines}; +use typst_library::World; + +use self::collect::{collect, Item}; +use self::finalize::finalize; +use self::run::{layout_blank_page, layout_page_run, LayoutedPage}; + +/// Layout content into a document. +/// +/// This first performs root-level realization and then lays out the resulting +/// elements. In contrast to [`layout_fragment`](crate::layout_fragment), +/// this does not take regions since the regions are defined by the page +/// configuration in the content and style chain. +#[typst_macros::time(name = "document")] +pub fn layout_document( + engine: &mut Engine, + content: &Content, + styles: StyleChain, +) -> SourceResult<Document> { + layout_document_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + content, + styles, + ) +} + +/// The internal implementation of `layout_document`. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn layout_document_impl( + routines: &Routines, + world: Tracked<dyn World + '_>, + introspector: Tracked<Introspector>, + traced: Tracked<Traced>, + sink: TrackedMut<Sink>, + route: Tracked<Route>, + content: &Content, + styles: StyleChain, +) -> SourceResult<Document> { + let mut locator = Locator::root().split(); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route).unnested(), + }; + + // Mark the external styles as "outside" so that they are valid at the page + // level. + let styles = styles.to_map().outside(); + let styles = StyleChain::new(&styles); + + let arenas = Arenas::default(); + let mut info = DocumentInfo::default(); + let mut children = (engine.routines.realize)( + RealizationKind::Root(&mut info), + &mut engine, + &mut locator, + &arenas, + content, + styles, + )?; + + let pages = layout_pages(&mut engine, &mut children, locator, styles)?; + let introspector = Introspector::new(&pages); + + Ok(Document { pages, info, introspector }) +} + +/// Layouts the document's pages. +fn layout_pages<'a>( + engine: &mut Engine, + children: &'a mut [Pair<'a>], + locator: SplitLocator<'a>, + styles: StyleChain<'a>, +) -> SourceResult<Vec<Page>> { + // Slice up the children into logical parts. + let items = collect(children, locator, styles); + + // Layout the page runs in parallel. + let mut runs = engine.parallelize( + items.iter().filter_map(|item| match item { + Item::Run(children, initial, locator) => { + Some((children, initial, locator.relayout())) + } + _ => None, + }), + |engine, (children, initial, locator)| { + layout_page_run(engine, children, locator, *initial) + }, + ); + + let mut pages = vec![]; + let mut tags = vec![]; + let mut counter = ManualPageCounter::new(); + + // Collect and finalize the runs, handling things like page parity and tags + // between pages. + for item in &items { + match item { + Item::Run(..) => { + let layouted = runs.next().unwrap()?; + for layouted in layouted { + let page = finalize(engine, &mut counter, &mut tags, layouted)?; + pages.push(page); + } + } + Item::Parity(parity, initial, locator) => { + if !parity.matches(pages.len()) { + continue; + } + + let layouted = layout_blank_page(engine, locator.relayout(), *initial)?; + let page = finalize(engine, &mut counter, &mut tags, layouted)?; + pages.push(page); + } + Item::Tags(items) => { + tags.extend( + items + .iter() + .filter_map(|(c, _)| c.to_packed::<TagElem>()) + .map(|elem| elem.tag.clone()), + ); + } + } + } + + // Add the remaining tags to the very end of the last page. + if !tags.is_empty() { + let last = pages.last_mut().unwrap(); + let pos = Point::with_y(last.frame.height()); + last.frame + .push_multiple(tags.into_iter().map(|tag| (pos, FrameItem::Tag(tag)))); + } + + Ok(pages) +} diff --git a/crates/typst-layout/src/pages/run.rs b/crates/typst-layout/src/pages/run.rs new file mode 100644 index 00000000..15735faa --- /dev/null +++ b/crates/typst-layout/src/pages/run.rs @@ -0,0 +1,267 @@ +use comemo::{Track, Tracked, TrackedMut}; +use typst_library::diag::SourceResult; +use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::foundations::{ + Content, NativeElement, Resolve, Smart, StyleChain, Styles, +}; +use typst_library::introspection::{ + Counter, CounterDisplayElem, CounterKey, Introspector, Locator, LocatorLink, TagElem, +}; +use typst_library::layout::{ + Abs, AlignElem, Alignment, Axes, Binding, ColumnsElem, Dir, Frame, HAlignment, + Length, OuterVAlignment, PageElem, Paper, Region, Regions, Rel, Sides, Size, + VAlignment, +}; +use typst_library::model::Numbering; +use typst_library::routines::{Pair, Routines}; +use typst_library::text::TextElem; +use typst_library::visualize::Paint; +use typst_library::World; +use typst_utils::Numeric; + +use crate::flow::layout_flow; + +/// A mostly finished layout for one page. Needs only knowledge of its exact +/// page number to be finalized into a `Page`. (Because the margins can depend +/// on the page number.) +#[derive(Clone)] +pub struct LayoutedPage { + pub inner: Frame, + pub margin: Sides<Abs>, + pub binding: Binding, + pub two_sided: bool, + pub header: Option<Frame>, + pub footer: Option<Frame>, + pub background: Option<Frame>, + pub foreground: Option<Frame>, + pub fill: Smart<Option<Paint>>, + pub numbering: Option<Numbering>, +} + +/// Layout a single page suitable for parity adjustment. +pub fn layout_blank_page( + engine: &mut Engine, + locator: Locator, + initial: StyleChain, +) -> SourceResult<LayoutedPage> { + let layouted = layout_page_run(engine, &[], locator, initial)?; + Ok(layouted.into_iter().next().unwrap()) +} + +/// Layout a page run with uniform properties. +#[typst_macros::time(name = "page run")] +pub fn layout_page_run( + engine: &mut Engine, + children: &[Pair], + locator: Locator, + initial: StyleChain, +) -> SourceResult<Vec<LayoutedPage>> { + layout_page_run_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + children, + locator.track(), + initial, + ) +} + +/// The internal implementation of `layout_page_run`. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn layout_page_run_impl( + routines: &Routines, + world: Tracked<dyn World + '_>, + introspector: Tracked<Introspector>, + traced: Tracked<Traced>, + sink: TrackedMut<Sink>, + route: Tracked<Route>, + children: &[Pair], + locator: Tracked<Locator>, + initial: StyleChain, +) -> SourceResult<Vec<LayoutedPage>> { + let link = LocatorLink::new(locator); + let mut locator = Locator::link(&link).split(); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route), + }; + + // Determine the page-wide styles. + let styles = determine_page_styles(children, initial); + let styles = StyleChain::new(&styles); + + // When one of the lengths is infinite the page fits its content along + // that axis. + let width = PageElem::width_in(styles).unwrap_or(Abs::inf()); + let height = PageElem::height_in(styles).unwrap_or(Abs::inf()); + let mut size = Size::new(width, height); + if PageElem::flipped_in(styles) { + std::mem::swap(&mut size.x, &mut size.y); + } + + let mut min = width.min(height); + if !min.is_finite() { + min = Paper::A4.width(); + } + + // Determine the margins. + let default = Rel::<Length>::from((2.5 / 21.0) * min); + let margin = PageElem::margin_in(styles); + let two_sided = margin.two_sided.unwrap_or(false); + let margin = margin + .sides + .map(|side| side.and_then(Smart::custom).unwrap_or(default)) + .resolve(styles) + .relative_to(size); + + let fill = PageElem::fill_in(styles); + let foreground = PageElem::foreground_in(styles); + let background = PageElem::background_in(styles); + let header_ascent = PageElem::header_ascent_in(styles).relative_to(margin.top); + let footer_descent = PageElem::footer_descent_in(styles).relative_to(margin.bottom); + let numbering = PageElem::numbering_in(styles); + let number_align = PageElem::number_align_in(styles); + let binding = + PageElem::binding_in(styles).unwrap_or_else(|| match TextElem::dir_in(styles) { + Dir::LTR => Binding::Left, + _ => Binding::Right, + }); + + // Construct the numbering (for header or footer). + let numbering_marginal = numbering.as_ref().map(|numbering| { + let both = match numbering { + Numbering::Pattern(pattern) => pattern.pieces() >= 2, + Numbering::Func(_) => true, + }; + + let mut counter = CounterDisplayElem::new( + Counter::new(CounterKey::Page), + Smart::Custom(numbering.clone()), + both, + ) + .pack(); + + // We interpret the Y alignment as selecting header or footer + // and then ignore it for aligning the actual number. + if let Some(x) = number_align.x() { + counter = counter.aligned(x.into()); + } + + counter + }); + + let header = PageElem::header_in(styles); + let footer = PageElem::footer_in(styles); + let (header, footer) = if matches!(number_align.y(), Some(OuterVAlignment::Top)) { + (header.as_ref().unwrap_or(&numbering_marginal), footer.as_ref().unwrap_or(&None)) + } else { + (header.as_ref().unwrap_or(&None), footer.as_ref().unwrap_or(&numbering_marginal)) + }; + + // Layout the children. + let area = size - margin.sum_by_axis(); + let fragment = layout_flow( + &mut engine, + children, + &mut locator, + styles, + Regions::repeat(area, area.map(Abs::is_finite)), + PageElem::columns_in(styles), + ColumnsElem::gutter_in(styles), + true, + )?; + + // Layouts a single marginal. + let mut layout_marginal = |content: &Option<Content>, area, align| { + let Some(content) = content else { return Ok(None) }; + let aligned = content.clone().styled(AlignElem::set_alignment(align)); + crate::layout_frame( + &mut engine, + &aligned, + locator.next(&content.span()), + styles, + Region::new(area, Axes::splat(true)), + ) + .map(Some) + }; + + // Layout marginals. + let mut layouted = Vec::with_capacity(fragment.len()); + for inner in fragment { + let header_size = Size::new(inner.width(), margin.top - header_ascent); + let footer_size = Size::new(inner.width(), margin.bottom - footer_descent); + let full_size = inner.size() + margin.sum_by_axis(); + let mid = HAlignment::Center + VAlignment::Horizon; + layouted.push(LayoutedPage { + inner, + fill: fill.clone(), + numbering: numbering.clone(), + header: layout_marginal(header, header_size, Alignment::BOTTOM)?, + footer: layout_marginal(footer, footer_size, Alignment::TOP)?, + background: layout_marginal(background, full_size, mid)?, + foreground: layout_marginal(foreground, full_size, mid)?, + margin, + binding, + two_sided, + }); + } + + Ok(layouted) +} + +/// Determines the styles used for a page run itself and page-level content like +/// marginals and footnotes. +/// +/// As a base, we collect the styles that are shared by all elements on the page +/// run. As a fallback if there are no elements, we use the styles active at the +/// pagebreak that introduced the page (at the very start, we use the default +/// styles). Then, to produce our page styles, we filter this list of styles +/// according to a few rules: +/// +/// - Other styles are only kept if they are `outside && (initial || liftable)`. +/// - "Outside" means they were not produced within a show rule or that the +/// show rule "broke free" to the page level by emitting page styles. +/// - "Initial" means they were active at the pagebreak that introduced the +/// page. Since these are intuitively already active, they should be kept even +/// if not liftable. (E.g. `text(red, page(..)`) makes the footer red.) +/// - "Liftable" means they can be lifted to the page-level even though they +/// weren't yet active at the very beginning. Set rule styles are liftable as +/// opposed to direct constructor calls: +/// - For `set page(..); set text(red)` the red text is kept even though it +/// comes after the weak pagebreak from set page. +/// - For `set page(..); text(red)[..]` the red isn't kept because the +/// constructor styles are not liftable. +fn determine_page_styles(children: &[Pair], initial: StyleChain) -> Styles { + // Determine the shared styles (excluding tags). + let tagless = children.iter().filter(|(c, _)| !c.is::<TagElem>()).map(|&(_, s)| s); + let base = StyleChain::trunk(tagless).unwrap_or(initial).to_map(); + + // Determine the initial styles that are also shared by everything. We can't + // use `StyleChain::trunk` because it currently doesn't deal with partially + // shared links (where a subslice matches). + let trunk_len = initial + .to_map() + .as_slice() + .iter() + .zip(base.as_slice()) + .take_while(|&(a, b)| a == b) + .count(); + + // Filter the base styles according to our rules. + base.into_iter() + .enumerate() + .filter(|(i, style)| { + let initial = *i < trunk_len; + style.outside() && (initial || style.liftable()) + }) + .map(|(_, style)| style) + .collect() +} diff --git a/crates/typst-layout/src/repeat.rs b/crates/typst-layout/src/repeat.rs new file mode 100644 index 00000000..b761438c --- /dev/null +++ b/crates/typst-layout/src/repeat.rs @@ -0,0 +1,60 @@ +use typst_library::diag::{bail, SourceResult}; +use typst_library::engine::Engine; +use typst_library::foundations::{Packed, Resolve, StyleChain}; +use typst_library::introspection::Locator; +use typst_library::layout::{ + Abs, AlignElem, Axes, Frame, Point, Region, RepeatElem, Size, +}; +use typst_utils::Numeric; + +/// Layout the repeated content. +#[typst_macros::time(span = elem.span())] +pub fn layout_repeat( + elem: &Packed<RepeatElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + let pod = Region::new(region.size, Axes::new(false, false)); + let piece = crate::layout_frame(engine, &elem.body, locator, styles, pod)?; + let size = Size::new(region.size.x, piece.height()); + + if !size.is_finite() { + bail!(elem.span(), "repeat with no size restrictions"); + } + + let mut frame = Frame::soft(size); + if piece.has_baseline() { + frame.set_baseline(piece.baseline()); + } + + let mut gap = elem.gap(styles).resolve(styles); + let fill = region.size.x; + let width = piece.width(); + + // count * width + (count - 1) * gap = fill, but count is an integer so + // we need to round down and get the remainder. + let count = ((fill + gap) / (width + gap)).floor(); + let remaining = (fill + gap) % (width + gap); + + let justify = elem.justify(styles); + if justify { + gap += remaining / (count - 1.0); + } + + let align = AlignElem::alignment_in(styles).resolve(styles); + let mut offset = Abs::zero(); + if count == 1.0 || !justify { + offset += align.x.position(remaining); + } + + if width > Abs::zero() { + for _ in 0..(count as usize).min(1000) { + frame.push_frame(Point::with_x(offset), piece.clone()); + offset += piece.width() + gap; + } + } + + Ok(frame) +} diff --git a/crates/typst-layout/src/shapes.rs b/crates/typst-layout/src/shapes.rs new file mode 100644 index 00000000..31f8c42b --- /dev/null +++ b/crates/typst-layout/src/shapes.rs @@ -0,0 +1,1028 @@ +use std::f64::consts::SQRT_2; + +use kurbo::ParamCurveExtrema; +use typst_library::diag::{bail, SourceResult}; +use typst_library::engine::Engine; +use typst_library::foundations::{Content, Packed, Resolve, Smart, StyleChain}; +use typst_library::introspection::Locator; +use typst_library::layout::{ + Abs, Axes, Corner, Corners, Frame, FrameItem, Length, Point, Ratio, Region, Rel, + Sides, Size, +}; +use typst_library::visualize::{ + CircleElem, EllipseElem, FillRule, FixedStroke, Geometry, LineElem, Paint, Path, + PathElem, PathVertex, PolygonElem, RectElem, Shape, SquareElem, Stroke, +}; +use typst_syntax::Span; +use typst_utils::{Get, Numeric}; + +/// Layout the line. +#[typst_macros::time(span = elem.span())] +pub fn layout_line( + elem: &Packed<LineElem>, + _: &mut Engine, + _: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + let resolve = |axes: Axes<Rel<Abs>>| axes.zip_map(region.size, Rel::relative_to); + let start = resolve(elem.start(styles)); + let delta = elem.end(styles).map(|end| resolve(end) - start).unwrap_or_else(|| { + let length = elem.length(styles); + let angle = elem.angle(styles); + let x = angle.cos() * length; + let y = angle.sin() * length; + resolve(Axes::new(x, y)) + }); + + let stroke = elem.stroke(styles).unwrap_or_default(); + let size = start.max(start + delta).max(Size::zero()); + + if !size.is_finite() { + bail!(elem.span(), "cannot create line with infinite length"); + } + + let mut frame = Frame::soft(size); + let shape = Geometry::Line(delta.to_point()).stroked(stroke); + frame.push(start.to_point(), FrameItem::Shape(shape, elem.span())); + Ok(frame) +} + +/// Layout the path. +#[typst_macros::time(span = elem.span())] +pub fn layout_path( + elem: &Packed<PathElem>, + _: &mut Engine, + _: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + let resolve = |axes: Axes<Rel<Length>>| { + axes.resolve(styles).zip_map(region.size, Rel::relative_to).to_point() + }; + + let vertices = elem.vertices(); + let points: Vec<Point> = vertices.iter().map(|c| resolve(c.vertex())).collect(); + + let mut size = Size::zero(); + if points.is_empty() { + return Ok(Frame::soft(size)); + } + + // Only create a path if there are more than zero points. + // Construct a closed path given all points. + let mut path = Path::new(); + path.move_to(points[0]); + + let mut add_cubic = |from_point: Point, + to_point: Point, + from: PathVertex, + to: PathVertex| { + let from_control_point = resolve(from.control_point_from()) + from_point; + let to_control_point = resolve(to.control_point_to()) + to_point; + path.cubic_to(from_control_point, to_control_point, to_point); + + let p0 = kurbo::Point::new(from_point.x.to_raw(), from_point.y.to_raw()); + let p1 = kurbo::Point::new( + from_control_point.x.to_raw(), + from_control_point.y.to_raw(), + ); + let p2 = + kurbo::Point::new(to_control_point.x.to_raw(), to_control_point.y.to_raw()); + let p3 = kurbo::Point::new(to_point.x.to_raw(), to_point.y.to_raw()); + let extrema = kurbo::CubicBez::new(p0, p1, p2, p3).bounding_box(); + size.x.set_max(Abs::raw(extrema.x1)); + size.y.set_max(Abs::raw(extrema.y1)); + }; + + for (vertex_window, point_window) in vertices.windows(2).zip(points.windows(2)) { + let from = vertex_window[0]; + let to = vertex_window[1]; + let from_point = point_window[0]; + let to_point = point_window[1]; + + add_cubic(from_point, to_point, from, to); + } + + if elem.closed(styles) { + let from = *vertices.last().unwrap(); // We checked that we have at least one element. + let to = vertices[0]; + let from_point = *points.last().unwrap(); + let to_point = points[0]; + + add_cubic(from_point, to_point, from, to); + path.close_path(); + } + + // Prepare fill and stroke. + let fill = elem.fill(styles); + let fill_rule = elem.fill_rule(styles); + let stroke = match elem.stroke(styles) { + Smart::Auto if fill.is_none() => Some(FixedStroke::default()), + Smart::Auto => None, + Smart::Custom(stroke) => stroke.map(Stroke::unwrap_or_default), + }; + + let mut frame = Frame::soft(size); + let shape = Shape { + geometry: Geometry::Path(path), + stroke, + fill, + fill_rule, + }; + frame.push(Point::zero(), FrameItem::Shape(shape, elem.span())); + Ok(frame) +} + +/// Layout the polygon. +#[typst_macros::time(span = elem.span())] +pub fn layout_polygon( + elem: &Packed<PolygonElem>, + _: &mut Engine, + _: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + let points: Vec<Point> = elem + .vertices() + .iter() + .map(|c| c.resolve(styles).zip_map(region.size, Rel::relative_to).to_point()) + .collect(); + + let size = points.iter().fold(Point::zero(), |max, c| c.max(max)).to_size(); + if !size.is_finite() { + bail!(elem.span(), "cannot create polygon with infinite size"); + } + + let mut frame = Frame::hard(size); + + // Only create a path if there are more than zero points. + if points.is_empty() { + return Ok(frame); + } + + // Prepare fill and stroke. + let fill = elem.fill(styles); + let fill_rule = elem.fill_rule(styles); + let stroke = match elem.stroke(styles) { + Smart::Auto if fill.is_none() => Some(FixedStroke::default()), + Smart::Auto => None, + Smart::Custom(stroke) => stroke.map(Stroke::unwrap_or_default), + }; + + // Construct a closed path given all points. + let mut path = Path::new(); + path.move_to(points[0]); + for &point in &points[1..] { + path.line_to(point); + } + path.close_path(); + + let shape = Shape { + geometry: Geometry::Path(path), + stroke, + fill, + fill_rule, + }; + frame.push(Point::zero(), FrameItem::Shape(shape, elem.span())); + Ok(frame) +} + +/// Lay out the rectangle. +#[typst_macros::time(span = elem.span())] +pub fn layout_rect( + elem: &Packed<RectElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + layout_shape( + engine, + locator, + styles, + region, + ShapeKind::Rect, + elem.body(styles), + elem.fill(styles), + elem.stroke(styles), + elem.inset(styles), + elem.outset(styles), + elem.radius(styles), + elem.span(), + ) +} + +/// Lay out the square. +#[typst_macros::time(span = elem.span())] +pub fn layout_square( + elem: &Packed<SquareElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + layout_shape( + engine, + locator, + styles, + region, + ShapeKind::Square, + elem.body(styles), + elem.fill(styles), + elem.stroke(styles), + elem.inset(styles), + elem.outset(styles), + elem.radius(styles), + elem.span(), + ) +} + +/// Lay out the ellipse. +#[typst_macros::time(span = elem.span())] +pub fn layout_ellipse( + elem: &Packed<EllipseElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + layout_shape( + engine, + locator, + styles, + region, + ShapeKind::Ellipse, + elem.body(styles), + elem.fill(styles), + elem.stroke(styles).map(|s| Sides::splat(Some(s))), + elem.inset(styles), + elem.outset(styles), + Corners::splat(None), + elem.span(), + ) +} + +/// Lay out the circle. +#[typst_macros::time(span = elem.span())] +pub fn layout_circle( + elem: &Packed<CircleElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + layout_shape( + engine, + locator, + styles, + region, + ShapeKind::Circle, + elem.body(styles), + elem.fill(styles), + elem.stroke(styles).map(|s| Sides::splat(Some(s))), + elem.inset(styles), + elem.outset(styles), + Corners::splat(None), + elem.span(), + ) +} + +/// A category of shape. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +enum ShapeKind { + /// A rectangle with equal side lengths. + Square, + /// A quadrilateral with four right angles. + Rect, + /// An ellipse with coinciding foci. + Circle, + /// A curve around two focal points. + Ellipse, +} + +impl ShapeKind { + /// Whether this shape kind is curvy. + fn is_round(self) -> bool { + matches!(self, Self::Circle | Self::Ellipse) + } + + /// Whether this shape kind has equal side length. + fn is_quadratic(self) -> bool { + matches!(self, Self::Square | Self::Circle) + } +} + +/// Layout a shape. +#[allow(clippy::too_many_arguments)] +fn layout_shape( + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Region, + kind: ShapeKind, + body: &Option<Content>, + fill: Option<Paint>, + stroke: Smart<Sides<Option<Option<Stroke<Abs>>>>>, + inset: Sides<Option<Rel<Abs>>>, + outset: Sides<Option<Rel<Abs>>>, + radius: Corners<Option<Rel<Abs>>>, + span: Span, +) -> SourceResult<Frame> { + let mut frame; + if let Some(child) = body { + let mut inset = inset.unwrap_or_default(); + if kind.is_round() { + // Apply extra inset to round shapes. + inset = inset.map(|v| v + Ratio::new(0.5 - SQRT_2 / 4.0)); + } + let has_inset = !inset.is_zero(); + + // Take the inset, if any, into account. + let mut pod = region; + if has_inset { + pod.size = crate::pad::shrink(region.size, &inset); + } + + // Layout the child. + frame = crate::layout_frame(engine, child, locator.relayout(), styles, pod)?; + + // If the child is a square or circle, relayout with full expansion into + // square region to make sure the result is really quadratic. + if kind.is_quadratic() { + let length = frame.size().max_by_side().min(pod.size.min_by_side()); + let quad_pod = Region::new(Size::splat(length), Axes::splat(true)); + frame = crate::layout_frame(engine, child, locator, styles, quad_pod)?; + } + + // Apply the inset. + if has_inset { + crate::pad::grow(&mut frame, &inset); + } + } else { + // The default size that a shape takes on if it has no child and + // enough space. + let default = Size::new(Abs::pt(45.0), Abs::pt(30.0)); + let mut size = region.expand.select(region.size, default.min(region.size)); + if kind.is_quadratic() { + size = Size::splat(size.min_by_side()); + } + frame = Frame::soft(size); + } + + // Prepare stroke. + let stroke = match stroke { + Smart::Auto if fill.is_none() => Sides::splat(Some(FixedStroke::default())), + Smart::Auto => Sides::splat(None), + Smart::Custom(strokes) => { + strokes.unwrap_or_default().map(|s| s.map(Stroke::unwrap_or_default)) + } + }; + + // Add fill and/or stroke. + if fill.is_some() || stroke.iter().any(Option::is_some) { + if kind.is_round() { + let outset = outset.unwrap_or_default().relative_to(frame.size()); + let size = frame.size() + outset.sum_by_axis(); + let pos = Point::new(-outset.left, -outset.top); + let shape = Shape { + geometry: Geometry::Path(Path::ellipse(size)), + fill, + stroke: stroke.left, + fill_rule: FillRule::default(), + }; + frame.prepend(pos, FrameItem::Shape(shape, span)); + } else { + fill_and_stroke( + &mut frame, + fill, + &stroke, + &outset.unwrap_or_default(), + &radius.unwrap_or_default(), + span, + ); + } + } + + Ok(frame) +} + +/// Creates a new rectangle as a path. +pub fn clip_rect( + size: Size, + radius: &Corners<Rel<Abs>>, + stroke: &Sides<Option<FixedStroke>>, +) -> Path { + let stroke_widths = stroke + .as_ref() + .map(|s| s.as_ref().map_or(Abs::zero(), |s| s.thickness / 2.0)); + + let max_radius = (size.x.min(size.y)) / 2.0 + + stroke_widths.iter().cloned().min().unwrap_or(Abs::zero()); + + let radius = radius.map(|side| side.relative_to(max_radius * 2.0).min(max_radius)); + let corners = corners_control_points(size, &radius, stroke, &stroke_widths); + + let mut path = Path::new(); + if corners.top_left.arc_inner() { + path.arc_move( + corners.top_left.start_inner(), + corners.top_left.center_inner(), + corners.top_left.end_inner(), + ); + } else { + path.move_to(corners.top_left.center_inner()); + } + for corner in [&corners.top_right, &corners.bottom_right, &corners.bottom_left] { + if corner.arc_inner() { + path.arc_line(corner.start_inner(), corner.center_inner(), corner.end_inner()) + } else { + path.line_to(corner.center_inner()); + } + } + path.close_path(); + path +} + +/// Add a fill and stroke with optional radius and outset to the frame. +pub fn fill_and_stroke( + frame: &mut Frame, + fill: Option<Paint>, + stroke: &Sides<Option<FixedStroke>>, + outset: &Sides<Rel<Abs>>, + radius: &Corners<Rel<Abs>>, + span: Span, +) { + let outset = outset.relative_to(frame.size()); + let size = frame.size() + outset.sum_by_axis(); + let pos = Point::new(-outset.left, -outset.top); + frame.prepend_multiple( + styled_rect(size, radius, fill, stroke) + .into_iter() + .map(|x| (pos, FrameItem::Shape(x, span))), + ); +} + +/// Create a styled rectangle with shapes. +/// - use rect primitive for simple rectangles +/// - stroke sides if possible +/// - use fill for sides for best looks +pub fn styled_rect( + size: Size, + radius: &Corners<Rel<Abs>>, + fill: Option<Paint>, + stroke: &Sides<Option<FixedStroke>>, +) -> Vec<Shape> { + if stroke.is_uniform() && radius.iter().cloned().all(Rel::is_zero) { + simple_rect(size, fill, stroke.top.clone()) + } else { + segmented_rect(size, radius, fill, stroke) + } +} + +/// Use rect primitive for the rectangle +fn simple_rect( + size: Size, + fill: Option<Paint>, + stroke: Option<FixedStroke>, +) -> Vec<Shape> { + vec![Shape { + geometry: Geometry::Rect(size), + fill, + stroke, + fill_rule: FillRule::default(), + }] +} + +fn corners_control_points( + size: Size, + radius: &Corners<Abs>, + strokes: &Sides<Option<FixedStroke>>, + stroke_widths: &Sides<Abs>, +) -> Corners<ControlPoints> { + Corners { + top_left: Corner::TopLeft, + top_right: Corner::TopRight, + bottom_right: Corner::BottomRight, + bottom_left: Corner::BottomLeft, + } + .map(|corner| ControlPoints { + radius: radius.get(corner), + stroke_before: stroke_widths.get(corner.side_ccw()), + stroke_after: stroke_widths.get(corner.side_cw()), + corner, + size, + same: match ( + strokes.get_ref(corner.side_ccw()), + strokes.get_ref(corner.side_cw()), + ) { + (Some(a), Some(b)) => a.paint == b.paint && a.dash == b.dash, + (None, None) => true, + _ => false, + }, + }) +} + +/// Use stroke and fill for the rectangle +fn segmented_rect( + size: Size, + radius: &Corners<Rel<Abs>>, + fill: Option<Paint>, + strokes: &Sides<Option<FixedStroke>>, +) -> Vec<Shape> { + let mut res = vec![]; + let stroke_widths = strokes + .as_ref() + .map(|s| s.as_ref().map_or(Abs::zero(), |s| s.thickness / 2.0)); + + let max_radius = (size.x.min(size.y)) / 2.0 + + stroke_widths.iter().cloned().min().unwrap_or(Abs::zero()); + + let radius = radius.map(|side| side.relative_to(max_radius * 2.0).min(max_radius)); + let corners = corners_control_points(size, &radius, strokes, &stroke_widths); + + // insert stroked sides below filled sides + let mut stroke_insert = 0; + + // fill shape with inner curve + if let Some(fill) = fill { + let mut path = Path::new(); + let c = corners.get_ref(Corner::TopLeft); + if c.arc() { + path.arc_move(c.start(), c.center(), c.end()); + } else { + path.move_to(c.center()); + }; + + for corner in [Corner::TopRight, Corner::BottomRight, Corner::BottomLeft] { + let c = corners.get_ref(corner); + if c.arc() { + path.arc_line(c.start(), c.center(), c.end()); + } else { + path.line_to(c.center()); + } + } + path.close_path(); + res.push(Shape { + geometry: Geometry::Path(path), + fill: Some(fill), + fill_rule: FillRule::default(), + stroke: None, + }); + stroke_insert += 1; + } + + let current = corners.iter().find(|c| !c.same).map(|c| c.corner); + if let Some(mut current) = current { + // multiple segments + // start at a corner with a change between sides and iterate clockwise all other corners + let mut last = current; + for _ in 0..4 { + current = current.next_cw(); + if corners.get_ref(current).same { + continue; + } + // create segment + let start = last; + let end = current; + last = current; + let Some(stroke) = strokes.get_ref(start.side_cw()) else { continue }; + let (shape, ontop) = segment(start, end, &corners, stroke); + if ontop { + res.push(shape); + } else { + res.insert(stroke_insert, shape); + stroke_insert += 1; + } + } + } else if let Some(stroke) = &strokes.top { + // single segment + let (shape, _) = segment(Corner::TopLeft, Corner::TopLeft, &corners, stroke); + res.push(shape); + } + res +} + +fn path_segment( + start: Corner, + end: Corner, + corners: &Corners<ControlPoints>, + path: &mut Path, +) { + // create start corner + let c = corners.get_ref(start); + if start == end || !c.arc() { + path.move_to(c.end()); + } else { + path.arc_move(c.mid(), c.center(), c.end()); + } + + // create corners between start and end + let mut current = start.next_cw(); + while current != end { + let c = corners.get_ref(current); + if c.arc() { + path.arc_line(c.start(), c.center(), c.end()); + } else { + path.line_to(c.end()); + } + current = current.next_cw(); + } + + // create end corner + let c = corners.get_ref(end); + if !c.arc() { + path.line_to(c.start()); + } else if start == end { + path.arc_line(c.start(), c.center(), c.end()); + } else { + path.arc_line(c.start(), c.center(), c.mid()); + } +} + +/// Returns the shape for the segment and whether the shape should be drawn on top. +fn segment( + start: Corner, + end: Corner, + corners: &Corners<ControlPoints>, + stroke: &FixedStroke, +) -> (Shape, bool) { + fn fill_corner(corner: &ControlPoints) -> bool { + corner.stroke_before != corner.stroke_after + || corner.radius() < corner.stroke_before + } + + fn fill_corners( + start: Corner, + end: Corner, + corners: &Corners<ControlPoints>, + ) -> bool { + if fill_corner(corners.get_ref(start)) { + return true; + } + if fill_corner(corners.get_ref(end)) { + return true; + } + let mut current = start.next_cw(); + while current != end { + if fill_corner(corners.get_ref(current)) { + return true; + } + current = current.next_cw(); + } + false + } + + let solid = stroke + .dash + .as_ref() + .map(|pattern| pattern.array.is_empty()) + .unwrap_or(true); + + let use_fill = solid && fill_corners(start, end, corners); + let shape = if use_fill { + fill_segment(start, end, corners, stroke) + } else { + stroke_segment(start, end, corners, stroke.clone()) + }; + + (shape, use_fill) +} + +/// Stroke the sides from `start` to `end` clockwise. +fn stroke_segment( + start: Corner, + end: Corner, + corners: &Corners<ControlPoints>, + stroke: FixedStroke, +) -> Shape { + // Create start corner. + let mut path = Path::new(); + path_segment(start, end, corners, &mut path); + + Shape { + geometry: Geometry::Path(path), + stroke: Some(stroke), + fill: None, + fill_rule: FillRule::default(), + } +} + +/// Fill the sides from `start` to `end` clockwise. +fn fill_segment( + start: Corner, + end: Corner, + corners: &Corners<ControlPoints>, + stroke: &FixedStroke, +) -> Shape { + let mut path = Path::new(); + + // create the start corner + // begin on the inside and finish on the outside + // no corner if start and end are equal + // half corner if different + if start == end { + let c = corners.get_ref(start); + path.move_to(c.end_inner()); + path.line_to(c.end_outer()); + } else { + let c = corners.get_ref(start); + + if c.arc_inner() { + path.arc_move(c.end_inner(), c.center_inner(), c.mid_inner()); + } else { + path.move_to(c.end_inner()); + } + + if c.arc_outer() { + path.arc_line(c.mid_outer(), c.center_outer(), c.end_outer()); + } else { + path.line_to(c.outer()); + path.line_to(c.end_outer()); + } + } + + // create the clockwise outside path for the corners between start and end + let mut current = start.next_cw(); + while current != end { + let c = corners.get_ref(current); + if c.arc_outer() { + path.arc_line(c.start_outer(), c.center_outer(), c.end_outer()); + } else { + path.line_to(c.outer()); + } + current = current.next_cw(); + } + + // create the end corner + // begin on the outside and finish on the inside + // full corner if start and end are equal + // half corner if different + if start == end { + let c = corners.get_ref(end); + if c.arc_outer() { + path.arc_line(c.start_outer(), c.center_outer(), c.end_outer()); + } else { + path.line_to(c.outer()); + path.line_to(c.end_outer()); + } + if c.arc_inner() { + path.arc_line(c.end_inner(), c.center_inner(), c.start_inner()); + } else { + path.line_to(c.center_inner()); + } + } else { + let c = corners.get_ref(end); + if c.arc_outer() { + path.arc_line(c.start_outer(), c.center_outer(), c.mid_outer()); + } else { + path.line_to(c.outer()); + } + if c.arc_inner() { + path.arc_line(c.mid_inner(), c.center_inner(), c.start_inner()); + } else { + path.line_to(c.center_inner()); + } + } + + // create the counterclockwise inside path for the corners between start and end + let mut current = end.next_ccw(); + while current != start { + let c = corners.get_ref(current); + if c.arc_inner() { + path.arc_line(c.end_inner(), c.center_inner(), c.start_inner()); + } else { + path.line_to(c.center_inner()); + } + current = current.next_ccw(); + } + + path.close_path(); + + Shape { + geometry: Geometry::Path(path), + stroke: None, + fill: Some(stroke.paint.clone()), + fill_rule: FillRule::default(), + } +} + +/// Helper to calculate different control points for the corners. +/// Clockwise orientation from start to end. +/// ```text +/// O-------------------EO --- - Z: Zero/Origin ({x: 0, y: 0} for top left corner) +/// |\ ___----''' | | - O: Outer: intersection between the straight outer lines +/// | \ / | | - S_: start +/// | MO | | - M_: midpoint +/// | /Z\ __-----------E | - E_: end +/// |/ \M | ro - r_: radius +/// | /\ | | - middle of the stroke +/// | / \ | | - arc from S through M to E with center C and radius r +/// | | MI--EI------- | - outer curve +/// | | / \ | - arc from SO through MO to EO with center CO and radius ro +/// SO | | \ CO --- - inner curve +/// | | | \ - arc from SI through MI to EI with center CI and radius ri +/// |--S-SI-----CI C +/// |--ri--| +/// |-------r--------| +/// ``` +struct ControlPoints { + radius: Abs, + stroke_after: Abs, + stroke_before: Abs, + corner: Corner, + size: Size, + same: bool, +} + +impl ControlPoints { + /// Move and rotate the point from top-left to the required corner. + fn rotate(&self, point: Point) -> Point { + match self.corner { + Corner::TopLeft => point, + Corner::TopRight => Point { x: self.size.x - point.y, y: point.x }, + Corner::BottomRight => { + Point { x: self.size.x - point.x, y: self.size.y - point.y } + } + Corner::BottomLeft => Point { x: point.y, y: self.size.y - point.x }, + } + } + + /// Outside intersection of the sides. + pub fn outer(&self) -> Point { + self.rotate(Point { x: -self.stroke_before, y: -self.stroke_after }) + } + + /// Center for the outer arc. + pub fn center_outer(&self) -> Point { + let r = self.radius_outer(); + self.rotate(Point { + x: r - self.stroke_before, + y: r - self.stroke_after, + }) + } + + /// Center for the middle arc. + pub fn center(&self) -> Point { + let r = self.radius(); + self.rotate(Point { x: r, y: r }) + } + + /// Center for the inner arc. + pub fn center_inner(&self) -> Point { + let r = self.radius_inner(); + + self.rotate(Point { + x: self.stroke_before + r, + y: self.stroke_after + r, + }) + } + + /// Radius of the outer arc. + pub fn radius_outer(&self) -> Abs { + self.radius + } + + /// Radius of the middle arc. + pub fn radius(&self) -> Abs { + (self.radius - self.stroke_before.min(self.stroke_after)).max(Abs::zero()) + } + + /// Radius of the inner arc. + pub fn radius_inner(&self) -> Abs { + (self.radius - 2.0 * self.stroke_before.max(self.stroke_after)).max(Abs::zero()) + } + + /// Middle of the corner on the outside of the stroke. + pub fn mid_outer(&self) -> Point { + let c_i = self.center_inner(); + let c_o = self.center_outer(); + let o = self.outer(); + let r = self.radius_outer(); + + // https://math.stackexchange.com/a/311956 + // intersection between the line from inner center to outside and the outer arc + let a = (o.x - c_i.x).to_raw().powi(2) + (o.y - c_i.y).to_raw().powi(2); + let b = 2.0 * (o.x - c_i.x).to_raw() * (c_i.x - c_o.x).to_raw() + + 2.0 * (o.y - c_i.y).to_raw() * (c_i.y - c_o.y).to_raw(); + let c = (c_i.x - c_o.x).to_raw().powi(2) + (c_i.y - c_o.y).to_raw().powi(2) + - r.to_raw().powi(2); + let t = (-b + (b * b - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a); + c_i + t * (o - c_i) + } + + /// Middle of the corner in the middle of the stroke. + pub fn mid(&self) -> Point { + let center = self.center_outer(); + let outer = self.outer(); + let diff = outer - center; + center + diff / diff.hypot().to_raw() * self.radius().to_raw() + } + + /// Middle of the corner on the inside of the stroke. + pub fn mid_inner(&self) -> Point { + let center = self.center_inner(); + let outer = self.outer(); + let diff = outer - center; + center + diff / diff.hypot().to_raw() * self.radius_inner().to_raw() + } + + /// If an outer arc is required. + pub fn arc_outer(&self) -> bool { + self.radius_outer() > Abs::zero() + } + + pub fn arc(&self) -> bool { + self.radius() > Abs::zero() + } + + /// If an inner arc is required. + pub fn arc_inner(&self) -> bool { + self.radius_inner() > Abs::zero() + } + + /// Start of the corner on the outside of the stroke. + pub fn start_outer(&self) -> Point { + self.rotate(Point { + x: -self.stroke_before, + y: self.radius_outer() - self.stroke_after, + }) + } + + /// Start of the corner in the center of the stroke. + pub fn start(&self) -> Point { + self.rotate(Point::with_y(self.radius())) + } + + /// Start of the corner on the inside of the stroke. + pub fn start_inner(&self) -> Point { + self.rotate(Point { + x: self.stroke_before, + y: self.stroke_after + self.radius_inner(), + }) + } + + /// End of the corner on the outside of the stroke. + pub fn end_outer(&self) -> Point { + self.rotate(Point { + x: self.radius_outer() - self.stroke_before, + y: -self.stroke_after, + }) + } + + /// End of the corner in the center of the stroke. + pub fn end(&self) -> Point { + self.rotate(Point::with_x(self.radius())) + } + + /// End of the corner on the inside of the stroke. + pub fn end_inner(&self) -> Point { + self.rotate(Point { + x: self.stroke_before + self.radius_inner(), + y: self.stroke_after, + }) + } +} + +/// Helper to draw arcs with bezier curves. +trait PathExt { + fn arc(&mut self, start: Point, center: Point, end: Point); + fn arc_move(&mut self, start: Point, center: Point, end: Point); + fn arc_line(&mut self, start: Point, center: Point, end: Point); +} + +impl PathExt for Path { + fn arc(&mut self, start: Point, center: Point, end: Point) { + let arc = bezier_arc_control(start, center, end); + self.cubic_to(arc[0], arc[1], end); + } + + fn arc_move(&mut self, start: Point, center: Point, end: Point) { + self.move_to(start); + self.arc(start, center, end); + } + + fn arc_line(&mut self, start: Point, center: Point, end: Point) { + self.line_to(start); + self.arc(start, center, end); + } +} + +/// Get the control points for a bezier curve that approximates a circular arc for +/// a start point, an end point and a center of the circle whose arc connects +/// the two. +fn bezier_arc_control(start: Point, center: Point, end: Point) -> [Point; 2] { + // https://stackoverflow.com/a/44829356/1567835 + let a = start - center; + let b = end - center; + + let q1 = a.x.to_raw() * a.x.to_raw() + a.y.to_raw() * a.y.to_raw(); + let q2 = q1 + a.x.to_raw() * b.x.to_raw() + a.y.to_raw() * b.y.to_raw(); + let k2 = (4.0 / 3.0) * ((2.0 * q1 * q2).sqrt() - q2) + / (a.x.to_raw() * b.y.to_raw() - a.y.to_raw() * b.x.to_raw()); + + let control_1 = Point::new(center.x + a.x - k2 * a.y, center.y + a.y + k2 * a.x); + let control_2 = Point::new(center.x + b.x + k2 * b.y, center.y + b.y - k2 * b.x); + + [control_1, control_2] +} diff --git a/crates/typst-layout/src/stack.rs b/crates/typst-layout/src/stack.rs new file mode 100644 index 00000000..a3ebc9f3 --- /dev/null +++ b/crates/typst-layout/src/stack.rs @@ -0,0 +1,324 @@ +use typst_library::diag::{bail, SourceResult}; +use typst_library::engine::Engine; +use typst_library::foundations::{Content, Packed, Resolve, StyleChain, StyledElem}; +use typst_library::introspection::{Locator, SplitLocator}; +use typst_library::layout::{ + Abs, AlignElem, Axes, Axis, Dir, FixedAlignment, Fr, Fragment, Frame, HElem, Point, + Regions, Size, Spacing, StackChild, StackElem, VElem, +}; +use typst_syntax::Span; +use typst_utils::{Get, Numeric}; + +/// Layout the stack. +#[typst_macros::time(span = elem.span())] +pub fn layout_stack( + elem: &Packed<StackElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + regions: Regions, +) -> SourceResult<Fragment> { + let mut layouter = + StackLayouter::new(elem.span(), elem.dir(styles), locator, styles, regions); + + let axis = layouter.dir.axis(); + + // Spacing to insert before the next block. + let spacing = elem.spacing(styles); + let mut deferred = None; + + for child in elem.children() { + match child { + StackChild::Spacing(kind) => { + layouter.layout_spacing(*kind); + deferred = None; + } + StackChild::Block(block) => { + // Transparently handle `h`. + if let (Axis::X, Some(h)) = (axis, block.to_packed::<HElem>()) { + layouter.layout_spacing(*h.amount()); + deferred = None; + continue; + } + + // Transparently handle `v`. + if let (Axis::Y, Some(v)) = (axis, block.to_packed::<VElem>()) { + layouter.layout_spacing(*v.amount()); + deferred = None; + continue; + } + + if let Some(kind) = deferred { + layouter.layout_spacing(kind); + } + + layouter.layout_block(engine, block, styles)?; + deferred = spacing; + } + } + } + + layouter.finish() +} + +/// Performs stack layout. +struct StackLayouter<'a> { + /// The span to raise errors at during layout. + span: Span, + /// The stacking direction. + dir: Dir, + /// The axis of the stacking direction. + axis: Axis, + /// Provides unique locations to the stack's children. + locator: SplitLocator<'a>, + /// The inherited styles. + styles: StyleChain<'a>, + /// The regions to layout children into. + regions: Regions<'a>, + /// Whether the stack itself should expand to fill the region. + expand: Axes<bool>, + /// The initial size of the current region before we started subtracting. + initial: Size, + /// The generic size used by the frames for the current region. + used: GenericSize<Abs>, + /// The sum of fractions in the current region. + fr: Fr, + /// Already layouted items whose exact positions are not yet known due to + /// fractional spacing. + items: Vec<StackItem>, + /// Finished frames for previous regions. + finished: Vec<Frame>, +} + +/// A prepared item in a stack layout. +enum StackItem { + /// Absolute spacing between other items. + Absolute(Abs), + /// Fractional spacing between other items. + Fractional(Fr), + /// A frame for a layouted block. + Frame(Frame, Axes<FixedAlignment>), +} + +impl<'a> StackLayouter<'a> { + /// Create a new stack layouter. + fn new( + span: Span, + dir: Dir, + locator: Locator<'a>, + styles: StyleChain<'a>, + mut regions: Regions<'a>, + ) -> Self { + let axis = dir.axis(); + let expand = regions.expand; + + // Disable expansion along the block axis for children. + regions.expand.set(axis, false); + + Self { + span, + dir, + axis, + locator: locator.split(), + styles, + regions, + expand, + initial: regions.size, + used: GenericSize::zero(), + fr: Fr::zero(), + items: vec![], + finished: vec![], + } + } + + /// Add spacing along the spacing direction. + fn layout_spacing(&mut self, spacing: Spacing) { + match spacing { + Spacing::Rel(v) => { + // Resolve the spacing and limit it to the remaining space. + let resolved = v + .resolve(self.styles) + .relative_to(self.regions.base().get(self.axis)); + let remaining = self.regions.size.get_mut(self.axis); + let limited = resolved.min(*remaining); + if self.dir.axis() == Axis::Y { + *remaining -= limited; + } + self.used.main += limited; + self.items.push(StackItem::Absolute(resolved)); + } + Spacing::Fr(v) => { + self.fr += v; + self.items.push(StackItem::Fractional(v)); + } + } + } + + /// Layout an arbitrary block. + fn layout_block( + &mut self, + engine: &mut Engine, + block: &Content, + styles: StyleChain, + ) -> SourceResult<()> { + if self.regions.is_full() { + self.finish_region()?; + } + + // Block-axis alignment of the `AlignElem` is respected by stacks. + let align = if let Some(align) = block.to_packed::<AlignElem>() { + align.alignment(styles) + } else if let Some(styled) = block.to_packed::<StyledElem>() { + AlignElem::alignment_in(styles.chain(&styled.styles)) + } else { + AlignElem::alignment_in(styles) + } + .resolve(styles); + + let fragment = crate::layout_fragment( + engine, + block, + self.locator.next(&block.span()), + styles, + self.regions, + )?; + + let len = fragment.len(); + for (i, frame) in fragment.into_iter().enumerate() { + // Grow our size, shrink the region and save the frame for later. + let specific_size = frame.size(); + if self.dir.axis() == Axis::Y { + self.regions.size.y -= specific_size.y; + } + + let generic_size = match self.axis { + Axis::X => GenericSize::new(specific_size.y, specific_size.x), + Axis::Y => GenericSize::new(specific_size.x, specific_size.y), + }; + + self.used.main += generic_size.main; + self.used.cross.set_max(generic_size.cross); + + self.items.push(StackItem::Frame(frame, align)); + + if i + 1 < len { + self.finish_region()?; + } + } + + Ok(()) + } + + /// Advance to the next region. + fn finish_region(&mut self) -> SourceResult<()> { + // Determine the size of the stack in this region depending on whether + // the region expands. + let mut size = self + .expand + .select(self.initial, self.used.into_axes(self.axis)) + .min(self.initial); + + // Expand fully if there are fr spacings. + let full = self.initial.get(self.axis); + let remaining = full - self.used.main; + if self.fr.get() > 0.0 && full.is_finite() { + self.used.main = full; + size.set(self.axis, full); + } + + if !size.is_finite() { + bail!(self.span, "stack spacing is infinite"); + } + + let mut output = Frame::hard(size); + let mut cursor = Abs::zero(); + let mut ruler: FixedAlignment = self.dir.start().into(); + + // Place all frames. + for item in self.items.drain(..) { + match item { + StackItem::Absolute(v) => cursor += v, + StackItem::Fractional(v) => cursor += v.share(self.fr, remaining), + StackItem::Frame(frame, align) => { + if self.dir.is_positive() { + ruler = ruler.max(align.get(self.axis)); + } else { + ruler = ruler.min(align.get(self.axis)); + } + + // Align along the main axis. + let parent = size.get(self.axis); + let child = frame.size().get(self.axis); + let main = ruler.position(parent - self.used.main) + + if self.dir.is_positive() { + cursor + } else { + self.used.main - child - cursor + }; + + // Align along the cross axis. + let other = self.axis.other(); + let cross = align + .get(other) + .position(size.get(other) - frame.size().get(other)); + + let pos = GenericSize::new(cross, main).to_point(self.axis); + cursor += child; + output.push_frame(pos, frame); + } + } + } + + // Advance to the next region. + self.regions.next(); + self.initial = self.regions.size; + self.used = GenericSize::zero(); + self.fr = Fr::zero(); + self.finished.push(output); + + Ok(()) + } + + /// Finish layouting and return the resulting frames. + fn finish(mut self) -> SourceResult<Fragment> { + self.finish_region()?; + Ok(Fragment::frames(self.finished)) + } +} + +/// A generic size with main and cross axes. The axes are generic, meaning the +/// main axis could correspond to either the X or the Y axis. +#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)] +struct GenericSize<T> { + /// The cross component, along the axis perpendicular to the main. + pub cross: T, + /// The main component. + pub main: T, +} + +impl<T> GenericSize<T> { + /// Create a new instance from the two components. + const fn new(cross: T, main: T) -> Self { + Self { cross, main } + } + + /// Convert to the specific representation, given the current main axis. + fn into_axes(self, main: Axis) -> Axes<T> { + match main { + Axis::X => Axes::new(self.main, self.cross), + Axis::Y => Axes::new(self.cross, self.main), + } + } +} + +impl GenericSize<Abs> { + /// The zero value. + fn zero() -> Self { + Self { cross: Abs::zero(), main: Abs::zero() } + } + + /// Convert to a point. + fn to_point(self, main: Axis) -> Point { + self.into_axes(main).to_point() + } +} diff --git a/crates/typst-layout/src/transforms.rs b/crates/typst-layout/src/transforms.rs new file mode 100644 index 00000000..5ac9f777 --- /dev/null +++ b/crates/typst-layout/src/transforms.rs @@ -0,0 +1,246 @@ +use once_cell::unsync::Lazy; +use typst_library::diag::{bail, SourceResult}; +use typst_library::engine::Engine; +use typst_library::foundations::{Content, Packed, Resolve, Smart, StyleChain}; +use typst_library::introspection::Locator; +use typst_library::layout::{ + Abs, Axes, FixedAlignment, Frame, MoveElem, Point, Ratio, Region, Rel, RotateElem, + ScaleAmount, ScaleElem, Size, SkewElem, Transform, +}; +use typst_utils::Numeric; + +/// Layout the moved content. +#[typst_macros::time(span = elem.span())] +pub fn layout_move( + elem: &Packed<MoveElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + let mut frame = crate::layout_frame(engine, &elem.body, locator, styles, region)?; + let delta = Axes::new(elem.dx(styles), elem.dy(styles)).resolve(styles); + let delta = delta.zip_map(region.size, Rel::relative_to); + frame.translate(delta.to_point()); + Ok(frame) +} + +/// Layout the rotated content. +#[typst_macros::time(span = elem.span())] +pub fn layout_rotate( + elem: &Packed<RotateElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + let angle = elem.angle(styles); + let align = elem.origin(styles).resolve(styles); + + // Compute the new region's approximate size. + let is_finite = region.size.is_finite(); + let size = if is_finite { + compute_bounding_box(region.size, Transform::rotate(-angle)).1 + } else { + Size::splat(Abs::inf()) + }; + + measure_and_layout( + engine, + locator, + region, + size, + styles, + elem.body(), + Transform::rotate(angle), + align, + elem.reflow(styles), + ) +} + +/// Layout the scaled content. +#[typst_macros::time(span = elem.span())] +pub fn layout_scale( + elem: &Packed<ScaleElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + // Compute the new region's approximate size. + let scale = resolve_scale(elem, engine, locator.relayout(), region.size, styles)?; + let size = region + .size + .zip_map(scale, |r, s| if r.is_finite() { Ratio::new(1.0 / s).of(r) } else { r }) + .map(Abs::abs); + + measure_and_layout( + engine, + locator, + region, + size, + styles, + elem.body(), + Transform::scale(scale.x, scale.y), + elem.origin(styles).resolve(styles), + elem.reflow(styles), + ) +} + +/// Resolves scale parameters, preserving aspect ratio if one of the scales +/// is set to `auto`. +fn resolve_scale( + elem: &Packed<ScaleElem>, + engine: &mut Engine, + locator: Locator, + container: Size, + styles: StyleChain, +) -> SourceResult<Axes<Ratio>> { + fn resolve_axis( + axis: Smart<ScaleAmount>, + body: impl Fn() -> SourceResult<Abs>, + styles: StyleChain, + ) -> SourceResult<Smart<Ratio>> { + Ok(match axis { + Smart::Auto => Smart::Auto, + Smart::Custom(amt) => Smart::Custom(match amt { + ScaleAmount::Ratio(ratio) => ratio, + ScaleAmount::Length(length) => { + let length = length.resolve(styles); + Ratio::new(length / body()?) + } + }), + }) + } + + let size = Lazy::new(|| { + let pod = Region::new(container, Axes::splat(false)); + let frame = crate::layout_frame(engine, &elem.body, locator, styles, pod)?; + SourceResult::Ok(frame.size()) + }); + + let x = resolve_axis( + elem.x(styles), + || size.as_ref().map(|size| size.x).map_err(Clone::clone), + styles, + )?; + + let y = resolve_axis( + elem.y(styles), + || size.as_ref().map(|size| size.y).map_err(Clone::clone), + styles, + )?; + + match (x, y) { + (Smart::Auto, Smart::Auto) => { + bail!(elem.span(), "x and y cannot both be auto") + } + (Smart::Custom(x), Smart::Custom(y)) => Ok(Axes::new(x, y)), + (Smart::Auto, Smart::Custom(v)) | (Smart::Custom(v), Smart::Auto) => { + Ok(Axes::splat(v)) + } + } +} + +/// Layout the skewed content. +#[typst_macros::time(span = elem.span())] +pub fn layout_skew( + elem: &Packed<SkewElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Region, +) -> SourceResult<Frame> { + let ax = elem.ax(styles); + let ay = elem.ay(styles); + let align = elem.origin(styles).resolve(styles); + + // Compute the new region's approximate size. + let size = if region.size.is_finite() { + compute_bounding_box(region.size, Transform::skew(ax, ay)).1 + } else { + Size::splat(Abs::inf()) + }; + + measure_and_layout( + engine, + locator, + region, + size, + styles, + elem.body(), + Transform::skew(ax, ay), + align, + elem.reflow(styles), + ) +} + +/// Applies a transformation to a frame, reflowing the layout if necessary. +#[allow(clippy::too_many_arguments)] +fn measure_and_layout( + engine: &mut Engine, + locator: Locator, + region: Region, + size: Size, + styles: StyleChain, + body: &Content, + transform: Transform, + align: Axes<FixedAlignment>, + reflow: bool, +) -> SourceResult<Frame> { + if reflow { + // Measure the size of the body. + let pod = Region::new(size, Axes::splat(false)); + let frame = crate::layout_frame(engine, body, locator.relayout(), styles, pod)?; + + // Actually perform the layout. + let pod = Region::new(frame.size(), Axes::splat(true)); + let mut frame = crate::layout_frame(engine, body, locator, styles, pod)?; + let Axes { x, y } = align.zip_map(frame.size(), FixedAlignment::position); + + // Compute the transform. + let ts = Transform::translate(x, y) + .pre_concat(transform) + .pre_concat(Transform::translate(-x, -y)); + + // Compute the bounding box and offset and wrap in a new frame. + let (offset, size) = compute_bounding_box(frame.size(), ts); + frame.transform(ts); + frame.translate(offset); + frame.set_size(size); + Ok(frame) + } else { + // Layout the body. + let mut frame = crate::layout_frame(engine, body, locator, styles, region)?; + let Axes { x, y } = align.zip_map(frame.size(), FixedAlignment::position); + + // Compute the transform. + let ts = Transform::translate(x, y) + .pre_concat(transform) + .pre_concat(Transform::translate(-x, -y)); + + // Apply the transform. + frame.transform(ts); + Ok(frame) + } +} + +/// Computes the bounding box and offset of a transformed area. +fn compute_bounding_box(size: Size, ts: Transform) -> (Point, Size) { + let top_left = Point::zero().transform_inf(ts); + let top_right = Point::with_x(size.x).transform_inf(ts); + let bottom_left = Point::with_y(size.y).transform_inf(ts); + let bottom_right = size.to_point().transform_inf(ts); + + // We first compute the new bounding box of the rotated area. + let min_x = top_left.x.min(top_right.x).min(bottom_left.x).min(bottom_right.x); + let min_y = top_left.y.min(top_right.y).min(bottom_left.y).min(bottom_right.y); + let max_x = top_left.x.max(top_right.x).max(bottom_left.x).max(bottom_right.x); + let max_y = top_left.y.max(top_right.y).max(bottom_left.y).max(bottom_right.y); + + // Then we compute the new size of the area. + let width = max_x - min_x; + let height = max_y - min_y; + + (Point::new(-min_x, -min_y), Size::new(width.abs(), height.abs())) +} |
