From b0f4b13f6d4a1fe7742707d08e11ba03f3542b58 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 25 Feb 2022 20:48:38 +0100 Subject: [PATCH] Basic justification --- src/geom/em.rs | 5 + src/library/par.rs | 277 ++++++----- src/library/text.rs | 914 +++++++++++++++++++------------------ tests/ref/text/justify.png | Bin 0 -> 11615 bytes tests/typ/text/justify.typ | 14 + 5 files changed, 657 insertions(+), 553 deletions(-) create mode 100644 tests/ref/text/justify.png create mode 100644 tests/typ/text/justify.typ diff --git a/src/geom/em.rs b/src/geom/em.rs index af6be7065..b9f1d8978 100644 --- a/src/geom/em.rs +++ b/src/geom/em.rs @@ -27,6 +27,11 @@ impl Em { Self(Scalar(units.into() / units_per_em)) } + /// Create an em length from a length at the given font size. + pub fn from_length(length: Length, font_size: Length) -> Self { + Self(Scalar(length / font_size)) + } + /// Convert to a length at the given font size. pub fn resolve(self, font_size: Length) -> Length { self.get() * font_size diff --git a/src/library/par.rs b/src/library/par.rs index cc5dd9b6d..8b4adf92d 100644 --- a/src/library/par.rs +++ b/src/library/par.rs @@ -32,6 +32,8 @@ impl ParNode { pub const DIR: Dir = Dir::LTR; /// How to align text and inline objects in their line. pub const ALIGN: Align = Align::Left; + /// Whether to justify text in its line. + pub const JUSTIFY: bool = false; /// The spacing between lines (dependent on scaled font size). pub const LEADING: Linear = Relative::new(0.65).into(); /// The extra spacing between paragraphs (dependent on scaled font size). @@ -75,6 +77,7 @@ impl ParNode { styles.set_opt(Self::DIR, dir); styles.set_opt(Self::ALIGN, align); + styles.set_opt(Self::JUSTIFY, args.named("justify")?); styles.set_opt(Self::LEADING, args.named("leading")?); styles.set_opt(Self::SPACING, args.named("spacing")?); styles.set_opt(Self::INDENT, args.named("indent")?); @@ -83,103 +86,6 @@ impl ParNode { } } -impl Layout for ParNode { - fn layout( - &self, - ctx: &mut Context, - regions: &Regions, - styles: StyleChain, - ) -> TypResult>> { - // Collect all text into one string used for BiDi analysis. - let text = self.collect_text(); - let level = Level::from_dir(styles.get(Self::DIR)); - let bidi = BidiInfo::new(&text, level); - - // Prepare paragraph layout by building a representation on which we can - // do line breaking without layouting each and every line from scratch. - let par = ParLayout::new(ctx, self, bidi, regions, &styles)?; - let fonts = &mut ctx.fonts; - let em = styles.get(TextNode::SIZE).abs; - let align = styles.get(ParNode::ALIGN); - let leading = styles.get(ParNode::LEADING).resolve(em); - - // The already determined lines and the current line attempt. - let mut lines = vec![]; - let mut start = 0; - let mut last = None; - - // Find suitable line breaks. - for (end, mandatory) in LineBreakIterator::new(&text) { - // Compute the line and its size. - let mut line = par.line(fonts, start .. end); - - // If the line doesn't fit anymore, we push the last fitting attempt - // into the stack and rebuild the line from its end. The resulting - // line cannot be broken up further. - if !regions.first.x.fits(line.size.x) { - if let Some((last_line, last_end)) = last.take() { - lines.push(last_line); - start = last_end; - line = par.line(fonts, start .. end); - } - } - - // 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 no shorter line will be possible. - if mandatory || !regions.first.x.fits(line.size.x) { - lines.push(line); - start = end; - last = None; - } else { - last = Some((line, end)); - } - } - - if let Some((line, _)) = last { - lines.push(line); - } - - // Determine the paragraph's width: Fit to width if we shoudn't expand - // and there's no fractional spacing. - let mut width = regions.first.x; - if !regions.expand.x && lines.iter().all(|line| line.fr.is_zero()) { - width = lines.iter().map(|line| line.size.x).max().unwrap_or_default(); - } - - // State for final frame building. - let mut regions = regions.clone(); - let mut finished = vec![]; - let mut first = true; - let mut output = Frame::new(Size::with_x(width)); - - // Stack the lines into one frame per region. - for line in lines { - while !regions.first.y.fits(line.size.y) && !regions.in_last() { - finished.push(Arc::new(output)); - output = Frame::new(Size::with_x(width)); - regions.next(); - first = true; - } - - if !first { - output.size.y += leading; - } - - let frame = line.build(fonts, width, align); - let pos = Point::with_y(output.size.y); - output.size.y += frame.size.y; - output.merge_frame(pos, frame); - - regions.first.y -= line.size.y + leading; - first = false; - } - - finished.push(Arc::new(output)); - Ok(finished) - } -} - impl ParNode { /// Concatenate all text in the paragraph into one string, replacing spacing /// with a space character and other non-text nodes with the object @@ -212,6 +118,127 @@ impl ParNode { } } +impl Layout for ParNode { + fn layout( + &self, + ctx: &mut Context, + regions: &Regions, + styles: StyleChain, + ) -> TypResult>> { + // Collect all text into one string and perform BiDi analysis. + let text = self.collect_text(); + let level = Level::from_dir(styles.get(Self::DIR)); + let bidi = BidiInfo::new(&text, level); + + // Prepare paragraph layout by building a representation on which we can + // do line breaking without layouting each and every line from scratch. + let par = ParLayout::new(ctx, self, bidi, regions, &styles)?; + + // Break the paragraph into lines. + let lines = break_lines(&mut ctx.fonts, &par, regions.first.x); + + // Stack the lines into one frame per region. + Ok(stack_lines(&ctx.fonts, lines, regions, styles)) + } +} + +/// Perform line breaking. +fn break_lines<'a>( + fonts: &mut FontStore, + par: &'a ParLayout<'a>, + width: Length, +) -> Vec> { + // The already determined lines and the current line attempt. + let mut lines = vec![]; + let mut start = 0; + let mut last = None; + + // Find suitable line breaks. + for (end, mandatory) in LineBreakIterator::new(&par.bidi.text) { + // Compute the line and its size. + let mut line = par.line(fonts, start .. end, mandatory); + + // If the line doesn't fit anymore, we push the last fitting attempt + // into the stack and rebuild the line from its end. The resulting + // line cannot be broken up further. + if !width.fits(line.size.x) { + if let Some((last_line, last_end)) = last.take() { + lines.push(last_line); + start = last_end; + line = par.line(fonts, start .. end, mandatory); + } + } + + // 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(line.size.x) { + lines.push(line); + start = end; + last = None; + } else { + last = Some((line, end)); + } + } + + if let Some((line, _)) = last { + lines.push(line); + } + + lines +} + +/// Combine the lines into one frame per region. +fn stack_lines( + fonts: &FontStore, + lines: Vec, + regions: &Regions, + styles: StyleChain, +) -> Vec> { + let em = styles.get(TextNode::SIZE).abs; + let leading = styles.get(ParNode::LEADING).resolve(em); + let align = styles.get(ParNode::ALIGN); + let justify = styles.get(ParNode::JUSTIFY); + + // 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.size.x).max().unwrap_or_default(); + } + + // State for final frame building. + let mut regions = regions.clone(); + let mut finished = vec![]; + let mut first = true; + let mut output = Frame::new(Size::with_x(width)); + + // Stack the lines into one frame per region. + for line in lines { + while !regions.first.y.fits(line.size.y) && !regions.in_last() { + finished.push(Arc::new(output)); + output = Frame::new(Size::with_x(width)); + regions.next(); + first = true; + } + + if !first { + output.size.y += leading; + } + + let frame = line.build(fonts, width, align, justify); + let pos = Point::with_y(output.size.y); + output.size.y += frame.size.y; + output.merge_frame(pos, frame); + + regions.first.y -= line.size.y + leading; + first = false; + } + + finished.push(Arc::new(output)); + finished +} + impl Debug for ParNode { fn fmt(&self, f: &mut Formatter) -> fmt::Result { f.write_str("Par ")?; @@ -261,7 +288,7 @@ impl LinebreakNode { } /// A paragraph representation in which children are already layouted and text -/// is separated into shapable runs. +/// is already preshaped. struct ParLayout<'a> { /// Bidirectional text embedding levels for the paragraph. bidi: BidiInfo<'a>, @@ -340,7 +367,12 @@ impl<'a> ParLayout<'a> { } /// Create a line which spans the given range. - fn line(&'a self, fonts: &mut FontStore, mut range: Range) -> LineLayout<'a> { + fn line( + &'a self, + fonts: &mut FontStore, + mut range: Range, + mandatory: bool, + ) -> LineLayout<'a> { // Find the items which bound the text range. let last_idx = self.find(range.end.saturating_sub(1)).unwrap(); let first_idx = if range.is_empty() { @@ -432,6 +464,7 @@ impl<'a> ParLayout<'a> { size: Size::new(width, top + bottom), baseline: top, fr, + mandatory, } } @@ -467,18 +500,36 @@ struct LineLayout<'a> { baseline: Length, /// The sum of fractional ratios in the line. fr: Fractional, + /// Whether the line ends at a mandatory break. + mandatory: bool, } impl<'a> LineLayout<'a> { /// Build the line's frame. - fn build(&self, fonts: &FontStore, width: Length, align: Align) -> Frame { - let size = Size::new(self.size.x.max(width), self.size.y); - let remaining = size.x - self.size.x; + fn build( + &self, + fonts: &FontStore, + width: Length, + align: Align, + justify: bool, + ) -> Frame { + let size = Size::new(width, self.size.y); + let mut remaining = width - self.size.x; let mut offset = Length::zero(); let mut output = Frame::new(size); output.baseline = Some(self.baseline); + let mut justification = Length::zero(); + if justify + && !self.mandatory + && self.range.end < self.bidi.text.len() + && self.fr.is_zero() + { + justification = remaining / self.spaces() as f64; + remaining = Length::zero(); + } + for item in self.reordered() { let mut position = |frame: Frame| { let x = offset + align.resolve(remaining); @@ -490,7 +541,7 @@ impl<'a> LineLayout<'a> { match item { ParItem::Absolute(v) => offset += *v, ParItem::Fractional(v) => offset += v.resolve(self.fr, remaining), - ParItem::Text(shaped) => position(shaped.build(fonts)), + ParItem::Text(shaped) => position(shaped.build(fonts, justification)), ParItem::Frame(frame) => position(frame.clone()), } } @@ -498,6 +549,11 @@ impl<'a> LineLayout<'a> { output } + /// The number of spaces in the line. + fn spaces(&self) -> usize { + self.shapeds().map(ShapedText::spaces).sum() + } + /// Iterate through the line's items in visual order. fn reordered(&self) -> impl Iterator> { // The bidi crate doesn't like empty lines. @@ -533,6 +589,19 @@ impl<'a> LineLayout<'a> { .map(move |idx| self.get(idx).unwrap()) } + /// Iterate over the line's items. + fn items(&self) -> impl Iterator> { + self.first.iter().chain(self.items).chain(&self.last) + } + + /// Iterate through the line's text items. + fn shapeds(&self) -> impl Iterator> { + self.items().filter_map(|item| match item { + ParItem::Text(shaped) => Some(shaped), + _ => None, + }) + } + /// Find the index of the item whose range contains the `text_offset`. fn find(&self, text_offset: usize) -> Option { self.ranges.binary_search_by(|r| r.locate(text_offset)).ok() @@ -540,7 +609,7 @@ impl<'a> LineLayout<'a> { /// Get the item at the index. fn get(&self, index: usize) -> Option<&ParItem<'a>> { - self.first.iter().chain(self.items).chain(&self.last).nth(index) + self.items().nth(index) } } diff --git a/src/library/text.rs b/src/library/text.rs index 448ba9af0..b76b60ee9 100644 --- a/src/library/text.rs +++ b/src/library/text.rs @@ -406,6 +406,191 @@ impl Case { } } +/// 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(Debug, Clone)] +pub struct ShapedText<'a> { + /// The text that was shaped. + pub text: Cow<'a, str>, + /// The text direction. + pub dir: Dir, + /// The text's style properties. + pub styles: StyleChain<'a>, + /// The size of the text's bounding box. + pub size: Size, + /// The baseline from the top of the frame. + pub baseline: Length, + /// The shaped glyphs. + pub glyphs: Cow<'a, [ShapedGlyph]>, +} + +/// A single glyph resulting from shaping. +#[derive(Debug, Copy, Clone)] +pub struct ShapedGlyph { + /// The font face the glyph is contained in. + pub face_id: FaceId, + /// The glyph's index in the face. + 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 start index of the glyph in the source text. + pub text_index: usize, + /// 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, + /// Whether this glyph represents a space. + pub is_space: bool, +} + +/// A visual side. +enum Side { + Left, + Right, +} + +impl<'a> ShapedText<'a> { + /// Build the shaped text's frame. + /// + /// The `justification` defines how much extra advance width each + /// [space glyph](ShapedGlyph::is_space) will get. + pub fn build(&self, fonts: &FontStore, justification: Length) -> Frame { + let mut offset = Length::zero(); + let mut frame = Frame::new(self.size); + frame.baseline = Some(self.baseline); + + for (face_id, group) in self.glyphs.as_ref().group_by_key(|g| g.face_id) { + let pos = Point::new(offset, self.baseline); + + let size = self.styles.get(TextNode::SIZE).abs; + let fill = self.styles.get(TextNode::FILL); + let glyphs = group + .iter() + .map(|glyph| Glyph { + id: glyph.glyph_id, + x_advance: glyph.x_advance + + if glyph.is_space { + frame.size.x += justification; + Em::from_length(justification, size) + } else { + Em::zero() + }, + x_offset: glyph.x_offset, + }) + .collect(); + + let text = Text { face_id, size, fill, glyphs }; + let text_layer = frame.layer(); + let width = text.width(); + + // Apply line decorations. + for deco in self.styles.get_cloned(TextNode::LINES) { + decorate(&mut frame, &deco, fonts, &text, pos, width); + } + + frame.insert(text_layer, pos, Element::Text(text)); + offset += width; + } + + // Apply link if it exists. + if let Some(url) = self.styles.get_ref(TextNode::LINK) { + frame.link(url); + } + + frame + } + + /// How many spaces the text contains. + pub fn spaces(&self) -> usize { + self.glyphs.iter().filter(|g| g.is_space).count() + } + + /// Reshape a range of the shaped text, reusing information from this + /// shaping process if possible. + pub fn reshape( + &'a self, + fonts: &mut FontStore, + text_range: Range, + ) -> ShapedText<'a> { + if let Some(glyphs) = self.slice_safe_to_break(text_range.clone()) { + let (size, baseline) = measure(fonts, glyphs, self.styles); + Self { + text: Cow::Borrowed(&self.text[text_range]), + dir: self.dir, + styles: self.styles.clone(), + size, + baseline, + glyphs: Cow::Borrowed(glyphs), + } + } else { + shape(fonts, &self.text[text_range], self.styles.clone(), self.dir) + } + } + + /// 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, Side::Left)?; + let right = self.find_safe_to_break(end, Side::Right)?; + Some(&self.glyphs[left .. right]) + } + + /// Find the glyph offset matching the text index that is most towards the + /// given side and safe-to-break. + fn find_safe_to_break(&self, text_index: usize, towards: Side) -> Option { + let ltr = self.dir.is_positive(); + + // Handle edge cases. + let len = self.glyphs.len(); + if text_index == 0 { + return Some(if ltr { 0 } else { len }); + } else if text_index == self.text.len() { + return Some(if ltr { len } else { 0 }); + } + + // Find any glyph with the text index. + let mut idx = self + .glyphs + .binary_search_by(|g| { + let ordering = g.text_index.cmp(&text_index); + if ltr { ordering } else { ordering.reverse() } + }) + .ok()?; + + let next = match towards { + Side::Left => usize::checked_sub, + Side::Right => usize::checked_add, + }; + + // Search for the outermost glyph with the text index. + while let Some(next) = next(idx, 1) { + if self.glyphs.get(next).map_or(true, |g| g.text_index != 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. + if !ltr { + idx += 1; + } + + self.glyphs[idx].safe_to_break.then(|| idx) + } +} + /// Shape text into [`ShapedText`]. pub fn shape<'a>( fonts: &mut FontStore, @@ -446,197 +631,6 @@ pub fn shape<'a>( } } -/// Shape text with font fallback using the `families` iterator. -fn shape_segment<'a>( - fonts: &mut FontStore, - glyphs: &mut Vec, - base: usize, - text: &str, - variant: FontVariant, - mut families: impl Iterator + Clone, - mut first_face: Option, - dir: Dir, - tags: &[rustybuzz::Feature], -) { - // No font has newlines. - if text.chars().all(|c| c == '\n') { - return; - } - - // Select the font family. - let (face_id, fallback) = loop { - // Try to load the next available font family. - match families.next() { - Some(family) => { - if let Some(id) = fonts.select(family, variant) { - break (id, true); - } - } - // We're out of families, so we don't do any more fallback and just - // shape the tofus with the first face we originally used. - None => match first_face { - Some(id) => break (id, false), - None => return, - }, - } - }; - - // Remember the id if this the first available face since we use that one to - // shape tofus. - first_face.get_or_insert(face_id); - - // Fill the buffer with our text. - let mut buffer = UnicodeBuffer::new(); - buffer.push_str(text); - buffer.set_direction(match dir { - Dir::LTR => rustybuzz::Direction::LeftToRight, - Dir::RTL => rustybuzz::Direction::RightToLeft, - _ => unimplemented!(), - }); - - // Shape! - let mut face = fonts.get(face_id); - let buffer = rustybuzz::shape(face.ttf(), tags, buffer); - let infos = buffer.glyph_infos(); - let pos = buffer.glyph_positions(); - - // 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; - - if info.glyph_id != 0 || !fallback { - // Add the glyph to the shaped output. - // TODO: Don't ignore y_advance and y_offset. - glyphs.push(ShapedGlyph { - face_id, - glyph_id: info.glyph_id as u16, - x_advance: face.to_em(pos[i].x_advance), - x_offset: face.to_em(pos[i].x_offset), - text_index: base + cluster, - safe_to_break: !info.unsafe_to_break(), - }); - } else { - // Determine the source text range for the tofu sequence. - let range = { - // First, search for the end of the tofu sequence. - let k = i; - while infos.get(i + 1).map_or(false, |info| info.glyph_id == 0) { - i += 1; - } - - // Then, determine the start and end text index. - // - // 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 ltr = dir.is_positive(); - let first = if ltr { k } else { i }; - let start = infos[first].cluster as usize; - let last = if ltr { i.checked_add(1) } else { k.checked_sub(1) }; - let end = last - .and_then(|last| infos.get(last)) - .map_or(text.len(), |info| info.cluster as usize); - - start .. end - }; - - // Recursively shape the tofu sequence with the next family. - shape_segment( - fonts, - glyphs, - base + range.start, - &text[range], - variant, - families.clone(), - first_face, - dir, - tags, - ); - - face = fonts.get(face_id); - } - - i += 1; - } -} - -/// Apply tracking to a slice of shaped glyphs. -fn track(glyphs: &mut [ShapedGlyph], tracking: Em) { - if tracking.is_zero() { - return; - } - - let mut glyphs = glyphs.iter_mut().peekable(); - while let Some(glyph) = glyphs.next() { - if glyphs - .peek() - .map_or(false, |next| glyph.text_index != next.text_index) - { - glyph.x_advance += tracking; - } - } -} - -/// Measure the size and baseline of a run of shaped glyphs with the given -/// properties. -fn measure( - fonts: &mut FontStore, - glyphs: &[ShapedGlyph], - styles: StyleChain, -) -> (Size, Length) { - let mut width = Length::zero(); - let mut top = Length::zero(); - let mut bottom = Length::zero(); - - let size = styles.get(TextNode::SIZE).abs; - let top_edge = styles.get(TextNode::TOP_EDGE); - let bottom_edge = styles.get(TextNode::BOTTOM_EDGE); - - // Expand top and bottom by reading the face's vertical metrics. - let mut expand = |face: &Face| { - top.set_max(face.vertical_metric(top_edge, size)); - bottom.set_max(-face.vertical_metric(bottom_edge, size)); - }; - - if glyphs.is_empty() { - // When there are no glyphs, we just use the vertical metrics of the - // first available font. - for family in families(styles) { - if let Some(face_id) = fonts.select(family, variant(styles)) { - expand(fonts.get(face_id)); - break; - } - } - } else { - for (face_id, group) in glyphs.group_by_key(|g| g.face_id) { - let face = fonts.get(face_id); - expand(face); - - for glyph in group { - width += glyph.x_advance.resolve(size); - } - } - } - - (Size::new(width, top + bottom), top) -} - /// Resolve the font variant with `STRONG` and `EMPH` factored in. fn variant(styles: StyleChain) -> FontVariant { let mut variant = FontVariant::new( @@ -762,297 +756,319 @@ fn tags(styles: StyleChain) -> Vec { tags } -/// 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(Debug, Clone)] -pub struct ShapedText<'a> { - /// The text that was shaped. - pub text: Cow<'a, str>, - /// The text direction. - pub dir: Dir, - /// The text's style properties. - pub styles: StyleChain<'a>, - /// The font size. - pub size: Size, - /// The baseline from the top of the frame. - pub baseline: Length, - /// The shaped glyphs. - pub glyphs: Cow<'a, [ShapedGlyph]>, -} - -/// A single glyph resulting from shaping. -#[derive(Debug, Copy, Clone)] -pub struct ShapedGlyph { - /// The font face the glyph is contained in. - pub face_id: FaceId, - /// The glyph's index in the face. - 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 start index of the glyph in the source text. - pub text_index: usize, - /// 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, -} - -impl<'a> ShapedText<'a> { - /// Build the shaped text's frame. - pub fn build(&self, fonts: &FontStore) -> Frame { - let mut offset = Length::zero(); - let mut frame = Frame::new(self.size); - frame.baseline = Some(self.baseline); - - for (face_id, group) in self.glyphs.as_ref().group_by_key(|g| g.face_id) { - let pos = Point::new(offset, self.baseline); - - let size = self.styles.get(TextNode::SIZE).abs; - let fill = self.styles.get(TextNode::FILL); - let glyphs = group - .iter() - .map(|glyph| Glyph { - id: glyph.glyph_id, - x_advance: glyph.x_advance, - x_offset: glyph.x_offset, - }) - .collect(); - - let text = Text { face_id, size, fill, glyphs }; - let text_layer = frame.layer(); - let width = text.width(); - - // Apply line decorations. - for deco in self.styles.get_cloned(TextNode::LINES) { - self.decorate(&mut frame, &deco, fonts, &text, pos, width); - } - - frame.insert(text_layer, pos, Element::Text(text)); - offset += width; - } - - // Apply link if it exists. - if let Some(url) = self.styles.get_ref(TextNode::LINK) { - frame.link(url); - } - - frame +/// Shape text with font fallback using the `families` iterator. +fn shape_segment<'a>( + fonts: &mut FontStore, + glyphs: &mut Vec, + base: usize, + text: &str, + variant: FontVariant, + mut families: impl Iterator + Clone, + mut first_face: Option, + dir: Dir, + tags: &[rustybuzz::Feature], +) { + // No font has newlines. + if text.chars().all(|c| c == '\n') { + return; } - /// Add line decorations to a run of shaped text of a single font. - fn decorate( - &self, - frame: &mut Frame, - deco: &Decoration, - fonts: &FontStore, - text: &Text, - pos: Point, - width: Length, - ) { - let face = fonts.get(text.face_id); - let metrics = match deco.line { - super::STRIKETHROUGH => face.strikethrough, - super::OVERLINE => face.overline, - super::UNDERLINE | _ => face.underline, - }; - - let evade = deco.evade && deco.line != super::STRIKETHROUGH; - let extent = deco.extent.resolve(text.size); - let offset = deco - .offset - .map(|s| s.resolve(text.size)) - .unwrap_or(-metrics.position.resolve(text.size)); - - let stroke = Stroke { - paint: deco.stroke.unwrap_or(text.fill), - thickness: deco - .thickness - .map(|s| s.resolve(text.size)) - .unwrap_or(metrics.thickness.resolve(text.size)), - }; - - let gap_padding = 0.08 * text.size; - let min_width = 0.162 * text.size; - - let mut start = pos.x - extent; - let end = pos.x + (width + 2.0 * extent); - - let mut push_segment = |from: Length, to: Length| { - let origin = Point::new(from, pos.y + offset); - let target = Point::new(to - from, Length::zero()); - - if target.x >= min_width || !evade { - let shape = Shape::stroked(Geometry::Line(target), stroke); - frame.push(origin, Element::Shape(shape)); + // Select the font family. + let (face_id, fallback) = loop { + // Try to load the next available font family. + match families.next() { + Some(family) => { + if let Some(id) = fonts.select(family, variant) { + break (id, true); + } } - }; - - if !evade { - push_segment(start, end); - return; + // We're out of families, so we don't do any more fallback and just + // shape the tofus with the first face we originally used. + None => match first_face { + Some(id) => break (id, false), + None => 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()), - ); + // Remember the id if this the first available face since we use that one to + // shape tofus. + first_face.get_or_insert(face_id); - let mut x = pos.x; - let mut intersections = vec![]; + // Fill the buffer with our text. + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(text); + buffer.set_direction(match dir { + Dir::LTR => rustybuzz::Direction::LeftToRight, + Dir::RTL => rustybuzz::Direction::RightToLeft, + _ => unimplemented!(), + }); - for glyph in text.glyphs.iter() { - let dx = glyph.x_offset.resolve(text.size) + x; - let mut builder = - KurboPathBuilder::new(face.units_per_em, text.size, dx.to_raw()); + // Shape! + let mut face = fonts.get(face_id); + let buffer = rustybuzz::shape(face.ttf(), tags, buffer); + let infos = buffer.glyph_infos(); + let pos = buffer.glyph_positions(); - let bbox = face.ttf().outline_glyph(GlyphId(glyph.id), &mut builder); - let path = builder.finish(); + // 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; - x += glyph.x_advance.resolve(text.size); - - // Only do the costly segments intersection test if the line - // intersects the bounding box. - if bbox.map_or(false, |bbox| { - let y_min = -face.to_em(bbox.y_max).resolve(text.size); - let y_max = -face.to_em(bbox.y_min).resolve(text.size); - - offset >= y_min && offset <= y_max - }) { - // Find all intersections of segments with the line. - intersections.extend( - path.segments() - .flat_map(|seg| seg.intersect_line(line)) - .map(|is| Length::raw(line.eval(is.line_t).x)), - ); - } - } - - // When emitting the decorative line segments, we move from left to - // right. The intersections are not necessarily in this order, yet. - intersections.sort(); - - for gap in intersections.chunks_exact(2) { - let l = gap[0] - gap_padding; - let r = gap[1] + gap_padding; - - if start >= end { - break; - } - - if start >= l { - start = r; - continue; - } - - push_segment(start, l); - start = r; - } - - if start < end { - push_segment(start, end); - } - } - - /// Reshape a range of the shaped text, reusing information from this - /// shaping process if possible. - pub fn reshape( - &'a self, - fonts: &mut FontStore, - text_range: Range, - ) -> ShapedText<'a> { - if let Some(glyphs) = self.slice_safe_to_break(text_range.clone()) { - let (size, baseline) = measure(fonts, glyphs, self.styles); - Self { - text: Cow::Borrowed(&self.text[text_range]), - dir: self.dir, - styles: self.styles.clone(), - size, - baseline, - glyphs: Cow::Borrowed(glyphs), - } + if info.glyph_id != 0 || !fallback { + // Add the glyph to the shaped output. + // TODO: Don't ignore y_advance and y_offset. + glyphs.push(ShapedGlyph { + face_id, + glyph_id: info.glyph_id as u16, + x_advance: face.to_em(pos[i].x_advance), + x_offset: face.to_em(pos[i].x_offset), + text_index: base + cluster, + safe_to_break: !info.unsafe_to_break(), + is_space: text[cluster ..].chars().next() == Some(' '), + }); } else { - shape(fonts, &self.text[text_range], self.styles.clone(), self.dir) + // Determine the source text range for the tofu sequence. + let range = { + // First, search for the end of the tofu sequence. + let k = i; + while infos.get(i + 1).map_or(false, |info| info.glyph_id == 0) { + i += 1; + } + + // Then, determine the start and end text index. + // + // 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 ltr = dir.is_positive(); + let first = if ltr { k } else { i }; + let start = infos[first].cluster as usize; + let last = if ltr { i.checked_add(1) } else { k.checked_sub(1) }; + let end = last + .and_then(|last| infos.get(last)) + .map_or(text.len(), |info| info.cluster as usize); + + start .. end + }; + + // Recursively shape the tofu sequence with the next family. + shape_segment( + fonts, + glyphs, + base + range.start, + &text[range], + variant, + families.clone(), + first_face, + dir, + tags, + ); + + face = fonts.get(face_id); } + + i += 1; + } +} + +/// Apply tracking to a slice of shaped glyphs. +fn track(glyphs: &mut [ShapedGlyph], tracking: Em) { + if tracking.is_zero() { + return; } - /// 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 mut glyphs = glyphs.iter_mut().peekable(); + while let Some(glyph) = glyphs.next() { + if glyphs + .peek() + .map_or(false, |next| glyph.text_index != next.text_index) + { + glyph.x_advance += tracking; } - - let left = self.find_safe_to_break(start, Side::Left)?; - let right = self.find_safe_to_break(end, Side::Right)?; - Some(&self.glyphs[left .. right]) } +} - /// Find the glyph offset matching the text index that is most towards the - /// given side and safe-to-break. - fn find_safe_to_break(&self, text_index: usize, towards: Side) -> Option { - let ltr = self.dir.is_positive(); +/// Measure the size and baseline of a run of shaped glyphs with the given +/// properties. +fn measure( + fonts: &mut FontStore, + glyphs: &[ShapedGlyph], + styles: StyleChain, +) -> (Size, Length) { + let mut width = Length::zero(); + let mut top = Length::zero(); + let mut bottom = Length::zero(); - // Handle edge cases. - let len = self.glyphs.len(); - if text_index == 0 { - return Some(if ltr { 0 } else { len }); - } else if text_index == self.text.len() { - return Some(if ltr { len } else { 0 }); - } + let size = styles.get(TextNode::SIZE).abs; + let top_edge = styles.get(TextNode::TOP_EDGE); + let bottom_edge = styles.get(TextNode::BOTTOM_EDGE); - // Find any glyph with the text index. - let mut idx = self - .glyphs - .binary_search_by(|g| { - let ordering = g.text_index.cmp(&text_index); - if ltr { ordering } else { ordering.reverse() } - }) - .ok()?; + // Expand top and bottom by reading the face's vertical metrics. + let mut expand = |face: &Face| { + top.set_max(face.vertical_metric(top_edge, size)); + bottom.set_max(-face.vertical_metric(bottom_edge, size)); + }; - let next = match towards { - Side::Left => usize::checked_sub, - Side::Right => usize::checked_add, - }; - - // Search for the outermost glyph with the text index. - while let Some(next) = next(idx, 1) { - if self.glyphs.get(next).map_or(true, |g| g.text_index != text_index) { + if glyphs.is_empty() { + // When there are no glyphs, we just use the vertical metrics of the + // first available font. + for family in families(styles) { + if let Some(face_id) = fonts.select(family, variant(styles)) { + expand(fonts.get(face_id)); break; } - idx = next; + } + } else { + for (face_id, group) in glyphs.group_by_key(|g| g.face_id) { + let face = fonts.get(face_id); + expand(face); + + for glyph in group { + width += glyph.x_advance.resolve(size); + } + } + } + + (Size::new(width, top + bottom), top) +} + +/// Add line decorations to a single run of shaped text. +fn decorate( + frame: &mut Frame, + deco: &Decoration, + fonts: &FontStore, + text: &Text, + pos: Point, + width: Length, +) { + let face = fonts.get(text.face_id); + let metrics = match deco.line { + super::STRIKETHROUGH => face.strikethrough, + super::OVERLINE => face.overline, + super::UNDERLINE | _ => face.underline, + }; + + let evade = deco.evade && deco.line != super::STRIKETHROUGH; + let extent = deco.extent.resolve(text.size); + let offset = deco + .offset + .map(|s| s.resolve(text.size)) + .unwrap_or(-metrics.position.resolve(text.size)); + + let stroke = Stroke { + paint: deco.stroke.unwrap_or(text.fill), + thickness: deco + .thickness + .map(|s| s.resolve(text.size)) + .unwrap_or(metrics.thickness.resolve(text.size)), + }; + + let gap_padding = 0.08 * text.size; + let min_width = 0.162 * text.size; + + let mut start = pos.x - extent; + let end = pos.x + (width + 2.0 * extent); + + let mut push_segment = |from: Length, to: Length| { + let origin = Point::new(from, pos.y + offset); + let target = Point::new(to - from, Length::zero()); + + if target.x >= min_width || !evade { + let shape = Shape::stroked(Geometry::Line(target), stroke); + frame.push(origin, Element::Shape(shape)); + } + }; + + if !evade { + push_segment(start, end); + 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.resolve(text.size) + x; + let mut builder = BezPathBuilder::new(face.units_per_em, text.size, dx.to_raw()); + + let bbox = face.ttf().outline_glyph(GlyphId(glyph.id), &mut builder); + let path = builder.finish(); + + x += glyph.x_advance.resolve(text.size); + + // Only do the costly segments intersection test if the line + // intersects the bounding box. + if bbox.map_or(false, |bbox| { + let y_min = -face.to_em(bbox.y_max).resolve(text.size); + let y_max = -face.to_em(bbox.y_min).resolve(text.size); + + offset >= y_min && offset <= y_max + }) { + // Find all intersections of segments with the line. + intersections.extend( + path.segments() + .flat_map(|seg| seg.intersect_line(line)) + .map(|is| Length::raw(line.eval(is.line_t).x)), + ); + } + } + + // When emitting the decorative line segments, we move from left to + // right. The intersections are not necessarily in this order, yet. + intersections.sort(); + + for gap in intersections.chunks_exact(2) { + let l = gap[0] - gap_padding; + let r = gap[1] + gap_padding; + + if start >= end { + break; } - // 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. - if !ltr { - idx += 1; + if start >= l { + start = r; + continue; } - self.glyphs[idx].safe_to_break.then(|| idx) + push_segment(start, l); + start = r; + } + + if start < end { + push_segment(start, end); } } -/// A visual side. -enum Side { - Left, - Right, -} - -struct KurboPathBuilder { +/// Builds a kurbo [`BezPath`] for a glyph. +struct BezPathBuilder { path: BezPath, units_per_em: f64, font_size: Length, x_offset: f64, } -impl KurboPathBuilder { +impl BezPathBuilder { fn new(units_per_em: f64, font_size: Length, x_offset: f64) -> Self { Self { path: BezPath::new(), @@ -1075,7 +1091,7 @@ impl KurboPathBuilder { } } -impl OutlineBuilder for KurboPathBuilder { +impl OutlineBuilder for BezPathBuilder { fn move_to(&mut self, x: f32, y: f32) { self.path.move_to(self.p(x, y)); } diff --git a/tests/ref/text/justify.png b/tests/ref/text/justify.png new file mode 100644 index 0000000000000000000000000000000000000000..26787af9f4405c9e51252e8203a0176eb0cc325b GIT binary patch literal 11615 zcmaKyWl&q~yY3T6Aw@%Jai>6WiaQi{N^y60iff?3O7Rw#LJP&ExF%R}cXx^t+~M%P z|9xhkIWv3zX02Ik?oZE$=f38aEAqXHEH(xS1^@uSmY0+I0000%Pj3MDDFEnJkzWP? zSn1`Z-f4Kx9V}XFywoN|9Qz%zQB};0yy^-uqhli2e^x1-YxXWWm#8Z&RH_o5J8DxQ zO((-bq^RLg*(vvhl^&0W2-R)`HY2TuU#ZCcc-g@Q?kOWeycWDfwH_YgvJ4t%;W`}{ zXpwj$&8VRE`NP-Tmw8XWJ}k|q((m3wUV@&z`=35WbP^2Z1F`9I_qrVVZmol+l8IYh z@^3|tx}9thg(}Y2f4$R<-J+1Y8W3=+E`zpx_H|3$7mn(ESVcG5v>!VQPd8svr@v$y zxzZtN+F8Fcl-DXYxNk7vlMY*J4{KRKO^7)E{?U<`eD&Go<>hc#oZI5c@8QTJER}hd zhra8{Z|;(9$8}h2zdBi<_45dkx0ov!q2T}stG@^3Zz>{2>u4$me~MM-Z@E>RVoL;7 zSlzEMB=jndLKFe4ikwF($B$vNZcI4be$0hPL_D_T`R1S7^@+NUjGmyZ z7~S;^OMt0st@N){*DxarZB{iDJbOk#-gcai$>^O<^=3a@nHU!-)?dRQLnCz{?&KRc zz>Gn)lzQp49rW7HgRUPK8f?jDK2jTI?4N)`z4Q4jDmHeR2?ybm z*cSQo%lps{)!+6k@>NlUC=w!2wUaU&YE2QIIwX6S7lO@|$Ry>Oc82z^(Qgtgj`arK zRfgkd?lvj&A#OC>9=)SDXRPtpK6U(n7G`>2+uTqRR_tqfcB^&ARYIGVaR=ZAK}Z29Hel z#^(_(^Fnl>;Kv@oYobPi;_L)wsoKdBs}P?&b2yaG@sSl4 zJXBln?N+Jptkg}9RngOI%&WpF@qeHMhP%4W@Cl*yF+!-|A*5Y%KRevsGEiLzz+hTd ziMU}ty`LJWGSswn8-nycR;G8W5CkrhAN>-=IZi4AE{nuMoSj>)ZY{B+?yxQuUS-3B z4uooofgYV{ceD#5N>c#^^TZ~ZTUb~2hX_q;M$7vOveli;D7#ufRE@+8OGr<`51{BY zOK4LxcUq6bC<9Mh9RGkpA7}R8DURQju(%cpZsQJhRW8PUXS5WjQXk4CX7q*8D^z29 z9MntSbj;B!5F#(1X~!l1qv7{L9sPo9nBU(c;#86VR-SUq&ryx>S$otk#@MX1a(=

x+&8o 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 Vhp`jn8vo;*mX}tMDw8k?`9C-a?x6qx literal 0 HcmV?d00001 diff --git a/tests/typ/text/justify.typ b/tests/typ/text/justify.typ new file mode 100644 index 000000000..7b8a28299 --- /dev/null +++ b/tests/typ/text/justify.typ @@ -0,0 +1,14 @@ + +--- +#set par(indent: 14pt, spacing: 0pt, leading: 5pt, justify: true) + +This text is justified, meaning that spaces are stretched so that the text +forms as "block" with flush edges at both sides. + +First line indents and hyphenation play nicely with justified text. + +--- +// Test that lines with hard breaks aren't justified. +#set par(justify: true) +A B C \ +D