summaryrefslogtreecommitdiff
path: root/src/library
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2022-02-25 20:48:38 +0100
committerLaurenz <laurmaedje@gmail.com>2022-02-25 20:48:38 +0100
commitb0f4b13f6d4a1fe7742707d08e11ba03f3542b58 (patch)
tree13704c92d6e9f2b85f487533c2f0d1ca7cf58d84 /src/library
parentefde5cac88078f10485f715be66a27efba2f23d8 (diff)
Basic justification
Diffstat (limited to 'src/library')
-rw-r--r--src/library/par.rs277
-rw-r--r--src/library/text.rs776
2 files changed, 569 insertions, 484 deletions
diff --git a/src/library/par.rs b/src/library/par.rs
index cc5dd9b6..8b4adf92 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,6 +86,38 @@ impl ParNode {
}
}
+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
+ /// replacement character.
+ fn collect_text(&self) -> String {
+ let mut text = String::new();
+ for string in self.strings() {
+ text.push_str(string);
+ }
+ text
+ }
+
+ /// The range of each item in the collected text.
+ fn ranges(&self) -> impl Iterator<Item = Range> + '_ {
+ let mut cursor = 0;
+ self.strings().map(move |string| {
+ let start = cursor;
+ cursor += string.len();
+ start .. cursor
+ })
+ }
+
+ /// The string representation of each child.
+ fn strings(&self) -> impl Iterator<Item = &str> {
+ self.0.items().map(|child| match child {
+ ParChild::Text(text) => text,
+ ParChild::Spacing(_) => " ",
+ ParChild::Node(_) => "\u{FFFC}",
+ })
+ }
+}
+
impl Layout for ParNode {
fn layout(
&self,
@@ -90,7 +125,7 @@ impl Layout for ParNode {
regions: &Regions,
styles: StyleChain,
) -> TypResult<Vec<Arc<Frame>>> {
- // Collect all text into one string used for BiDi analysis.
+ // 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);
@@ -98,118 +133,110 @@ impl Layout for ParNode {
// 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);
- }
- }
+ // Break the paragraph into lines.
+ let lines = break_lines(&mut ctx.fonts, &par, regions.first.x);
- // 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));
+ // 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<LineLayout<'a>> {
+ // 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);
}
}
- if let Some((line, _)) = 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 mandatory || !width.fits(line.size.x) {
lines.push(line);
+ start = end;
+ last = None;
+ } else {
+ last = Some((line, end));
}
+ }
- // 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 let Some((line, _)) = last {
+ lines.push(line);
+ }
- if !first {
- output.size.y += leading;
- }
+ lines
+}
- 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);
+/// Combine the lines into one frame per region.
+fn stack_lines(
+ fonts: &FontStore,
+ lines: Vec<LineLayout>,
+ regions: &Regions,
+ styles: StyleChain,
+) -> Vec<Arc<Frame>> {
+ 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();
+ }
- regions.first.y -= line.size.y + leading;
- first = false;
+ // 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;
}
- 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
- /// replacement character.
- fn collect_text(&self) -> String {
- let mut text = String::new();
- for string in self.strings() {
- text.push_str(string);
+ if !first {
+ output.size.y += leading;
}
- text
- }
- /// The range of each item in the collected text.
- fn ranges(&self) -> impl Iterator<Item = Range> + '_ {
- let mut cursor = 0;
- self.strings().map(move |string| {
- let start = cursor;
- cursor += string.len();
- start .. cursor
- })
- }
+ 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);
- /// The string representation of each child.
- fn strings(&self) -> impl Iterator<Item = &str> {
- self.0.items().map(|child| match child {
- ParChild::Text(text) => text,
- ParChild::Spacing(_) => " ",
- ParChild::Node(_) => "\u{FFFC}",
- })
+ regions.first.y -= line.size.y + leading;
+ first = false;
}
+
+ finished.push(Arc::new(output));
+ finished
}
impl Debug for ParNode {
@@ -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<Item = &ParItem<'a>> {
// 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<Item = &ParItem<'a>> {
+ self.first.iter().chain(self.items).chain(&self.last)
+ }
+
+ /// Iterate through the line's text items.
+ fn shapeds(&self) -> impl Iterator<Item = &ShapedText<'a>> {
+ 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<usize> {
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 448ba9af..b76b60ee 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<usize>,
+ ) -> 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<usize>) -> 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<usize> {
+ 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,6 +631,131 @@ pub fn shape<'a>(
}
}
+/// Resolve the font variant with `STRONG` and `EMPH` factored in.
+fn variant(styles: StyleChain) -> FontVariant {
+ let mut variant = FontVariant::new(
+ styles.get(TextNode::STYLE),
+ styles.get(TextNode::WEIGHT),
+ styles.get(TextNode::STRETCH),
+ );
+
+ if styles.get(TextNode::STRONG) {
+ variant.weight = variant.weight.thicken(300);
+ }
+
+ if styles.get(TextNode::EMPH) {
+ variant.style = match variant.style {
+ FontStyle::Normal => FontStyle::Italic,
+ FontStyle::Italic => FontStyle::Normal,
+ FontStyle::Oblique => FontStyle::Normal,
+ }
+ }
+
+ variant
+}
+
+/// Resolve a prioritized iterator over the font families.
+fn families(styles: StyleChain) -> impl Iterator<Item = &str> + Clone {
+ let head = if styles.get(TextNode::MONOSPACED) {
+ styles.get_ref(TextNode::MONOSPACE).as_slice()
+ } else {
+ &[]
+ };
+
+ let core = styles.get_ref(TextNode::FAMILY).iter().flat_map(move |family| {
+ match family {
+ FontFamily::Named(name) => std::slice::from_ref(name),
+ FontFamily::Serif => styles.get_ref(TextNode::SERIF),
+ FontFamily::SansSerif => styles.get_ref(TextNode::SANS_SERIF),
+ FontFamily::Monospace => styles.get_ref(TextNode::MONOSPACE),
+ }
+ });
+
+ let tail: &[&str] = if styles.get(TextNode::FALLBACK) {
+ &["ibm plex sans", "latin modern math", "twitter color emoji"]
+ } else {
+ &[]
+ };
+
+ head.iter()
+ .chain(core)
+ .map(|named| named.as_str())
+ .chain(tail.iter().copied())
+}
+
+/// Collect the tags of the OpenType features to apply.
+fn tags(styles: StyleChain) -> Vec<Feature> {
+ let mut tags = vec![];
+ let mut feat = |tag, value| {
+ tags.push(Feature::new(Tag::from_bytes(tag), value, ..));
+ };
+
+ // Features that are on by default in Harfbuzz are only added if disabled.
+ if !styles.get(TextNode::KERNING) {
+ feat(b"kern", 0);
+ }
+
+ // Features that are off by default in Harfbuzz are only added if enabled.
+ if styles.get(TextNode::SMALLCAPS) {
+ feat(b"smcp", 1);
+ }
+
+ if styles.get(TextNode::ALTERNATES) {
+ feat(b"salt", 1);
+ }
+
+ let storage;
+ if let Some(set) = styles.get(TextNode::STYLISTIC_SET) {
+ storage = [b's', b's', b'0' + set.get() / 10, b'0' + set.get() % 10];
+ feat(&storage, 1);
+ }
+
+ if !styles.get(TextNode::LIGATURES) {
+ feat(b"liga", 0);
+ feat(b"clig", 0);
+ }
+
+ if styles.get(TextNode::DISCRETIONARY_LIGATURES) {
+ feat(b"dlig", 1);
+ }
+
+ if styles.get(TextNode::HISTORICAL_LIGATURES) {
+ feat(b"hilg", 1);
+ }
+
+ match styles.get(TextNode::NUMBER_TYPE) {
+ Smart::Auto => {}
+ Smart::Custom(NumberType::Lining) => feat(b"lnum", 1),
+ Smart::Custom(NumberType::OldStyle) => feat(b"onum", 1),
+ }
+
+ match styles.get(TextNode::NUMBER_WIDTH) {
+ Smart::Auto => {}
+ Smart::Custom(NumberWidth::Proportional) => feat(b"pnum", 1),
+ Smart::Custom(NumberWidth::Tabular) => feat(b"tnum", 1),
+ }
+
+ match styles.get(TextNode::NUMBER_POSITION) {
+ NumberPosition::Normal => {}
+ NumberPosition::Subscript => feat(b"subs", 1),
+ NumberPosition::Superscript => feat(b"sups", 1),
+ }
+
+ if styles.get(TextNode::SLASHED_ZERO) {
+ feat(b"zero", 1);
+ }
+
+ if styles.get(TextNode::FRACTIONS) {
+ feat(b"frac", 1);
+ }
+
+ for &(tag, value) in styles.get_ref(TextNode::FEATURES).iter() {
+ tags.push(Feature::new(tag, value, ..))
+ }
+
+ tags
+}
+
/// Shape text with font fallback using the `families` iterator.
fn shape_segment<'a>(
fonts: &mut FontStore,
@@ -517,6 +827,7 @@ fn shape_segment<'a>(
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 {
// Determine the source text range for the tofu sequence.
@@ -637,422 +948,127 @@ fn measure(
(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(
- styles.get(TextNode::STYLE),
- styles.get(TextNode::WEIGHT),
- styles.get(TextNode::STRETCH),
- );
-
- if styles.get(TextNode::STRONG) {
- variant.weight = variant.weight.thicken(300);
- }
-
- if styles.get(TextNode::EMPH) {
- variant.style = match variant.style {
- FontStyle::Normal => FontStyle::Italic,
- FontStyle::Italic => FontStyle::Normal,
- FontStyle::Oblique => FontStyle::Normal,
- }
- }
-
- variant
-}
-
-/// Resolve a prioritized iterator over the font families.
-fn families(styles: StyleChain) -> impl Iterator<Item = &str> + Clone {
- let head = if styles.get(TextNode::MONOSPACED) {
- styles.get_ref(TextNode::MONOSPACE).as_slice()
- } else {
- &[]
- };
-
- let core = styles.get_ref(TextNode::FAMILY).iter().flat_map(move |family| {
- match family {
- FontFamily::Named(name) => std::slice::from_ref(name),
- FontFamily::Serif => styles.get_ref(TextNode::SERIF),
- FontFamily::SansSerif => styles.get_ref(TextNode::SANS_SERIF),
- FontFamily::Monospace => styles.get_ref(TextNode::MONOSPACE),
- }
- });
-
- let tail: &[&str] = if styles.get(TextNode::FALLBACK) {
- &["ibm plex sans", "latin modern math", "twitter color emoji"]
- } else {
- &[]
+/// 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,
};
- head.iter()
- .chain(core)
- .map(|named| named.as_str())
- .chain(tail.iter().copied())
-}
-
-/// Collect the tags of the OpenType features to apply.
-fn tags(styles: StyleChain) -> Vec<Feature> {
- let mut tags = vec![];
- let mut feat = |tag, value| {
- tags.push(Feature::new(Tag::from_bytes(tag), value, ..));
+ 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)),
};
- // Features that are on by default in Harfbuzz are only added if disabled.
- if !styles.get(TextNode::KERNING) {
- feat(b"kern", 0);
- }
+ let gap_padding = 0.08 * text.size;
+ let min_width = 0.162 * text.size;
- // Features that are off by default in Harfbuzz are only added if enabled.
- if styles.get(TextNode::SMALLCAPS) {
- feat(b"smcp", 1);
- }
-
- if styles.get(TextNode::ALTERNATES) {
- feat(b"salt", 1);
- }
+ let mut start = pos.x - extent;
+ let end = pos.x + (width + 2.0 * extent);
- let storage;
- if let Some(set) = styles.get(TextNode::STYLISTIC_SET) {
- storage = [b's', b's', b'0' + set.get() / 10, b'0' + set.get() % 10];
- feat(&storage, 1);
- }
+ 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 !styles.get(TextNode::LIGATURES) {
- feat(b"liga", 0);
- feat(b"clig", 0);
- }
-
- if styles.get(TextNode::DISCRETIONARY_LIGATURES) {
- feat(b"dlig", 1);
- }
-
- if styles.get(TextNode::HISTORICAL_LIGATURES) {
- feat(b"hilg", 1);
- }
-
- match styles.get(TextNode::NUMBER_TYPE) {
- Smart::Auto => {}
- Smart::Custom(NumberType::Lining) => feat(b"lnum", 1),
- Smart::Custom(NumberType::OldStyle) => feat(b"onum", 1),
- }
-
- match styles.get(TextNode::NUMBER_WIDTH) {
- Smart::Auto => {}
- Smart::Custom(NumberWidth::Proportional) => feat(b"pnum", 1),
- Smart::Custom(NumberWidth::Tabular) => feat(b"tnum", 1),
- }
-
- match styles.get(TextNode::NUMBER_POSITION) {
- NumberPosition::Normal => {}
- NumberPosition::Subscript => feat(b"subs", 1),
- NumberPosition::Superscript => feat(b"sups", 1),
- }
-
- if styles.get(TextNode::SLASHED_ZERO) {
- feat(b"zero", 1);
- }
-
- if styles.get(TextNode::FRACTIONS) {
- feat(b"frac", 1);
- }
+ if target.x >= min_width || !evade {
+ let shape = Shape::stroked(Geometry::Line(target), stroke);
+ frame.push(origin, Element::Shape(shape));
+ }
+ };
- for &(tag, value) in styles.get_ref(TextNode::FEATURES).iter() {
- tags.push(Feature::new(tag, value, ..))
+ if !evade {
+ push_segment(start, end);
+ return;
}
- 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);
+ 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()),
+ );
- for (face_id, group) in self.glyphs.as_ref().group_by_key(|g| g.face_id) {
- let pos = Point::new(offset, self.baseline);
+ let mut x = pos.x;
+ let mut intersections = vec![];
- 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();
+ 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 text = Text { face_id, size, fill, glyphs };
- let text_layer = frame.layer();
- let width = text.width();
+ let bbox = face.ttf().outline_glyph(GlyphId(glyph.id), &mut builder);
+ let path = builder.finish();
- // Apply line decorations.
- for deco in self.styles.get_cloned(TextNode::LINES) {
- self.decorate(&mut frame, &deco, fonts, &text, pos, width);
- }
+ x += glyph.x_advance.resolve(text.size);
- frame.insert(text_layer, pos, Element::Text(text));
- offset += width;
- }
+ // 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);
- // Apply link if it exists.
- if let Some(url) = self.styles.get_ref(TextNode::LINK) {
- frame.link(url);
+ 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)),
+ );
}
-
- frame
}
- /// 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);
+ // When emitting the decorative line segments, we move from left to
+ // right. The intersections are not necessarily in this order, yet.
+ intersections.sort();
- let mut push_segment = |from: Length, to: Length| {
- let origin = Point::new(from, pos.y + offset);
- let target = Point::new(to - from, Length::zero());
+ for gap in intersections.chunks_exact(2) {
+ let l = gap[0] - gap_padding;
+ let r = gap[1] + gap_padding;
- 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;
+ if start >= end {
+ break;
}
- 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 =
- KurboPathBuilder::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;
- }
-
- if start >= l {
- start = r;
- continue;
- }
-
- push_segment(start, l);
+ if start >= l {
start = r;
+ continue;
}
- if start < end {
- push_segment(start, end);
- }
+ push_segment(start, l);
+ start = r;
}
- /// 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<usize>,
- ) -> 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)
- }
+ if start < end {
+ push_segment(start, end);
}
-
- /// 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<usize>) -> 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<usize> {
- 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)
- }
-}
-
-/// 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));
}