summaryrefslogtreecommitdiff
path: root/library/src/layout
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2022-11-29 13:37:25 +0100
committerLaurenz <laurmaedje@gmail.com>2022-11-29 14:18:13 +0100
commit0efe669278a5e1c3f2985eba2f3360e91159c54a (patch)
tree502712857c48f0decb5e698257c0a96d358a436e /library/src/layout
parent836692e73cff0356e409a9ba5b4887b86809d4ca (diff)
Reorganize library and tests
Diffstat (limited to 'library/src/layout')
-rw-r--r--library/src/layout/align.rs2
-rw-r--r--library/src/layout/flow.rs3
-rw-r--r--library/src/layout/hide.rs29
-rw-r--r--library/src/layout/mod.rs15
-rw-r--r--library/src/layout/par.rs1190
-rw-r--r--library/src/layout/repeat.rs25
-rw-r--r--library/src/layout/stack.rs3
7 files changed, 1256 insertions, 11 deletions
diff --git a/library/src/layout/align.rs b/library/src/layout/align.rs
index d8b6d92e..a06f7edb 100644
--- a/library/src/layout/align.rs
+++ b/library/src/layout/align.rs
@@ -1,5 +1,5 @@
+use super::{HorizontalAlign, ParNode};
use crate::prelude::*;
-use crate::text::{HorizontalAlign, ParNode};
/// Align content along the layouting axes.
#[derive(Debug, Hash)]
diff --git a/library/src/layout/flow.rs b/library/src/layout/flow.rs
index 07c3e012..b644d73f 100644
--- a/library/src/layout/flow.rs
+++ b/library/src/layout/flow.rs
@@ -1,8 +1,7 @@
use typst::model::Style;
-use super::{AlignNode, BlockNode, ColbreakNode, PlaceNode, Spacing, VNode};
+use super::{AlignNode, BlockNode, ColbreakNode, ParNode, PlaceNode, Spacing, VNode};
use crate::prelude::*;
-use crate::text::ParNode;
/// Arrange spacing, paragraphs and block-level nodes into a flow.
///
diff --git a/library/src/layout/hide.rs b/library/src/layout/hide.rs
new file mode 100644
index 00000000..64cbee64
--- /dev/null
+++ b/library/src/layout/hide.rs
@@ -0,0 +1,29 @@
+use crate::prelude::*;
+
+/// Hide content without affecting layout.
+#[derive(Debug, Hash)]
+pub struct HideNode(pub Content);
+
+#[node(Layout, Inline)]
+impl HideNode {
+ fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> {
+ Ok(Self(args.expect("body")?).pack())
+ }
+}
+
+impl Layout for HideNode {
+ fn layout(
+ &self,
+ world: Tracked<dyn World>,
+ styles: StyleChain,
+ regions: &Regions,
+ ) -> SourceResult<Fragment> {
+ let mut fragment = self.0.layout(world, styles, regions)?;
+ for frame in &mut fragment {
+ frame.clear();
+ }
+ Ok(fragment)
+ }
+}
+
+impl Inline for HideNode {}
diff --git a/library/src/layout/mod.rs b/library/src/layout/mod.rs
index 7edc88ad..8f9337ba 100644
--- a/library/src/layout/mod.rs
+++ b/library/src/layout/mod.rs
@@ -5,9 +5,12 @@ mod columns;
mod container;
mod flow;
mod grid;
+mod hide;
mod pad;
mod page;
+mod par;
mod place;
+mod repeat;
mod spacing;
mod stack;
mod transform;
@@ -17,9 +20,12 @@ pub use self::columns::*;
pub use self::container::*;
pub use self::flow::*;
pub use self::grid::*;
+pub use self::hide::*;
pub use self::pad::*;
pub use self::page::*;
+pub use self::par::*;
pub use self::place::*;
+pub use self::repeat::*;
pub use self::spacing::*;
pub use self::stack::*;
pub use self::transform::*;
@@ -36,14 +42,11 @@ use typst::model::{
};
use typst::World;
+use crate::basics::{DescNode, EnumNode, ListItem, ListNode, DESC, ENUM, LIST};
+use crate::meta::DocumentNode;
use crate::prelude::*;
use crate::shared::BehavedBuilder;
-use crate::structure::{
- DescNode, DocumentNode, EnumNode, ListItem, ListNode, DESC, ENUM, LIST,
-};
-use crate::text::{
- LinebreakNode, ParNode, ParbreakNode, SmartQuoteNode, SpaceNode, TextNode,
-};
+use crate::text::{LinebreakNode, SmartQuoteNode, SpaceNode, TextNode};
/// Root-level layout.
#[capability]
diff --git a/library/src/layout/par.rs b/library/src/layout/par.rs
new file mode 100644
index 00000000..82bea1b5
--- /dev/null
+++ b/library/src/layout/par.rs
@@ -0,0 +1,1190 @@
+use unicode_bidi::{BidiInfo, Level as BidiLevel};
+use unicode_script::{Script, UnicodeScript};
+use xi_unicode::LineBreakIterator;
+
+use typst::model::Key;
+
+use super::{HNode, RepeatNode, Spacing};
+use crate::prelude::*;
+use crate::text::{
+ shape, LinebreakNode, Quoter, Quotes, ShapedText, SmartQuoteNode, SpaceNode, TextNode,
+};
+
+/// Arrange text, spacing and inline-level nodes into a paragraph.
+#[derive(Hash)]
+pub struct ParNode(pub StyleVec<Content>);
+
+#[node]
+impl ParNode {
+ /// The indent the first line of a consecutive paragraph should have.
+ #[property(resolve)]
+ pub const INDENT: Length = Length::zero();
+ /// The spacing between lines.
+ #[property(resolve)]
+ pub const LEADING: Length = Em::new(0.65).into();
+ /// How to align text and inline objects in their line.
+ #[property(resolve)]
+ pub const ALIGN: HorizontalAlign = HorizontalAlign(GenAlign::Start);
+ /// Whether to justify text in its line.
+ pub const JUSTIFY: bool = false;
+ /// How to determine line breaks.
+ pub const LINEBREAKS: Smart<Linebreaks> = Smart::Auto;
+
+ fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> {
+ // The paragraph constructor is special: It doesn't create a paragraph
+ // node. Instead, it just ensures that the passed content lives in a
+ // separate paragraph and styles it.
+ Ok(Content::sequence(vec![
+ ParbreakNode.pack(),
+ args.expect("body")?,
+ ParbreakNode.pack(),
+ ]))
+ }
+}
+
+impl ParNode {
+ /// Layout the paragraph into a collection of lines.
+ #[comemo::memoize]
+ pub fn layout(
+ &self,
+ world: Tracked<dyn World>,
+ styles: StyleChain,
+ regions: &Regions,
+ consecutive: bool,
+ ) -> SourceResult<Fragment> {
+ // Collect all text into one string for BiDi analysis.
+ let (text, segments) = collect(self, &styles, consecutive);
+
+ // Perform BiDi analysis and then prepare paragraph layout by building a
+ // representation on which we can do line breaking without layouting
+ // each and every line from scratch.
+ let p = prepare(world, self, &text, segments, regions, styles)?;
+
+ // Break the paragraph into lines.
+ let lines = linebreak(&p, regions.first.x);
+
+ // Stack the lines into one frame per region.
+ finalize(&p, &lines, regions)
+ }
+}
+
+impl Debug for ParNode {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("Par ")?;
+ self.0.fmt(f)
+ }
+}
+
+/// A horizontal alignment.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct HorizontalAlign(pub GenAlign);
+
+castable! {
+ HorizontalAlign,
+ Expected: "alignment",
+ @align: GenAlign => match align.axis() {
+ Axis::X => Self(*align),
+ Axis::Y => Err("must be horizontal")?,
+ },
+}
+
+impl Resolve for HorizontalAlign {
+ type Output = Align;
+
+ fn resolve(self, styles: StyleChain) -> Self::Output {
+ self.0.resolve(styles)
+ }
+}
+
+/// How to determine line breaks in a paragraph.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Linebreaks {
+ /// Determine the linebreaks in a simple first-fit style.
+ Simple,
+ /// Optimize the linebreaks for the whole paragraph.
+ Optimized,
+}
+
+castable! {
+ Linebreaks,
+ Expected: "string",
+ Value::Str(string) => match string.as_str() {
+ "simple" => Self::Simple,
+ "optimized" => Self::Optimized,
+ _ => Err(r#"expected "simple" or "optimized""#)?,
+ },
+}
+
+/// A paragraph break.
+#[derive(Debug, Hash)]
+pub struct ParbreakNode;
+
+#[node(Unlabellable)]
+impl ParbreakNode {
+ fn construct(_: &Vm, _: &mut Args) -> SourceResult<Content> {
+ Ok(Self.pack())
+ }
+}
+
+impl Unlabellable for ParbreakNode {}
+
+/// Range of a substring of text.
+type Range = std::ops::Range<usize>;
+
+// The characters by which spacing, inline content and pins are replaced in the
+// paragraph's full text.
+const SPACING_REPLACE: char = ' '; // Space
+const NODE_REPLACE: char = '\u{FFFC}'; // Object Replacement Character
+
+/// A paragraph representation in which children are already layouted and text
+/// is already preshaped.
+///
+/// In many cases, we can directly reuse these results when constructing a line.
+/// Only when a line break falls onto a text index that is not safe-to-break per
+/// rustybuzz, we have to reshape that portion.
+struct Preparation<'a> {
+ /// The compilation environment.
+ world: Tracked<'a, dyn World>,
+ /// Bidirectional text embedding levels for the paragraph.
+ bidi: BidiInfo<'a>,
+ /// Text runs, spacing and layouted nodes.
+ items: Vec<Item<'a>>,
+ /// The styles shared by all children.
+ styles: StyleChain<'a>,
+ /// Whether to hyphenate if it's the same for all children.
+ hyphenate: Option<bool>,
+ /// The text language if it's the same for all children.
+ lang: Option<Lang>,
+ /// The paragraph's resolved alignment.
+ align: Align,
+ /// Whether to justify the paragraph.
+ justify: bool,
+}
+
+impl<'a> Preparation<'a> {
+ /// Find the item that contains the given `text_offset`.
+ fn find(&self, text_offset: usize) -> Option<&Item<'a>> {
+ let mut cursor = 0;
+ for item in &self.items {
+ let end = cursor + item.len();
+ if (cursor..end).contains(&text_offset) {
+ return Some(item);
+ }
+ cursor = end;
+ }
+ None
+ }
+
+ /// Return the items that intersect the given `text_range`.
+ ///
+ /// Returns the expanded range around the items and the items.
+ fn slice(&self, text_range: Range) -> (Range, &[Item<'a>]) {
+ let mut cursor = 0;
+ let mut start = 0;
+ let mut end = 0;
+ let mut expanded = text_range.clone();
+
+ for (i, item) in self.items.iter().enumerate() {
+ if cursor <= text_range.start {
+ start = i;
+ expanded.start = cursor;
+ }
+
+ let len = item.len();
+ if cursor < text_range.end || cursor + len <= text_range.end {
+ end = i + 1;
+ expanded.end = cursor + len;
+ } else {
+ break;
+ }
+
+ cursor += len;
+ }
+
+ (expanded, &self.items[start..end])
+ }
+}
+
+/// A segment of one or multiple collapsed children.
+#[derive(Debug, Copy, Clone)]
+enum Segment<'a> {
+ /// One or multiple collapsed text or text-equivalent children. Stores how
+ /// long the segment is (in bytes of the full text string).
+ Text(usize),
+ /// Horizontal spacing between other segments.
+ Spacing(Spacing),
+ /// Arbitrary inline-level content.
+ Inline(&'a Content),
+}
+
+impl Segment<'_> {
+ /// The text length of the item.
+ fn len(&self) -> usize {
+ match *self {
+ Self::Text(len) => len,
+ Self::Spacing(_) => SPACING_REPLACE.len_utf8(),
+ Self::Inline(_) => NODE_REPLACE.len_utf8(),
+ }
+ }
+}
+
+/// A prepared item in a paragraph layout.
+#[derive(Debug)]
+enum Item<'a> {
+ /// A shaped text run with consistent style and direction.
+ Text(ShapedText<'a>),
+ /// Absolute spacing between other items.
+ Absolute(Abs),
+ /// Fractional spacing between other items.
+ Fractional(Fr),
+ /// Layouted inline-level content.
+ Frame(Frame),
+ /// A repeating node that fills the remaining space in a line.
+ Repeat(&'a RepeatNode, StyleChain<'a>),
+}
+
+impl<'a> Item<'a> {
+ /// If this a text item, return it.
+ fn text(&self) -> Option<&ShapedText<'a>> {
+ match self {
+ Self::Text(shaped) => Some(shaped),
+ _ => None,
+ }
+ }
+
+ /// The text length of the item.
+ fn len(&self) -> usize {
+ match self {
+ Self::Text(shaped) => shaped.text.len(),
+ Self::Absolute(_) | Self::Fractional(_) => SPACING_REPLACE.len_utf8(),
+ Self::Frame(_) | Self::Repeat(_, _) => NODE_REPLACE.len_utf8(),
+ }
+ }
+
+ /// The natural layouted width of the item.
+ fn width(&self) -> Abs {
+ match self {
+ Self::Text(shaped) => shaped.width,
+ Self::Absolute(v) => *v,
+ Self::Frame(frame) => frame.width(),
+ Self::Fractional(_) | Self::Repeat(_, _) => Abs::zero(),
+ }
+ }
+}
+
+/// 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 comitting to building the
+/// line's frame.
+///
+/// At most two paragraph items must be created individually for this line: The
+/// first and last one since they may be broken apart by the start or end of the
+/// line, respectively. But even those can partially reuse previous results when
+/// the break index is safe-to-break per rustybuzz.
+struct Line<'a> {
+ /// Bidi information about the paragraph.
+ bidi: &'a BidiInfo<'a>,
+ /// The trimmed range the line spans in the paragraph.
+ trimmed: Range,
+ /// The untrimmed end where the line ends.
+ end: usize,
+ /// A reshaped text item if the line sliced up a text item at the start.
+ first: Option<Item<'a>>,
+ /// Inner items which don't need to be reprocessed.
+ inner: &'a [Item<'a>],
+ /// A reshaped text item if the line sliced up a text item at the end. If
+ /// there is only one text item, this takes precedence over `first`.
+ last: Option<Item<'a>>,
+ /// The width of the line.
+ width: Abs,
+ /// Whether the line should be justified.
+ justify: bool,
+ /// Whether the line ends with a hyphen or dash, either naturally or through
+ /// hyphenation.
+ dash: bool,
+}
+
+impl<'a> Line<'a> {
+ /// Iterate over the line's items.
+ fn items(&self) -> impl Iterator<Item = &Item<'a>> {
+ self.first.iter().chain(self.inner).chain(&self.last)
+ }
+
+ /// Return items that intersect the given `text_range`.
+ fn slice(&self, text_range: Range) -> impl Iterator<Item = &Item<'a>> {
+ let mut cursor = self.trimmed.start;
+ let mut start = 0;
+ let mut end = 0;
+
+ for (i, item) in self.items().enumerate() {
+ if cursor <= text_range.start {
+ start = i;
+ }
+
+ let len = item.len();
+ if cursor < text_range.end || cursor + len <= text_range.end {
+ end = i + 1;
+ } else {
+ break;
+ }
+
+ cursor += len;
+ }
+
+ self.items().skip(start).take(end - start)
+ }
+
+ /// How many justifiable glyphs the line contains.
+ fn justifiables(&self) -> usize {
+ let mut count = 0;
+ for shaped in self.items().filter_map(Item::text) {
+ count += shaped.justifiables();
+ }
+ count
+ }
+
+ /// How much of the line is stretchable spaces.
+ fn stretch(&self) -> Abs {
+ let mut stretch = Abs::zero();
+ for shaped in self.items().filter_map(Item::text) {
+ stretch += shaped.stretch();
+ }
+ stretch
+ }
+
+ /// The sum of fractions in the line.
+ fn fr(&self) -> Fr {
+ self.items()
+ .filter_map(|item| match item {
+ Item::Fractional(fr) => Some(*fr),
+ Item::Repeat(_, _) => Some(Fr::one()),
+ _ => None,
+ })
+ .sum()
+ }
+}
+
+/// Collect all text of the paragraph into one string. This also performs
+/// string-level preprocessing like case transformations.
+fn collect<'a>(
+ par: &'a ParNode,
+ styles: &'a StyleChain<'a>,
+ consecutive: bool,
+) -> (String, Vec<(Segment<'a>, StyleChain<'a>)>) {
+ let mut full = String::new();
+ let mut quoter = Quoter::new();
+ let mut segments = vec![];
+ let mut iter = par.0.iter().peekable();
+
+ if consecutive {
+ let indent = styles.get(ParNode::INDENT);
+ if !indent.is_zero()
+ && par
+ .0
+ .items()
+ .find_map(|child| {
+ if child.is::<TextNode>() || child.is::<SmartQuoteNode>() {
+ Some(true)
+ } else if child.has::<dyn Inline>() {
+ Some(false)
+ } else {
+ None
+ }
+ })
+ .unwrap_or_default()
+ {
+ full.push(SPACING_REPLACE);
+ segments.push((Segment::Spacing(indent.into()), *styles));
+ }
+ }
+
+ while let Some((child, map)) = iter.next() {
+ let styles = styles.chain(map);
+ let segment = if child.is::<SpaceNode>() {
+ full.push(' ');
+ Segment::Text(1)
+ } else if let Some(node) = child.to::<TextNode>() {
+ let prev = full.len();
+ if let Some(case) = styles.get(TextNode::CASE) {
+ full.push_str(&case.apply(&node.0));
+ } else {
+ full.push_str(&node.0);
+ }
+ Segment::Text(full.len() - prev)
+ } else if let Some(node) = child.to::<LinebreakNode>() {
+ let c = if node.justify { '\u{2028}' } else { '\n' };
+ full.push(c);
+ Segment::Text(c.len_utf8())
+ } else if let Some(node) = child.to::<SmartQuoteNode>() {
+ let prev = full.len();
+ if styles.get(TextNode::SMART_QUOTES) {
+ let lang = styles.get(TextNode::LANG);
+ let region = styles.get(TextNode::REGION);
+ let quotes = Quotes::from_lang(lang, region);
+ let peeked = iter.peek().and_then(|(child, _)| {
+ if let Some(node) = child.to::<TextNode>() {
+ node.0.chars().next()
+ } else if child.is::<SmartQuoteNode>() {
+ Some('"')
+ } else if child.is::<SpaceNode>() || child.is::<HNode>() {
+ Some(SPACING_REPLACE)
+ } else {
+ Some(NODE_REPLACE)
+ }
+ });
+
+ full.push_str(quoter.quote(&quotes, node.double, peeked));
+ } else {
+ full.push(if node.double { '"' } else { '\'' });
+ }
+ Segment::Text(full.len() - prev)
+ } else if let Some(&node) = child.to::<HNode>() {
+ full.push(SPACING_REPLACE);
+ Segment::Spacing(node.amount)
+ } else if child.has::<dyn Inline>() {
+ full.push(NODE_REPLACE);
+ Segment::Inline(child)
+ } else {
+ panic!("unexpected par child: {child:?}");
+ };
+
+ if let Some(last) = full.chars().last() {
+ quoter.last(last);
+ }
+
+ if let (Some((Segment::Text(last_len), last_styles)), Segment::Text(len)) =
+ (segments.last_mut(), segment)
+ {
+ if *last_styles == styles {
+ *last_len += len;
+ continue;
+ }
+ }
+
+ segments.push((segment, styles));
+ }
+
+ (full, segments)
+}
+
+/// Prepare paragraph layout by shaping the whole paragraph and layouting all
+/// contained inline-level content.
+fn prepare<'a>(
+ world: Tracked<'a, dyn World>,
+ par: &'a ParNode,
+ text: &'a str,
+ segments: Vec<(Segment<'a>, StyleChain<'a>)>,
+ regions: &Regions,
+ styles: StyleChain<'a>,
+) -> SourceResult<Preparation<'a>> {
+ let bidi = BidiInfo::new(
+ text,
+ match styles.get(TextNode::DIR) {
+ Dir::LTR => Some(BidiLevel::ltr()),
+ Dir::RTL => Some(BidiLevel::rtl()),
+ _ => None,
+ },
+ );
+
+ let mut cursor = 0;
+ let mut items = vec![];
+
+ // Shape / layout the children and collect them into items.
+ for (segment, styles) in segments {
+ let end = cursor + segment.len();
+ match segment {
+ Segment::Text(_) => {
+ shape_range(&mut items, world, &bidi, cursor..end, styles);
+ }
+ Segment::Spacing(spacing) => match spacing {
+ Spacing::Relative(v) => {
+ let resolved = v.resolve(styles).relative_to(regions.base.x);
+ items.push(Item::Absolute(resolved));
+ }
+ Spacing::Fractional(v) => {
+ items.push(Item::Fractional(v));
+ }
+ },
+ Segment::Inline(inline) => {
+ if let Some(repeat) = inline.to::<RepeatNode>() {
+ items.push(Item::Repeat(repeat, styles));
+ } else {
+ let size = Size::new(regions.first.x, regions.base.y);
+ let pod = Regions::one(size, regions.base, Axes::splat(false));
+ let mut frame = inline.layout(world, styles, &pod)?.into_frame();
+ frame.translate(Point::with_y(styles.get(TextNode::BASELINE)));
+ items.push(Item::Frame(frame));
+ }
+ }
+ }
+
+ cursor = end;
+ }
+
+ Ok(Preparation {
+ world,
+ bidi,
+ items,
+ styles,
+ hyphenate: shared_get(styles, &par.0, TextNode::HYPHENATE),
+ lang: shared_get(styles, &par.0, TextNode::LANG),
+ align: styles.get(ParNode::ALIGN),
+ justify: styles.get(ParNode::JUSTIFY),
+ })
+}
+
+/// Group a range of text by BiDi level and script, shape the runs and generate
+/// items for them.
+fn shape_range<'a>(
+ items: &mut Vec<Item<'a>>,
+ world: Tracked<dyn World>,
+ bidi: &BidiInfo<'a>,
+ range: Range,
+ styles: StyleChain<'a>,
+) {
+ let mut process = |text, level: BidiLevel| {
+ let dir = if level.is_ltr() { Dir::LTR } else { Dir::RTL };
+ let shaped = shape(world, text, styles, dir);
+ items.push(Item::Text(shaped));
+ };
+
+ let mut prev_level = BidiLevel::ltr();
+ let mut prev_script = Script::Unknown;
+ let mut cursor = range.start;
+
+ // Group by embedding level and script.
+ for i in cursor..range.end {
+ if !bidi.text.is_char_boundary(i) {
+ continue;
+ }
+
+ let level = bidi.levels[i];
+ let script =
+ bidi.text[i..].chars().next().map_or(Script::Unknown, |c| c.script());
+
+ if level != prev_level || !is_compatible(script, prev_script) {
+ if cursor < i {
+ process(&bidi.text[cursor..i], prev_level);
+ }
+ cursor = i;
+ prev_level = level;
+ prev_script = script;
+ } else if is_generic_script(prev_script) {
+ prev_script = script;
+ }
+ }
+
+ process(&bidi.text[cursor..range.end], prev_level);
+}
+
+/// Whether this is not a specific script.
+fn is_generic_script(script: Script) -> bool {
+ matches!(script, Script::Unknown | Script::Common | Script::Inherited)
+}
+
+/// Whether these script can be part of the same shape run.
+fn is_compatible(a: Script, b: Script) -> bool {
+ is_generic_script(a) || is_generic_script(b) || a == b
+}
+
+/// Get a style property, but only if it is the same for all children of the
+/// paragraph.
+fn shared_get<'a, K: Key>(
+ styles: StyleChain<'a>,
+ children: &StyleVec<Content>,
+ key: K,
+) -> Option<K::Output<'a>> {
+ children
+ .styles()
+ .all(|map| !map.contains(key))
+ .then(|| styles.get(key))
+}
+
+/// Find suitable linebreaks.
+fn linebreak<'a>(p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> {
+ let linebreaks = p.styles.get(ParNode::LINEBREAKS).unwrap_or_else(|| {
+ if p.styles.get(ParNode::JUSTIFY) {
+ Linebreaks::Optimized
+ } else {
+ Linebreaks::Simple
+ }
+ });
+
+ match linebreaks {
+ Linebreaks::Simple => linebreak_simple(p, width),
+ Linebreaks::Optimized => linebreak_optimized(p, width),
+ }
+}
+
+/// Perform line breaking in simple first-fit style. This means that we build
+/// lines greedily, always taking the longest possible line. This may lead to
+/// very unbalanced line, but is fast and simple.
+fn linebreak_simple<'a>(p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> {
+ let mut lines = vec![];
+ let mut start = 0;
+ let mut last = None;
+
+ for (end, mandatory, hyphen) in breakpoints(p) {
+ // Compute the line and its size.
+ let mut attempt = line(p, start..end, mandatory, hyphen);
+
+ // If the line doesn't fit anymore, we push the last fitting attempt
+ // into the stack and rebuild the line from the attempt's end. The
+ // resulting line cannot be broken up further.
+ if !width.fits(attempt.width) {
+ if let Some((last_attempt, last_end)) = last.take() {
+ lines.push(last_attempt);
+ start = last_end;
+ attempt = line(p, start..end, mandatory, hyphen);
+ }
+ }
+
+ // Finish the current line if there is a mandatory line break (i.e.
+ // due to "\n") or if the line doesn't fit horizontally already
+ // since then no shorter line will be possible.
+ if mandatory || !width.fits(attempt.width) {
+ lines.push(attempt);
+ start = end;
+ last = None;
+ } else {
+ last = Some((attempt, end));
+ }
+ }
+
+ if let Some((line, _)) = last {
+ lines.push(line);
+ }
+
+ lines
+}
+
+/// Perform line breaking in optimized Knuth-Plass style. Here, we use more
+/// context to determine the line breaks than in the simple first-fit style. For
+/// example, we might choose to cut a line short even though there is still a
+/// bit of space to improve the fit of one of the following lines. The
+/// Knuth-Plass algorithm is based on the idea of "cost". A line which has a
+/// very tight or very loose fit has a higher cost than one that is just right.
+/// Ending a line with a hyphen incurs extra cost and endings two successive
+/// lines with hyphens even more.
+///
+/// To find the layout with the minimal total cost the algorithm uses dynamic
+/// programming: For each possible breakpoint it determines the optimal
+/// paragraph layout _up to that point_. It walks over all possible start points
+/// for a line ending at that point and finds the one for which the cost of the
+/// line plus the cost of the optimal paragraph up to the start point (already
+/// computed and stored in dynamic programming table) is minimal. The final
+/// result is simply the layout determined for the last breakpoint at the end of
+/// text.
+fn linebreak_optimized<'a>(p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> {
+ /// The cost of a line or paragraph layout.
+ type Cost = f64;
+
+ /// An entry in the dynamic programming table.
+ struct Entry<'a> {
+ pred: usize,
+ total: Cost,
+ line: Line<'a>,
+ }
+
+ // Cost parameters.
+ const HYPH_COST: Cost = 0.5;
+ const CONSECUTIVE_DASH_COST: Cost = 30.0;
+ const MAX_COST: Cost = 1_000_000.0;
+ const MIN_COST: Cost = -MAX_COST;
+ const MIN_RATIO: f64 = -0.15;
+
+ // Dynamic programming table.
+ let mut active = 0;
+ let mut table = vec![Entry {
+ pred: 0,
+ total: 0.0,
+ line: line(p, 0..0, false, false),
+ }];
+
+ let em = p.styles.get(TextNode::SIZE);
+
+ for (end, mandatory, hyphen) in breakpoints(p) {
+ let k = table.len();
+ let eof = end == p.bidi.text.len();
+ let mut best: Option<Entry> = None;
+
+ // Find the optimal predecessor.
+ for (i, pred) in table.iter_mut().enumerate().skip(active) {
+ // Layout the line.
+ let start = pred.line.end;
+ let attempt = line(p, start..end, mandatory, hyphen);
+
+ // Determine how much the line's spaces would need to be stretched
+ // to make it the desired width.
+ let delta = width - attempt.width;
+ let mut ratio = delta / attempt.stretch();
+ if ratio.is_infinite() {
+ ratio = delta / (em / 2.0);
+ }
+
+ // At some point, it doesn't matter any more.
+ ratio = ratio.min(10.0);
+
+ // Determine the cost of the line.
+ let min_ratio = if attempt.justify { MIN_RATIO } else { 0.0 };
+ let mut cost = if ratio < min_ratio {
+ // The line is overfull. This is the case if
+ // - justification is on, but we'd need to shrink too much
+ // - justification is off and the line just doesn't fit
+ // Since any longer line will also be overfull, we can deactive
+ // this breakpoint.
+ active = i + 1;
+ MAX_COST
+ } else if mandatory || eof {
+ // This is a mandatory break and the line is not overfull, so it
+ // has minimum cost. All breakpoints before this one become
+ // inactive since no line can span above the mandatory break.
+ active = k;
+ MIN_COST + if attempt.justify { ratio.powi(3).abs() } else { 0.0 }
+ } else {
+ // Normal line with cost of |ratio^3|.
+ ratio.powi(3).abs()
+ };
+
+ // Penalize hyphens.
+ if hyphen {
+ cost += HYPH_COST;
+ }
+
+ // Penalize two consecutive dashes (not necessarily hyphens) extra.
+ if attempt.dash && pred.line.dash {
+ cost += CONSECUTIVE_DASH_COST;
+ }
+
+ // The total cost of this line and its chain of predecessors.
+ let total = pred.total + cost;
+
+ // If this attempt is better than what we had before, take it!
+ if best.as_ref().map_or(true, |best| best.total >= total) {
+ best = Some(Entry { pred: i, total, line: attempt });
+ }
+ }
+
+ table.push(best.unwrap());
+ }
+
+ // Retrace the best path.
+ let mut lines = vec![];
+ let mut idx = table.len() - 1;
+ while idx != 0 {
+ table.truncate(idx + 1);
+ let entry = table.pop().unwrap();
+ lines.push(entry.line);
+ idx = entry.pred;
+ }
+
+ lines.reverse();
+ lines
+}
+
+/// Determine all possible points in the text where lines can broken.
+///
+/// Returns for each breakpoint the text index, whether the break is mandatory
+/// (after `\n`) and whether a hyphen is required (when breaking inside of a
+/// word).
+fn breakpoints<'a>(p: &'a Preparation) -> Breakpoints<'a> {
+ Breakpoints {
+ p,
+ linebreaks: LineBreakIterator::new(p.bidi.text),
+ syllables: None,
+ offset: 0,
+ suffix: 0,
+ end: 0,
+ mandatory: false,
+ }
+}
+
+/// An iterator over the line break opportunities in a text.
+struct Breakpoints<'a> {
+ /// The paragraph's items.
+ p: &'a Preparation<'a>,
+ /// The inner iterator over the unicode line break opportunities.
+ linebreaks: LineBreakIterator<'a>,
+ /// Iterator over syllables of the current word.
+ syllables: Option<hypher::Syllables<'a>>,
+ /// The current text offset.
+ offset: usize,
+ /// The trimmed end of the current word.
+ suffix: usize,
+ /// The untrimmed end of the current word.
+ end: usize,
+ /// Whether the break after the current word is mandatory.
+ mandatory: bool,
+}
+
+impl Iterator for Breakpoints<'_> {
+ type Item = (usize, bool, bool);
+
+ fn next(&mut self) -> Option<Self::Item> {
+ // If we're currently in a hyphenated "word", process the next syllable.
+ if let Some(syllable) = self.syllables.as_mut().and_then(Iterator::next) {
+ self.offset += syllable.len();
+ if self.offset == self.suffix {
+ self.offset = self.end;
+ }
+
+ // Filter out hyphenation opportunities where hyphenation was
+ // actually disabled.
+ let hyphen = self.offset < self.end;
+ if hyphen && !self.hyphenate(self.offset) {
+ return self.next();
+ }
+
+ return Some((self.offset, self.mandatory && !hyphen, hyphen));
+ }
+
+ // Get the next "word".
+ (self.end, self.mandatory) = self.linebreaks.next()?;
+
+ // Hyphenate the next word.
+ if self.p.hyphenate != Some(false) {
+ if let Some(lang) = self.lang(self.offset) {
+ let word = &self.p.bidi.text[self.offset..self.end];
+ let trimmed = word.trim_end_matches(|c: char| !c.is_alphabetic());
+ if !trimmed.is_empty() {
+ self.suffix = self.offset + trimmed.len();
+ self.syllables = Some(hypher::hyphenate(trimmed, lang));
+ return self.next();
+ }
+ }
+ }
+
+ self.offset = self.end;
+ Some((self.end, self.mandatory, false))
+ }
+}
+
+impl Breakpoints<'_> {
+ /// Whether hyphenation is enabled at the given offset.
+ fn hyphenate(&self, offset: usize) -> bool {
+ self.p
+ .hyphenate
+ .or_else(|| {
+ let shaped = self.p.find(offset)?.text()?;
+ Some(shaped.styles.get(TextNode::HYPHENATE))
+ })
+ .unwrap_or(false)
+ }
+
+ /// The text language at the given offset.
+ fn lang(&self, offset: usize) -> Option<hypher::Lang> {
+ let lang = self.p.lang.or_else(|| {
+ let shaped = self.p.find(offset)?.text()?;
+ Some(shaped.styles.get(TextNode::LANG))
+ })?;
+
+ let bytes = lang.as_str().as_bytes().try_into().ok()?;
+ hypher::Lang::from_iso(bytes)
+ }
+}
+
+/// Create a line which spans the given range.
+fn line<'a>(
+ p: &'a Preparation,
+ mut range: Range,
+ mandatory: bool,
+ hyphen: bool,
+) -> Line<'a> {
+ let end = range.end;
+ let mut justify = p.justify && end < p.bidi.text.len() && !mandatory;
+
+ if range.is_empty() {
+ return Line {
+ bidi: &p.bidi,
+ end,
+ trimmed: range,
+ first: None,
+ inner: &[],
+ last: None,
+ width: Abs::zero(),
+ justify,
+ dash: false,
+ };
+ }
+
+ // Slice out the relevant items.
+ let (expanded, mut inner) = p.slice(range.clone());
+ let mut width = Abs::zero();
+
+ // Reshape the last item if it's split in half or hyphenated.
+ let mut last = None;
+ let mut dash = false;
+ if let Some((Item::Text(shaped), before)) = inner.split_last() {
+ // Compute the range we want to shape, trimming whitespace at the
+ // end of the line.
+ let base = expanded.end - shaped.text.len();
+ let start = range.start.max(base);
+ let text = &p.bidi.text[start..range.end];
+ let trimmed = text.trim_end();
+ range.end = start + trimmed.len();
+
+ // Deal with hyphens, dashes and justification.
+ let shy = trimmed.ends_with('\u{ad}');
+ dash = hyphen || shy || trimmed.ends_with(['-', '–', '—']);
+ justify |= text.ends_with('\u{2028}');
+
+ // Usually, we don't want to shape an empty string because:
+ // - We don't want the height of trimmed whitespace in a different
+ // font to be considered for the line height.
+ // - Even if it's in the same font, its unnecessary.
+ //
+ // There is one exception though. When the whole line is empty, we
+ // need the shaped empty string to make the line the appropriate
+ // height. That is the case exactly if the string is empty and there
+ // are no other items in the line.
+ if hyphen || start + shaped.text.len() > range.end {
+ if hyphen || start < range.end || before.is_empty() {
+ let shifted = start - base..range.end - base;
+ let mut reshaped = shaped.reshape(p.world, shifted);
+ if hyphen || shy {
+ reshaped.push_hyphen(p.world);
+ }
+ width += reshaped.width;
+ last = Some(Item::Text(reshaped));
+ }
+
+ inner = before;
+ }
+ }
+
+ // Reshape the start item if it's split in half.
+ let mut first = None;
+ if let Some((Item::Text(shaped), after)) = inner.split_first() {
+ // Compute the range we want to shape.
+ let base = expanded.start;
+ let end = range.end.min(base + shaped.text.len());
+
+ // Reshape if necessary.
+ if range.start + shaped.text.len() > end {
+ if range.start < end {
+ let shifted = range.start - base..end - base;
+ let reshaped = shaped.reshape(p.world, shifted);
+ width += reshaped.width;
+ first = Some(Item::Text(reshaped));
+ }
+
+ inner = after;
+ }
+ }
+
+ // Measure the inner items.
+ for item in inner {
+ width += item.width();
+ }
+
+ Line {
+ bidi: &p.bidi,
+ trimmed: range,
+ end,
+ first,
+ inner,
+ last,
+ width,
+ justify,
+ dash,
+ }
+}
+
+/// Combine layouted lines into one frame per region.
+fn finalize(
+ p: &Preparation,
+ lines: &[Line],
+ regions: &Regions,
+) -> 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 mut width = regions.first.x;
+ if !regions.expand.x && lines.iter().all(|line| line.fr().is_zero()) {
+ width = lines.iter().map(|line| line.width).max().unwrap_or_default();
+ }
+
+ // Stack the lines into one frame per region.
+ lines
+ .iter()
+ .map(|line| commit(p, line, regions.base, width))
+ .collect::<SourceResult<_>>()
+ .map(Fragment::frames)
+}
+
+/// Commit to a line and build its frame.
+fn commit(p: &Preparation, line: &Line, base: Size, width: Abs) -> SourceResult<Frame> {
+ let mut remaining = width - line.width;
+ let mut offset = Abs::zero();
+
+ // Reorder the line from logical to visual order.
+ let reordered = reorder(line);
+
+ // Handle hanging punctuation to the left.
+ if let Some(Item::Text(text)) = reordered.first() {
+ if let Some(glyph) = text.glyphs.first() {
+ if !text.dir.is_positive()
+ && text.styles.get(TextNode::OVERHANG)
+ && (reordered.len() > 1 || text.glyphs.len() > 1)
+ {
+ let amount = overhang(glyph.c) * glyph.x_advance.at(text.size);
+ offset -= amount;
+ remaining += amount;
+ }
+ }
+ }
+
+ // Handle hanging punctuation to the right.
+ if let Some(Item::Text(text)) = reordered.last() {
+ if let Some(glyph) = text.glyphs.last() {
+ if text.dir.is_positive()
+ && text.styles.get(TextNode::OVERHANG)
+ && (reordered.len() > 1 || text.glyphs.len() > 1)
+ {
+ let amount = overhang(glyph.c) * glyph.x_advance.at(text.size);
+ remaining += amount;
+ }
+ }
+ }
+
+ // Determine how much to justify each space.
+ let fr = line.fr();
+ let mut justification = Abs::zero();
+ if remaining < Abs::zero() || (line.justify && fr.is_zero()) {
+ let justifiables = line.justifiables();
+ if justifiables > 0 {
+ justification = remaining / justifiables as f64;
+ remaining = Abs::zero();
+ }
+ }
+
+ let mut top = Abs::zero();
+ let mut bottom = Abs::zero();
+
+ // Build the frames and determine the height and baseline.
+ let mut frames = vec![];
+ for item in reordered {
+ let mut push = |offset: &mut Abs, frame: Frame| {
+ let width = frame.width();
+ top.set_max(frame.baseline());
+ bottom.set_max(frame.size().y - frame.baseline());
+ frames.push((*offset, frame));
+ *offset += width;
+ };
+
+ match item {
+ Item::Absolute(v) => {
+ offset += *v;
+ }
+ Item::Fractional(v) => {
+ offset += v.share(fr, remaining);
+ }
+ Item::Text(shaped) => {
+ let frame = shaped.build(p.world, justification);
+ push(&mut offset, frame);
+ }
+ Item::Frame(frame) => {
+ push(&mut offset, frame.clone());
+ }
+ Item::Repeat(repeat, styles) => {
+ let before = offset;
+ let fill = Fr::one().share(fr, remaining);
+ let size = Size::new(fill, base.y);
+ let pod = Regions::one(size, base, Axes::new(false, false));
+ let frame = repeat.layout(p.world, *styles, &pod)?.into_frame();
+ let width = frame.width();
+ let count = (fill / width).floor();
+ let remaining = fill % width;
+ let apart = remaining / (count - 1.0);
+ if count == 1.0 {
+ offset += p.align.position(remaining);
+ }
+ if width > Abs::zero() {
+ for _ in 0..(count as usize).min(1000) {
+ push(&mut offset, frame.clone());
+ offset += apart;
+ }
+ }
+ offset = before + fill;
+ }
+ }
+ }
+
+ // Remaining space is distributed now.
+ if !fr.is_zero() {
+ remaining = Abs::zero();
+ }
+
+ let size = Size::new(width, top + bottom);
+ let mut output = Frame::new(size);
+ output.set_baseline(top);
+
+ // Construct the line's frame.
+ for (offset, frame) in frames {
+ let x = offset + p.align.position(remaining);
+ let y = top - frame.baseline();
+ output.push_frame(Point::new(x, y), frame);
+ }
+
+ Ok(output)
+}
+
+/// Return a line's items in visual order.
+fn reorder<'a>(line: &'a Line<'a>) -> Vec<&Item<'a>> {
+ let mut reordered = vec![];
+
+ // The bidi crate doesn't like empty lines.
+ if line.trimmed.is_empty() {
+ return line.slice(line.trimmed.clone()).collect();
+ }
+
+ // Find the paragraph that contains the line.
+ let para = line
+ .bidi
+ .paragraphs
+ .iter()
+ .find(|para| para.range.contains(&line.trimmed.start))
+ .unwrap();
+
+ // Compute the reordered ranges in visual order (left to right).
+ let (levels, runs) = line.bidi.visual_runs(para, line.trimmed.clone());
+
+ // Collect the reordered items.
+ for run in runs {
+ // Skip reset L1 runs because handling them would require reshaping
+ // again in some cases.
+ if line.bidi.levels[run.start] != levels[run.start] {
+ continue;
+ }
+
+ let prev = reordered.len();
+ reordered.extend(line.slice(run.clone()));
+
+ if levels[run.start].is_rtl() {
+ reordered[prev..].reverse();
+ }
+ }
+
+ reordered
+}
+
+/// 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 and Ideographic
+ '\u{60C}' | '\u{6D4}' => 0.4,
+ '\u{3001}' | '\u{3002}' => 1.0,
+
+ _ => 0.0,
+ }
+}
diff --git a/library/src/layout/repeat.rs b/library/src/layout/repeat.rs
new file mode 100644
index 00000000..d9323e1d
--- /dev/null
+++ b/library/src/layout/repeat.rs
@@ -0,0 +1,25 @@
+use crate::prelude::*;
+
+/// Repeats content to fill a line.
+#[derive(Debug, Hash)]
+pub struct RepeatNode(pub Content);
+
+#[node(Layout, Inline)]
+impl RepeatNode {
+ fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> {
+ Ok(Self(args.expect("body")?).pack())
+ }
+}
+
+impl Layout for RepeatNode {
+ fn layout(
+ &self,
+ world: Tracked<dyn World>,
+ styles: StyleChain,
+ regions: &Regions,
+ ) -> SourceResult<Fragment> {
+ self.0.layout(world, styles, regions)
+ }
+}
+
+impl Inline for RepeatNode {}
diff --git a/library/src/layout/stack.rs b/library/src/layout/stack.rs
index 7de1d34a..c1073b26 100644
--- a/library/src/layout/stack.rs
+++ b/library/src/layout/stack.rs
@@ -1,8 +1,7 @@
use typst::model::StyledNode;
-use super::{AlignNode, Spacing};
+use super::{AlignNode, ParNode, Spacing};
use crate::prelude::*;
-use crate::text::ParNode;
/// Arrange content and spacing along an axis.
#[derive(Debug, Hash)]