diff --git a/Cargo.toml b/Cargo.toml index 883d3443b..a71d27654 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,9 +26,11 @@ fontdock = { path = "../fontdock", default-features = false } image = { version = "0.23", default-features = false, features = ["jpeg", "png"] } miniz_oxide = "0.3" pdf-writer = { path = "../pdf-writer" } -rustybuzz = "0.3" -ttf-parser = "0.9" +rustybuzz = { git = "https://github.com/laurmaedje/rustybuzz" } +ttf-parser = "0.12" +unicode-bidi = "0.3.5" unicode-xid = "0.2" +xi-unicode = "0.3" anyhow = { version = "1", optional = true } serde = { version = "1", features = ["derive"], optional = true } diff --git a/fonts/NotoSerifCJKsc-Regular.otf b/fonts/NotoSerifCJKsc-Regular.otf new file mode 100644 index 000000000..4c5f715ba Binary files /dev/null and b/fonts/NotoSerifCJKsc-Regular.otf differ diff --git a/fonts/NotoSerifHebrew-Bold.ttf b/fonts/NotoSerifHebrew-Bold.ttf new file mode 100644 index 000000000..e7cbd93ae Binary files /dev/null and b/fonts/NotoSerifHebrew-Bold.ttf differ diff --git a/fonts/NotoSerifHebrew-Regular.ttf b/fonts/NotoSerifHebrew-Regular.ttf new file mode 100644 index 000000000..27893f102 Binary files /dev/null and b/fonts/NotoSerifHebrew-Regular.ttf differ diff --git a/src/exec/context.rs b/src/exec/context.rs index 6101047e1..d33d62ef4 100644 --- a/src/exec/context.rs +++ b/src/exec/context.rs @@ -6,9 +6,8 @@ use crate::env::Env; use crate::eval::TemplateValue; use crate::geom::{Align, Dir, Gen, GenAxis, Length, Linear, Sides, Size}; use crate::layout::{ - AnyNode, PadNode, PageRun, ParChild, ParNode, StackChild, StackNode, TextNode, Tree, + AnyNode, PadNode, PageRun, ParChild, ParNode, StackChild, StackNode, Tree, }; -use crate::parse::{is_newline, Scanner}; use crate::syntax::Span; /// The context for execution. @@ -73,28 +72,14 @@ impl<'a> ExecContext<'a> { /// Push a word space into the active paragraph. pub fn push_word_space(&mut self) { - let em = self.state.font.resolve_size(); - let amount = self.state.par.word_spacing.resolve(em); - self.stack.par.push_soft(ParChild::Spacing(amount)); + self.stack.par.push_soft(self.make_text_node(" ")); } /// Push text into the active paragraph. /// /// The text is split into lines at newlines. - pub fn push_text(&mut self, text: &str) { - let mut scanner = Scanner::new(text); - let mut text = String::new(); - - while let Some(c) = scanner.eat_merging_crlf() { - if is_newline(c) { - self.stack.par.push_text(mem::take(&mut text), &self.state); - self.linebreak(); - } else { - text.push(c); - } - } - - self.stack.par.push_text(text, &self.state); + pub fn push_text(&mut self, text: impl Into) { + self.stack.par.push(self.make_text_node(text)); } /// Push spacing into paragraph or stack depending on `axis`. @@ -112,7 +97,7 @@ impl<'a> ExecContext<'a> { /// Apply a forced line break. pub fn linebreak(&mut self) { - self.stack.par.push_hard(ParChild::Linebreak); + self.stack.par.push_hard(self.make_text_node("\n")); } /// Apply a forced paragraph break. @@ -140,6 +125,12 @@ impl<'a> ExecContext<'a> { self.pagebreak(true, false, Span::default()); Pass::new(self.tree, self.diags) } + + fn make_text_node(&self, text: impl Into) -> ParChild { + let align = self.state.aligns.cross; + let props = self.state.font.resolve_props(); + ParChild::Text(text.into(), props, align) + } } struct PageBuilder { @@ -231,24 +222,10 @@ impl ParBuilder { } fn push(&mut self, child: ParChild) { - self.children.extend(self.last.any()); - self.children.push(child); - } - - fn push_text(&mut self, text: String, state: &State) { - self.children.extend(self.last.any()); - - let align = state.aligns.cross; - let props = state.font.resolve_props(); - - if let Some(ParChild::Text(prev, prev_align)) = self.children.last_mut() { - if *prev_align == align && prev.props == props { - prev.text.push_str(&text); - return; - } + if let Some(soft) = self.last.any() { + self.push_inner(soft); } - - self.children.push(ParChild::Text(TextNode { text, props }, align)); + self.push_inner(child); } fn push_soft(&mut self, child: ParChild) { @@ -257,6 +234,21 @@ impl ParBuilder { fn push_hard(&mut self, child: ParChild) { self.last.hard(); + self.push_inner(child); + } + + fn push_inner(&mut self, child: ParChild) { + if let ParChild::Text(curr_text, curr_props, curr_align) = &child { + if let Some(ParChild::Text(prev_text, prev_props, prev_align)) = + self.children.last_mut() + { + if prev_align == curr_align && prev_props == curr_props { + prev_text.push_str(&curr_text); + return; + } + } + } + self.children.push(child); } diff --git a/src/exec/mod.rs b/src/exec/mod.rs index 69a41beb0..b6765d1e6 100644 --- a/src/exec/mod.rs +++ b/src/exec/mod.rs @@ -64,7 +64,7 @@ impl ExecWithMap for Tree { impl ExecWithMap for Node { fn exec_with_map(&self, ctx: &mut ExecContext, map: &NodeMap) { match self { - Node::Text(text) => ctx.push_text(text), + Node::Text(text) => ctx.push_text(text.clone()), Node::Space => ctx.push_word_space(), _ => map[&(self as *const _)].exec(ctx), } @@ -75,9 +75,9 @@ impl Exec for Value { fn exec(&self, ctx: &mut ExecContext) { match self { Value::None => {} - Value::Int(v) => ctx.push_text(&pretty(v)), - Value::Float(v) => ctx.push_text(&pretty(v)), - Value::Str(v) => ctx.push_text(v), + Value::Int(v) => ctx.push_text(pretty(v)), + Value::Float(v) => ctx.push_text(pretty(v)), + Value::Str(v) => ctx.push_text(v.clone()), Value::Template(v) => v.exec(ctx), Value::Error => {} other => { @@ -85,7 +85,7 @@ impl Exec for Value { // the representation in monospace. let prev = Rc::clone(&ctx.state.font.families); ctx.set_monospace(); - ctx.push_text(&pretty(other)); + ctx.push_text(pretty(other)); ctx.state.font.families = prev; } } @@ -104,7 +104,7 @@ impl Exec for TemplateNode { fn exec(&self, ctx: &mut ExecContext) { match self { Self::Tree { tree, map } => tree.exec_with_map(ctx, &map), - Self::Str(v) => ctx.push_text(v), + Self::Str(v) => ctx.push_text(v.clone()), Self::Func(v) => v.exec(ctx), } } diff --git a/src/exec/state.rs b/src/exec/state.rs index c579bc4e4..82f653e92 100644 --- a/src/exec/state.rs +++ b/src/exec/state.rs @@ -97,6 +97,7 @@ pub struct ParState { /// The spacing between lines (dependent on scaled font size). pub leading: Linear, /// The spacing between words (dependent on scaled font size). + // TODO: Don't ignore this. pub word_spacing: Linear, } diff --git a/src/font.rs b/src/font.rs index bcc646272..1ac8fea37 100644 --- a/src/font.rs +++ b/src/font.rs @@ -4,12 +4,18 @@ use std::fmt::{self, Display, Formatter}; use fontdock::FaceFromVec; +use crate::geom::Length; + /// An owned font face. pub struct FaceBuf { data: Box<[u8]>, index: u32, - ttf: ttf_parser::Face<'static>, - buzz: rustybuzz::Face<'static>, + inner: rustybuzz::Face<'static>, + units_per_em: f64, + ascender: f64, + cap_height: f64, + x_height: f64, + descender: f64, } impl FaceBuf { @@ -23,18 +29,27 @@ impl FaceBuf { self.index } - /// Get a reference to the underlying ttf-parser face. - pub fn ttf(&self) -> &ttf_parser::Face<'_> { + /// Get a reference to the underlying ttf-parser/rustybuzz face. + pub fn ttf(&self) -> &rustybuzz::Face<'_> { // We can't implement Deref because that would leak the internal 'static // lifetime. - &self.ttf + &self.inner } - /// Get a reference to the underlying rustybuzz face. - pub fn buzz(&self) -> &rustybuzz::Face<'_> { - // We can't implement Deref because that would leak the internal 'static - // lifetime. - &self.buzz + /// Look up a vertical metric. + pub fn vertical_metric(&self, metric: VerticalFontMetric) -> EmLength { + self.convert(match metric { + VerticalFontMetric::Ascender => self.ascender, + VerticalFontMetric::CapHeight => self.cap_height, + VerticalFontMetric::XHeight => self.x_height, + VerticalFontMetric::Baseline => 0.0, + VerticalFontMetric::Descender => self.descender, + }) + } + + /// Convert from font units to an em length length. + pub fn convert(&self, units: impl Into) -> EmLength { + EmLength(units.into() / self.units_per_em) } } @@ -47,15 +62,44 @@ impl FaceFromVec for FaceBuf { let slice: &'static [u8] = unsafe { std::slice::from_raw_parts(data.as_ptr(), data.len()) }; + let inner = rustybuzz::Face::from_slice(slice, index)?; + + // Look up some metrics we may need often. + let units_per_em = inner.units_per_em(); + let ascender = inner.typographic_ascender().unwrap_or(inner.ascender()); + let cap_height = inner.capital_height().filter(|&h| h > 0).unwrap_or(ascender); + let x_height = inner.x_height().filter(|&h| h > 0).unwrap_or(ascender); + let descender = inner.typographic_descender().unwrap_or(inner.descender()); + Some(Self { data, index, - ttf: ttf_parser::Face::from_slice(slice, index).ok()?, - buzz: rustybuzz::Face::from_slice(slice, index)?, + inner, + units_per_em: f64::from(units_per_em), + ascender: f64::from(ascender), + cap_height: f64::from(cap_height), + x_height: f64::from(x_height), + descender: f64::from(descender), }) } } +/// A length in resolved em units. +#[derive(Default, Debug, Copy, Clone, PartialEq, PartialOrd)] +pub struct EmLength(f64); + +impl EmLength { + /// Convert to a length at the given font size. + pub fn scale(self, size: Length) -> Length { + self.0 * size + } + + /// Get the number of em units. + pub fn get(self) -> f64 { + self.0 + } +} + /// Identifies a vertical metric of a font. #[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] pub enum VerticalFontMetric { @@ -77,38 +121,6 @@ pub enum VerticalFontMetric { Descender, } -impl VerticalFontMetric { - /// Look up the metric in the given font face. - pub fn lookup(self, face: &ttf_parser::Face) -> i16 { - match self { - VerticalFontMetric::Ascender => lookup_ascender(face), - VerticalFontMetric::CapHeight => face - .capital_height() - .filter(|&h| h > 0) - .unwrap_or_else(|| lookup_ascender(face)), - VerticalFontMetric::XHeight => face - .x_height() - .filter(|&h| h > 0) - .unwrap_or_else(|| lookup_ascender(face)), - VerticalFontMetric::Baseline => 0, - VerticalFontMetric::Descender => lookup_descender(face), - } - } -} - -/// The ascender of the face. -fn lookup_ascender(face: &ttf_parser::Face) -> i16 { - // We prefer the typographic ascender over the Windows ascender because - // it can be overly large if the font has large glyphs. - face.typographic_ascender().unwrap_or_else(|| face.ascender()) -} - -/// The descender of the face. -fn lookup_descender(face: &ttf_parser::Face) -> i16 { - // See `lookup_ascender` for reason. - face.typographic_descender().unwrap_or_else(|| face.descender()) -} - impl Display for VerticalFontMetric { fn fmt(&self, f: &mut Formatter) -> fmt::Result { f.pad(match self { diff --git a/src/geom/align.rs b/src/geom/align.rs index 422624d84..515efdf22 100644 --- a/src/geom/align.rs +++ b/src/geom/align.rs @@ -13,8 +13,8 @@ pub enum Align { impl Align { /// Returns the position of this alignment in the given range. - pub fn resolve(self, range: Range) -> Length { - match self { + pub fn resolve(self, dir: Dir, range: Range) -> Length { + match if dir.is_positive() { self } else { self.inv() } { Self::Start => range.start, Self::Center => (range.start + range.end) / 2.0, Self::End => range.end, diff --git a/src/geom/length.rs b/src/geom/length.rs index 1175876c7..1c2a5f864 100644 --- a/src/geom/length.rs +++ b/src/geom/length.rs @@ -81,6 +81,11 @@ impl Length { Self { raw: self.raw.max(other.raw) } } + /// Whether the other length fits into this one (i.e. is smaller). + pub fn fits(self, other: Self) -> bool { + self.raw + 1e-6 >= other.raw + } + /// Whether the length is zero. pub fn is_zero(self) -> bool { self.raw == 0.0 diff --git a/src/geom/size.rs b/src/geom/size.rs index 1ba2f04b1..2dd34a873 100644 --- a/src/geom/size.rs +++ b/src/geom/size.rs @@ -28,8 +28,7 @@ impl Size { /// Whether the other size fits into this one (smaller width and height). pub fn fits(self, other: Self) -> bool { - const EPS: Length = Length::raw(1e-6); - self.width + EPS >= other.width && self.height + EPS >= other.height + self.width.fits(other.width) && self.height.fits(other.height) } /// Whether both components are finite. diff --git a/src/layout/frame.rs b/src/layout/frame.rs index d3276e996..21fdbf28c 100644 --- a/src/layout/frame.rs +++ b/src/layout/frame.rs @@ -10,14 +10,16 @@ use crate::geom::{Length, Path, Point, Size}; pub struct Frame { /// The size of the frame. pub size: Size, + /// The baseline of the frame measured from the top. + pub baseline: Length, /// The elements composing this layout. pub elements: Vec<(Point, Element)>, } impl Frame { /// Create a new, empty frame. - pub fn new(size: Size) -> Self { - Self { size, elements: vec![] } + pub fn new(size: Size, baseline: Length) -> Self { + Self { size, baseline, elements: vec![] } } /// Add an element at a position. @@ -38,62 +40,45 @@ impl Frame { #[derive(Debug, Clone, PartialEq)] pub enum Element { /// Shaped text. - Text(ShapedText), + Text(Text), /// A geometric shape. Geometry(Geometry), /// A raster image. Image(Image), } -/// A shaped run of text. +/// A run of shaped text. #[derive(Debug, Clone, PartialEq)] -pub struct ShapedText { - /// The font face the text was shaped with. - pub face: FaceId, +pub struct Text { + /// The font face the glyphs are contained in. + pub face_id: FaceId, /// The font size. pub size: Length, - /// The width. - pub width: Length, - /// The extent to the top. - pub top: Length, - /// The extent to the bottom. - pub bottom: Length, /// The glyph fill color / texture. pub color: Fill, - /// The shaped glyphs. - pub glyphs: Vec, - /// The horizontal offsets of the glyphs. This is indexed parallel to - /// `glyphs`. Vertical offsets are not yet supported. - pub offsets: Vec, + /// The glyphs. + pub glyphs: Vec, } -impl ShapedText { - /// Create a new shape run with `width` zero and empty `glyphs` and `offsets`. - pub fn new( - face: FaceId, - size: Length, - top: Length, - bottom: Length, - color: Fill, - ) -> Self { - Self { - face, - size, - width: Length::ZERO, - top, - bottom, - glyphs: vec![], - offsets: vec![], - color, - } - } +/// A glyph in a run of shaped text. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Glyph { + /// The glyph's ID in the face. + pub id: GlyphId, + /// The advance width of the glyph. + pub x_advance: Length, + /// The horizontal offset of the glyph. + pub x_offset: Length, +} +impl Text { /// Encode the glyph ids into a big-endian byte buffer. pub fn encode_glyphs_be(&self) -> Vec { let mut bytes = Vec::with_capacity(2 * self.glyphs.len()); - for &GlyphId(g) in &self.glyphs { - bytes.push((g >> 8) as u8); - bytes.push((g & 0xff) as u8); + for glyph in &self.glyphs { + let id = glyph.id.0; + bytes.push((id >> 8) as u8); + bytes.push((id & 0xff) as u8); } bytes } diff --git a/src/layout/pad.rs b/src/layout/pad.rs index 2c8712af6..d24ca6549 100644 --- a/src/layout/pad.rs +++ b/src/layout/pad.rs @@ -38,6 +38,8 @@ fn pad(frame: &mut Frame, padding: Sides) { let origin = Point::new(padding.left, padding.top); frame.size = padded; + frame.baseline += origin.y; + for (point, _) in &mut frame.elements { *point += origin; } diff --git a/src/layout/par.rs b/src/layout/par.rs index e0b428216..f7d679810 100644 --- a/src/layout/par.rs +++ b/src/layout/par.rs @@ -1,7 +1,14 @@ use std::fmt::{self, Debug, Formatter}; +use std::mem; + +use unicode_bidi::{BidiInfo, Level}; +use xi_unicode::LineBreakIterator; use super::*; use crate::exec::FontProps; +use crate::util::{RangeExt, SliceExt}; + +type Range = std::ops::Range; /// A node that arranges its children into a paragraph. #[derive(Debug, Clone, PartialEq)] @@ -15,52 +22,63 @@ pub struct ParNode { } /// A child of a paragraph node. -#[derive(Debug, Clone, PartialEq)] +#[derive(Clone, PartialEq)] pub enum ParChild { /// Spacing between other nodes. Spacing(Length), /// A run of text and how to align it in its line. - Text(TextNode, Align), + Text(String, FontProps, Align), /// Any child node and how to align it in its line. Any(AnyNode, Align), - /// A forced linebreak. - Linebreak, -} - -/// A consecutive, styled run of text. -#[derive(Clone, PartialEq)] -pub struct TextNode { - /// The text. - pub text: String, - /// Properties used for font selection and layout. - pub props: FontProps, -} - -impl Debug for TextNode { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - write!(f, "Text({})", self.text) - } } impl Layout for ParNode { fn layout(&self, ctx: &mut LayoutContext, areas: &Areas) -> Vec { - let mut layouter = ParLayouter::new(self.dir, self.line_spacing, areas.clone()); - for child in &self.children { - match *child { - ParChild::Spacing(amount) => layouter.push_spacing(amount), - ParChild::Text(ref node, align) => { - let frame = shape(&node.text, &mut ctx.env.fonts, &node.props); - layouter.push_frame(frame, align); - } - ParChild::Any(ref node, align) => { - for frame in node.layout(ctx, &layouter.areas) { - layouter.push_frame(frame, align); - } - } - ParChild::Linebreak => layouter.finish_line(), - } + // Collect all text into one string used for BiDi analysis. + let text = self.collect_text(); + + // Find out the BiDi embedding levels. + let bidi = BidiInfo::new(&text, Level::from_dir(self.dir)); + + // Build a representation of the paragraph on which we can do + // linebreaking without layouting each and every line from scratch. + let layout = ParLayout::new(ctx, areas, self, bidi); + + // Find suitable linebreaks. + layout.build(ctx, areas.clone(), self) + } +} + +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. Returns the full text alongside the range each + /// child spans in the text. + fn collect_text(&self) -> String { + let mut text = String::new(); + for string in self.strings() { + text.push_str(string); } - layouter.finish() + text + } + + /// The range of each item in the collected text. + fn ranges(&self) -> impl Iterator + '_ { + 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 { + self.children.iter().map(|child| match child { + ParChild::Spacing(_) => " ", + ParChild::Text(ref piece, _, _) => piece, + ParChild::Any(_, _) => "\u{FFFC}", + }) } } @@ -70,174 +88,468 @@ impl From for AnyNode { } } -struct ParLayouter { - dirs: Gen, - main: SpecAxis, - cross: SpecAxis, - line_spacing: Length, - areas: Areas, - finished: Vec, - stack: Vec<(Length, Frame, Align)>, - stack_size: Gen, - line: Vec<(Length, Frame, Align)>, - line_size: Gen, - line_ruler: Align, +impl Debug for ParChild { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::Spacing(amount) => write!(f, "Spacing({:?})", amount), + Self::Text(text, _, align) => write!(f, "Text({:?}, {:?})", text, align), + Self::Any(any, align) => { + f.debug_tuple("Any").field(any).field(align).finish() + } + } + } } -impl ParLayouter { - fn new(dir: Dir, line_spacing: Length, areas: Areas) -> Self { - Self { - dirs: Gen::new(Dir::TTB, dir), - main: SpecAxis::Vertical, - cross: SpecAxis::Horizontal, - line_spacing, - areas, - finished: vec![], - stack: vec![], - stack_size: Gen::ZERO, - line: vec![], - line_size: Gen::ZERO, - line_ruler: Align::Start, - } - } +/// A paragraph representation in which children are already layouted and text +/// is separated into shapable runs. +struct ParLayout<'a> { + /// The top-level direction. + dir: Dir, + /// Bidirectional text embedding levels for the paragraph. + bidi: BidiInfo<'a>, + /// Layouted children and separated text runs. + items: Vec>, + /// The ranges of the items in `bidi.text`. + ranges: Vec, +} - fn push_spacing(&mut self, amount: Length) { - let cross_max = self.areas.current.get(self.cross); - self.line_size.cross = (self.line_size.cross + amount).min(cross_max); - } +impl<'a> ParLayout<'a> { + /// Build a paragraph layout for the given node. + fn new( + ctx: &mut LayoutContext, + areas: &Areas, + par: &'a ParNode, + bidi: BidiInfo<'a>, + ) -> Self { + // Prepare an iterator over each child an the range it spans. + let mut items = vec![]; + let mut ranges = vec![]; - fn push_frame(&mut self, frame: Frame, align: Align) { - // When the alignment of the last pushed frame (stored in the "ruler") - // is further to the end than the new `frame`, we need a line break. - // - // For example - // ``` - // #align(right)[First] #align(center)[Second] - // ``` - // would be laid out as: - // +----------------------------+ - // | First | - // | Second | - // +----------------------------+ - if self.line_ruler > align { - self.finish_line(); - } + // Layout the children and collect them into items. + for (range, child) in par.ranges().zip(&par.children) { + match *child { + ParChild::Spacing(amount) => { + items.push(ParItem::Spacing(amount)); + ranges.push(range); + } + ParChild::Text(_, ref props, align) => { + // TODO: Also split by language and script. + for (subrange, dir) in split_runs(&bidi, range) { + let text = &bidi.text[subrange.clone()]; + let shaped = shape(ctx, text, dir, props); + items.push(ParItem::Text(shaped, align)); + ranges.push(subrange); + } + } + ParChild::Any(ref node, align) => { + let frames = node.layout(ctx, areas); + assert_eq!(frames.len(), 1); - // Find out whether the area still has enough space for this frame. - // Space occupied by previous lines is already removed from - // `areas.current`, but the cross-extent of the current line needs to be - // subtracted to make sure the frame fits. - let fits = { - let mut usable = self.areas.current; - *usable.get_mut(self.cross) -= self.line_size.cross; - usable.fits(frame.size) - }; - - if !fits { - self.finish_line(); - - // Here, we can directly check whether the frame fits into - // `areas.current` since we just called `finish_line`. - while !self.areas.current.fits(frame.size) { - if self.areas.in_full_last() { - // The frame fits nowhere. - // TODO: Should this be placed into the first area or the last? - // TODO: Produce diagnostic once the necessary spans exist. - break; - } else { - self.finish_area(); + let frame = frames.into_iter().next().unwrap(); + items.push(ParItem::Frame(frame, align)); + ranges.push(range); } } } - // A line can contain frames with different alignments. They exact - // positions are calculated later depending on the alignments. - let size = frame.size.switch(self.main); - self.line.push((self.line_size.cross, frame, align)); - self.line_size.cross += size.cross; - self.line_size.main = self.line_size.main.max(size.main); - self.line_ruler = align; + Self { dir: par.dir, bidi, items, ranges } } - fn finish_line(&mut self) { - let full_size = { - let expand = self.areas.expand.get(self.cross); - let full = self.areas.full.get(self.cross); - Gen::new( - self.line_size.main, - expand.resolve(self.line_size.cross, full), - ) - }; + /// Find first-fit line breaks and build the paragraph. + fn build(self, ctx: &mut LayoutContext, areas: Areas, par: &ParNode) -> Vec { + let mut stack = LineStack::new(par.line_spacing, areas); - let mut output = Frame::new(full_size.switch(self.main).to_size()); + // The current line attempt. + // Invariant: Always fits into `stack.areas.current`. + let mut last = None; - for (before, frame, align) in std::mem::take(&mut self.line) { - let child_cross_size = frame.size.get(self.cross); + // The start of the line in `last`. + let mut start = 0; - // Position along the cross axis. - let cross = align.resolve(if self.dirs.cross.is_positive() { - let after_with_self = self.line_size.cross - before; - before .. full_size.cross - after_with_self + // Find suitable line breaks. + // TODO: Provide line break opportunities on alignment changes. + for (end, mandatory) in LineBreakIterator::new(self.bidi.text) { + // Compute the line and its size. + let mut line = LineLayout::new(ctx, &self, 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 !stack.areas.current.fits(line.size) { + if let Some((last_line, last_end)) = last.take() { + stack.push(last_line); + start = last_end; + line = LineLayout::new(ctx, &self, start .. end); + } + } + + // If the line does not fit vertically, we start a new area. + if !stack.areas.current.height.fits(line.size.height) + && !stack.areas.in_full_last() + { + stack.finish_area(ctx); + } + + if mandatory || !stack.areas.current.width.fits(line.size.width) { + // If the line does not fit horizontally or we have a mandatory + // line break (i.e. due to "\n"), we push the line into the + // stack. + stack.push(line); + start = end; + last = None; + + // If there is a trailing line break at the end of the + // paragraph, we want to force an empty line. + if mandatory && end == self.bidi.text.len() { + stack.push(LineLayout::new(ctx, &self, end .. end)); + } } else { - let before_with_self = before + child_cross_size; - let after = self.line_size.cross - (before + child_cross_size); - full_size.cross - before_with_self .. after - }); + // Otherwise, the line fits both horizontally and vertically + // and we remember it. + last = Some((line, end)); + } + } - let pos = Gen::new(Length::ZERO, cross).switch(self.main).to_point(); + if let Some((line, _)) = last { + stack.push(line); + } + + stack.finish(ctx) + } + + /// 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() + } +} + +/// Split a range of text into runs of consistent direction. +fn split_runs<'a>( + bidi: &'a BidiInfo, + range: Range, +) -> impl Iterator + 'a { + let mut cursor = range.start; + bidi.levels[range.clone()] + .group_by_key(|&level| level) + .map(move |(level, group)| { + let start = cursor; + cursor += group.len(); + (start .. cursor, level.dir()) + }) +} + +/// A prepared item in a paragraph layout. +enum ParItem<'a> { + /// Spacing between other items. + Spacing(Length), + /// A shaped text run with consistent direction. + Text(ShapedText<'a>, Align), + /// A layouted child node. + Frame(Frame, Align), +} + +impl ParItem<'_> { + /// The size of the item. + pub fn size(&self) -> Size { + match self { + Self::Spacing(amount) => Size::new(*amount, Length::ZERO), + Self::Text(shaped, _) => shaped.size, + Self::Frame(frame, _) => frame.size, + } + } + + /// The baseline of the item. + pub fn baseline(&self) -> Length { + match self { + Self::Spacing(_) => Length::ZERO, + Self::Text(shaped, _) => shaped.baseline, + Self::Frame(frame, _) => frame.baseline, + } + } +} + +/// A simple layouter that stacks lines into areas. +struct LineStack<'a> { + line_spacing: Length, + areas: Areas, + finished: Vec, + lines: Vec>, + size: Size, +} + +impl<'a> LineStack<'a> { + fn new(line_spacing: Length, areas: Areas) -> Self { + Self { + line_spacing, + areas, + finished: vec![], + lines: vec![], + size: Size::ZERO, + } + } + + fn push(&mut self, line: LineLayout<'a>) { + self.areas.current.height -= line.size.height + self.line_spacing; + + self.size.width = self.size.width.max(line.size.width); + self.size.height += line.size.height; + if !self.lines.is_empty() { + self.size.height += self.line_spacing; + } + + self.lines.push(line); + } + + fn finish_area(&mut self, ctx: &mut LayoutContext) { + let expand = self.areas.expand.horizontal; + self.size.width = expand.resolve(self.size.width, self.areas.full.width); + + let mut output = Frame::new(self.size, self.size.height); + let mut first = true; + let mut offset = Length::ZERO; + + for line in mem::take(&mut self.lines) { + let frame = line.build(ctx, self.size.width); + let Frame { size, baseline, .. } = frame; + + let pos = Point::new(Length::ZERO, offset); output.push_frame(pos, frame); - } - // Add line spacing, but only between lines. - if !self.stack.is_empty() { - self.stack_size.main += self.line_spacing; - *self.areas.current.get_mut(self.main) -= self.line_spacing; - } + if first { + output.baseline = offset + baseline; + first = false; + } - // Update metrics of paragraph and reset for line. - self.stack.push((self.stack_size.main, output, self.line_ruler)); - self.stack_size.main += full_size.main; - self.stack_size.cross = self.stack_size.cross.max(full_size.cross); - *self.areas.current.get_mut(self.main) -= full_size.main; - self.line_size = Gen::ZERO; - self.line_ruler = Align::Start; - } - - fn finish_area(&mut self) { - let full_size = self.stack_size; - let mut output = Frame::new(full_size.switch(self.main).to_size()); - - for (before, line, cross_align) in std::mem::take(&mut self.stack) { - let child_size = line.size.switch(self.main); - - // Position along the main axis. - let main = if self.dirs.main.is_positive() { - before - } else { - full_size.main - (before + child_size.main) - }; - - // Align along the cross axis. - let cross = cross_align.resolve(if self.dirs.cross.is_positive() { - Length::ZERO .. full_size.cross - child_size.cross - } else { - full_size.cross - child_size.cross .. Length::ZERO - }); - - let pos = Gen::new(main, cross).switch(self.main).to_point(); - output.push_frame(pos, line); + offset += size.height + self.line_spacing; } self.finished.push(output); self.areas.next(); - - // Reset metrics for the whole paragraph. - self.stack_size = Gen::ZERO; + self.size = Size::ZERO; } - fn finish(mut self) -> Vec { - self.finish_line(); - self.finish_area(); + fn finish(mut self, ctx: &mut LayoutContext) -> Vec { + self.finish_area(ctx); self.finished } } + +/// A lightweight representation of a line that spans a specific range in a +/// paragraph's text. This type enables you to cheaply measure the size of a +/// line in a range before comitting to building the line's frame. +struct LineLayout<'a> { + /// The paragraph the line was created in. + par: &'a ParLayout<'a>, + /// The range the line spans in the paragraph. + line: Range, + /// A reshaped text item if the line sliced up a text item at the start. + first: Option>, + /// Middle items which don't need to be reprocessed. + items: &'a [ParItem<'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>, + /// The ranges, indexed as `[first, ..items, last]`. The ranges for `first` + /// and `last` aren't trimmed to the line, but it doesn't matter because + /// we're just checking which range an index falls into. + ranges: &'a [Range], + /// The size of the line. + size: Size, + /// The baseline of the line. + baseline: Length, +} + +impl<'a> LineLayout<'a> { + /// Create a line which spans the given range. + fn new(ctx: &mut LayoutContext, par: &'a ParLayout<'a>, mut line: Range) -> Self { + // Find the items which bound the text range. + let last_idx = par.find(line.end.saturating_sub(1)).unwrap(); + let first_idx = if line.is_empty() { + last_idx + } else { + par.find(line.start).unwrap() + }; + + // Slice out the relevant items and ranges. + let mut items = &par.items[first_idx ..= last_idx]; + let ranges = &par.ranges[first_idx ..= last_idx]; + + // Reshape the last item if it's split in half. + let mut last = None; + if let Some((ParItem::Text(shaped, align), rest)) = items.split_last() { + // Compute the range we want to shape, trimming whitespace at the + // end of the line. + let base = par.ranges[last_idx].start; + let start = line.start.max(base); + let end = start + par.bidi.text[start .. line.end].trim_end().len(); + let range = start - base .. end - base; + + // Reshape if necessary. + if range.len() < shaped.text.len() { + // If start == end and the rest is empty, then we have an empty + // line. To make that line have the appropriate height, we shape the + // empty string. + if !range.is_empty() || rest.is_empty() { + // Reshape that part. + let reshaped = shaped.reshape(ctx, range); + last = Some(ParItem::Text(reshaped, *align)); + } + + items = rest; + line.end = end; + } + } + + // Reshape the start item if it's split in half. + let mut first = None; + if let Some((ParItem::Text(shaped, align), rest)) = items.split_first() { + // Compute the range we want to shape. + let Range { start: base, end: first_end } = par.ranges[first_idx]; + let start = line.start; + let end = line.end.min(first_end); + let range = start - base .. end - base; + + // Reshape if necessary. + if range.len() < shaped.text.len() { + if !range.is_empty() { + let reshaped = shaped.reshape(ctx, range); + first = Some(ParItem::Text(reshaped, *align)); + } + + items = rest; + } + } + + let mut width = Length::ZERO; + let mut top = Length::ZERO; + let mut bottom = Length::ZERO; + + // Measure the size of the line. + for item in first.iter().chain(items).chain(&last) { + let size = item.size(); + let baseline = item.baseline(); + width += size.width; + top = top.max(baseline); + bottom = bottom.max(size.height - baseline); + } + + Self { + par, + line, + first, + items, + last, + ranges, + size: Size::new(width, top + bottom), + baseline: top, + } + } + + /// Build the line's frame. + fn build(&self, ctx: &mut LayoutContext, width: Length) -> Frame { + let full_width = self.size.width.max(width); + let full_size = Size::new(full_width, self.size.height); + let free_width = full_width - self.size.width; + + let mut output = Frame::new(full_size, self.baseline); + let mut ruler = Align::Start; + let mut offset = Length::ZERO; + + self.reordered(|item| { + let frame = match *item { + ParItem::Spacing(amount) => { + offset += amount; + return; + } + ParItem::Text(ref shaped, align) => { + ruler = ruler.max(align); + shaped.build(ctx) + } + ParItem::Frame(ref frame, align) => { + ruler = ruler.max(align); + frame.clone() + } + }; + + let Frame { size, baseline, .. } = frame; + let pos = Point::new( + ruler.resolve(self.par.dir, offset .. free_width + offset), + self.baseline - baseline, + ); + + output.push_frame(pos, frame); + offset += size.width; + }); + + output + } + + /// Iterate through the line's items in visual order. + fn reordered(&self, mut f: impl FnMut(&ParItem<'a>)) { + // The bidi crate doesn't like empty lines. + if self.line.is_empty() { + return; + } + + // Find the paragraph that contains the line. + let para = self + .par + .bidi + .paragraphs + .iter() + .find(|para| para.range.contains(&self.line.start)) + .unwrap(); + + // Compute the reordered ranges in visual order (left to right). + let (levels, runs) = self.par.bidi.visual_runs(para, self.line.clone()); + + // Find the items for each run. + for run in runs { + let first_idx = self.find(run.start).unwrap(); + let last_idx = self.find(run.end - 1).unwrap(); + let range = first_idx ..= last_idx; + + // Provide the items forwards or backwards depending on the run's + // direction. + if levels[run.start].is_ltr() { + for item in range { + f(self.get(item).unwrap()); + } + } else { + for item in range.rev() { + f(self.get(item).unwrap()); + } + } + } + } + + /// 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() + } + + /// 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) + } +} + +/// Helper methods for BiDi levels. +trait LevelExt: Sized { + fn from_dir(dir: Dir) -> Option; + fn dir(self) -> Dir; +} + +impl LevelExt for Level { + fn from_dir(dir: Dir) -> Option { + match dir { + Dir::LTR => Some(Level::ltr()), + Dir::RTL => Some(Level::rtl()), + _ => None, + } + } + + fn dir(self) -> Dir { + if self.is_ltr() { Dir::LTR } else { Dir::RTL } + } +} diff --git a/src/layout/shaping.rs b/src/layout/shaping.rs index 8d035516d..faa178d37 100644 --- a/src/layout/shaping.rs +++ b/src/layout/shaping.rs @@ -1,30 +1,219 @@ +use std::borrow::Cow; +use std::fmt::{self, Debug, Formatter}; +use std::ops::Range; + use fontdock::FaceId; use rustybuzz::UnicodeBuffer; use ttf_parser::GlyphId; -use super::{Element, Frame, ShapedText}; +use super::{Element, Frame, Glyph, LayoutContext, Text}; use crate::env::FontLoader; use crate::exec::FontProps; -use crate::geom::{Point, Size}; +use crate::font::FaceBuf; +use crate::geom::{Dir, Length, Point, Size}; +use crate::util::SliceExt; -/// Shape text into a frame containing [`ShapedText`] runs. -pub fn shape(text: &str, loader: &mut FontLoader, props: &FontProps) -> Frame { - let mut frame = Frame::new(Size::ZERO); - shape_segment(&mut frame, text, loader, props, props.families.iter(), None); - frame +/// 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. +pub struct ShapedText<'a> { + /// The text that was shaped. + pub text: &'a str, + /// The text direction. + pub dir: Dir, + /// The properties used for font selection. + pub props: &'a FontProps, + /// 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]>, } -/// Shape text into a frame with font fallback using the `families` iterator. +/// 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 ID in the face. + pub glyph_id: GlyphId, + /// The advance width of the glyph. + pub x_advance: i32, + /// The horizontal offset of the glyph. + pub x_offset: i32, + /// 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, +} + +/// A visual side. +enum Side { + Left, + Right, +} + +impl<'a> ShapedText<'a> { + /// Build the shaped text's frame. + pub fn build(&self, ctx: &mut LayoutContext) -> Frame { + let mut frame = Frame::new(self.size, self.baseline); + let mut offset = Length::ZERO; + + 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 text = Text { + face_id, + size: self.props.size, + color: self.props.color, + glyphs: vec![], + }; + + let face = ctx.env.fonts.face(face_id); + for glyph in group { + let x_advance = face.convert(glyph.x_advance).scale(self.props.size); + let x_offset = face.convert(glyph.x_offset).scale(self.props.size); + text.glyphs.push(Glyph { id: glyph.glyph_id, x_advance, x_offset }); + offset += x_advance; + } + + frame.push(pos, Element::Text(text)); + } + + frame + } + + /// Reshape a range of the shaped text, reusing information from this + /// shaping process if possible. + pub fn reshape( + &'a self, + ctx: &mut LayoutContext, + text_range: Range, + ) -> ShapedText<'a> { + if let Some(glyphs) = self.slice_safe_to_break(text_range.clone()) { + let (size, baseline) = measure(&mut ctx.env.fonts, glyphs, self.props); + Self { + text: &self.text[text_range], + dir: self.dir, + props: self.props, + size, + baseline, + glyphs: Cow::Borrowed(glyphs), + } + } else { + shape(ctx, &self.text[text_range], self.dir, self.props) + } + } + + /// 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) + } +} + +impl Debug for ShapedText<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "Shaped({:?})", self.text) + } +} + +/// Shape text into [`ShapedText`]. +pub fn shape<'a>( + ctx: &mut LayoutContext, + text: &'a str, + dir: Dir, + props: &'a FontProps, +) -> ShapedText<'a> { + let loader = &mut ctx.env.fonts; + + let mut glyphs = vec![]; + let families = props.families.iter(); + if !text.is_empty() { + shape_segment(loader, &mut glyphs, 0, text, dir, props, families, None); + } + + let (size, baseline) = measure(loader, &glyphs, props); + + ShapedText { + text, + dir, + props, + size, + baseline, + glyphs: Cow::Owned(glyphs), + } +} + +/// Shape text with font fallback using the `families` iterator. fn shape_segment<'a>( - frame: &mut Frame, - text: &str, loader: &mut FontLoader, + glyphs: &mut Vec, + base: usize, + text: &str, + dir: Dir, props: &FontProps, mut families: impl Iterator + Clone, - mut first: Option, + mut first_face: Option, ) { // Select the font family. - let (id, fallback) = loop { + let (face_id, fallback) = loop { // Try to load the next available font family. match families.next() { Some(family) => match loader.query(family, props.variant) { @@ -33,97 +222,140 @@ fn shape_segment<'a>( }, // 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 { + None => match first_face { Some(id) => break (id, false), None => return, }, } }; - // Register that this is the first available font. - if first.is_none() { - first = Some(id); - } - - // Find out some metrics and prepare the shaped text container. - let face = loader.face(id); - let ttf = face.ttf(); - let units_per_em = f64::from(ttf.units_per_em().unwrap_or(1000)); - let convert = |units| f64::from(units) / units_per_em * props.size; - let top = convert(i32::from(props.top_edge.lookup(ttf))); - let bottom = convert(i32::from(props.bottom_edge.lookup(ttf))); - let mut shaped = ShapedText::new(id, props.size, top, bottom, props.color); + // 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.guess_segment_properties(); - - // Find out the text direction. - // TODO: Replace this once we do BiDi. - let rtl = matches!(buffer.direction(), rustybuzz::Direction::RightToLeft); + buffer.set_direction(match dir { + Dir::LTR => rustybuzz::Direction::LeftToRight, + Dir::RTL => rustybuzz::Direction::RightToLeft, + _ => unimplemented!(), + }); // Shape! - let glyphs = rustybuzz::shape(face.buzz(), &[], buffer); - let info = glyphs.glyph_infos(); - let pos = glyphs.glyph_positions(); - let mut iter = info.iter().zip(pos).peekable(); + let buffer = rustybuzz::shape(loader.face(face_id).ttf(), &[], buffer); + let infos = buffer.glyph_infos(); + let pos = buffer.glyph_positions(); - while let Some((info, pos)) = iter.next() { - // Do font fallback if the glyph is a tofu. - if info.codepoint == 0 && fallback { - // Flush what we have so far. - if !shaped.glyphs.is_empty() { - place(frame, shaped); - shaped = ShapedText::new(id, props.size, top, bottom, props.color); - } + // 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; - // Determine the start and end cluster index of the tofu sequence. - let mut start = info.cluster as usize; - let mut end = info.cluster as usize; - while let Some((info, _)) = iter.peek() { - if info.codepoint != 0 { - break; - } - end = info.cluster as usize; - iter.next(); - } - - // Because Harfbuzz outputs glyphs in visual order, the start - // cluster actually corresponds to the last codepoint in - // right-to-left text. - if rtl { - assert!(end <= start); - std::mem::swap(&mut start, &mut end); - } - - // The end cluster index points right before the last character that - // mapped to the tofu sequence. So we have to offset the end by one - // char. - let offset = text[end ..].chars().next().unwrap().len_utf8(); - let range = start .. end + offset; - - // Recursively shape the tofu sequence with the next family. - shape_segment(frame, &text[range], loader, props, families.clone(), first); - } else { + if info.codepoint != 0 || !fallback { // Add the glyph to the shaped output. // TODO: Don't ignore y_advance and y_offset. - let glyph = GlyphId(info.codepoint as u16); - shaped.glyphs.push(glyph); - shaped.offsets.push(shaped.width + convert(pos.x_offset)); - shaped.width += convert(pos.x_advance); + glyphs.push(ShapedGlyph { + face_id, + glyph_id: GlyphId(info.codepoint as u16), + x_advance: pos[i].x_advance, + x_offset: 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.codepoint == 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( + loader, + glyphs, + base + range.start, + &text[range], + dir, + props, + families.clone(), + first_face, + ); + } + + i += 1; + } +} + +/// Measure the size and baseline of a run of shaped glyphs with the given +/// properties. +fn measure( + loader: &mut FontLoader, + glyphs: &[ShapedGlyph], + props: &FontProps, +) -> (Size, Length) { + let mut width = Length::ZERO; + let mut top = Length::ZERO; + let mut bottom = Length::ZERO; + let mut expand_vertical = |face: &FaceBuf| { + top = top.max(face.vertical_metric(props.top_edge).scale(props.size)); + bottom = bottom.max(-face.vertical_metric(props.bottom_edge).scale(props.size)); + }; + + if glyphs.is_empty() { + // When there are no glyphs, we just use the vertical metrics of the + // first available font. + for family in props.families.iter() { + if let Some(face_id) = loader.query(family, props.variant) { + expand_vertical(loader.face(face_id)); + break; + } + } + } else { + for (face_id, group) in glyphs.group_by_key(|g| g.face_id) { + let face = loader.face(face_id); + expand_vertical(face); + + for glyph in group { + width += face.convert(glyph.x_advance).scale(props.size); + } } } - if !shaped.glyphs.is_empty() { - place(frame, shaped) - } -} - -/// Place shaped text into a frame. -fn place(frame: &mut Frame, shaped: ShapedText) { - let offset = frame.size.width; - frame.size.width += shaped.width; - frame.size.height = frame.size.height.max(shaped.top - shaped.bottom); - frame.push(Point::new(offset, shaped.top), Element::Text(shaped)); + (Size::new(width, top + bottom), top) } diff --git a/src/layout/stack.rs b/src/layout/stack.rs index 79fde72d1..b69936ba8 100644 --- a/src/layout/stack.rs +++ b/src/layout/stack.rs @@ -28,7 +28,13 @@ impl Layout for StackNode { match *child { StackChild::Spacing(amount) => layouter.push_spacing(amount), StackChild::Any(ref node, aligns) => { - for frame in node.layout(ctx, &layouter.areas) { + let mut frames = node.layout(ctx, &layouter.areas).into_iter(); + if let Some(frame) = frames.next() { + layouter.push_frame(frame, aligns); + } + + for frame in frames { + layouter.finish_area(); layouter.push_frame(frame, aligns); } } @@ -116,32 +122,39 @@ impl StackLayouter { size = Size::new(width, width / aspect); } - size.switch(self.main) + size }; - let mut output = Frame::new(full_size.switch(self.main).to_size()); + let mut output = Frame::new(full_size, full_size.height); + let mut first = true; + let full_size = full_size.switch(self.main); for (before, frame, aligns) in std::mem::take(&mut self.frames) { let child_size = frame.size.switch(self.main); // Align along the main axis. - let main = aligns.main.resolve(if self.dirs.main.is_positive() { - let after_with_self = self.size.main - before; - before .. full_size.main - after_with_self - } else { - let before_with_self = before + child_size.main; - let after = self.size.main - (before + child_size.main); - full_size.main - before_with_self .. after - }); + let main = aligns.main.resolve( + self.dirs.main, + if self.dirs.main.is_positive() { + before .. before + full_size.main - self.size.main + } else { + self.size.main - (before + child_size.main) + .. full_size.main - (before + child_size.main) + }, + ); // Align along the cross axis. - let cross = aligns.cross.resolve(if self.dirs.cross.is_positive() { - Length::ZERO .. full_size.cross - child_size.cross - } else { - full_size.cross - child_size.cross .. Length::ZERO - }); + let cross = aligns.cross.resolve( + self.dirs.cross, + Length::ZERO .. full_size.cross - child_size.cross, + ); let pos = Gen::new(main, cross).switch(self.main).to_point(); + if first { + output.baseline = pos.y + frame.baseline; + first = false; + } + output.push_frame(pos, frame); } diff --git a/src/lib.rs b/src/lib.rs index 9e09a9b45..2802c3866 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,7 @@ pub mod parse; pub mod pdf; pub mod pretty; pub mod syntax; +pub mod util; use crate::diag::Pass; use crate::env::Env; diff --git a/src/library/image.rs b/src/library/image.rs index ed7b2268a..09b563360 100644 --- a/src/library/image.rs +++ b/src/library/image.rs @@ -73,7 +73,7 @@ impl Layout for ImageNode { } }; - let mut frame = Frame::new(size); + let mut frame = Frame::new(size, size.height); frame.push(Point::ZERO, Element::Image(Image { res: self.res, size })); vec![frame] diff --git a/src/library/markup.rs b/src/library/markup.rs index ac2356a9c..0ace43b6f 100644 --- a/src/library/markup.rs +++ b/src/library/markup.rs @@ -160,7 +160,7 @@ pub fn raw(ctx: &mut EvalContext, args: &mut FuncArgs) -> Value { let snapshot = ctx.state.clone(); ctx.set_monospace(); - ctx.push_text(&text); + ctx.push_text(text.clone()); ctx.state = snapshot; if block { diff --git a/src/pdf/mod.rs b/src/pdf/mod.rs index a97dfa2c1..365275444 100644 --- a/src/pdf/mod.rs +++ b/src/pdf/mod.rs @@ -15,6 +15,7 @@ use ttf_parser::{name_id, GlyphId}; use crate::color::Color; use crate::env::{Env, ImageResource, ResourceId}; +use crate::font::{EmLength, VerticalFontMetric}; use crate::geom::{self, Length, Size}; use crate::layout::{Element, Fill, Frame, Image, Shape}; @@ -50,7 +51,7 @@ impl<'a> PdfExporter<'a> { for frame in frames { for (_, element) in &frame.elements { match element { - Element::Text(shaped) => fonts.insert(shaped.face), + Element::Text(shaped) => fonts.insert(shaped.face_id), Element::Image(image) => { let img = env.resources.loaded::(image.res); if img.buf.color().has_alpha() { @@ -187,11 +188,11 @@ impl<'a> PdfExporter<'a> { // Then, also check if we need to issue a font switching // action. - if shaped.face != face || shaped.size != size { - face = shaped.face; + if shaped.face_id != face || shaped.size != size { + face = shaped.face_id; size = shaped.size; - let name = format!("F{}", self.fonts.map(shaped.face)); + let name = format!("F{}", self.fonts.map(shaped.face_id)); text.font(Name(name.as_bytes()), size.to_pt() as f32); } @@ -234,24 +235,18 @@ impl<'a> PdfExporter<'a> { flags.insert(FontFlags::SYMBOLIC); flags.insert(FontFlags::SMALL_CAP); - // Convert from OpenType font units to PDF glyph units. - let em_per_unit = 1.0 / ttf.units_per_em().unwrap_or(1000) as f32; - let convert = |font_unit: f32| (1000.0 * em_per_unit * font_unit).round(); - let convert_i16 = |font_unit: i16| convert(font_unit as f32); - let convert_u16 = |font_unit: u16| convert(font_unit as f32); - let global_bbox = ttf.global_bounding_box(); let bbox = Rect::new( - convert_i16(global_bbox.x_min), - convert_i16(global_bbox.y_min), - convert_i16(global_bbox.x_max), - convert_i16(global_bbox.y_max), + face.convert(global_bbox.x_min).to_pdf(), + face.convert(global_bbox.y_min).to_pdf(), + face.convert(global_bbox.x_max).to_pdf(), + face.convert(global_bbox.y_max).to_pdf(), ); let italic_angle = ttf.italic_angle().unwrap_or(0.0); - let ascender = convert_i16(ttf.typographic_ascender().unwrap_or(0)); - let descender = convert_i16(ttf.typographic_descender().unwrap_or(0)); - let cap_height = ttf.capital_height().map(convert_i16); + let ascender = face.vertical_metric(VerticalFontMetric::Ascender).to_pdf(); + let descender = face.vertical_metric(VerticalFontMetric::Descender).to_pdf(); + let cap_height = face.vertical_metric(VerticalFontMetric::CapHeight).to_pdf(); let stem_v = 10.0 + 0.244 * (f32::from(ttf.weight().to_number()) - 50.0); // Write the base font object referencing the CID font. @@ -272,8 +267,8 @@ impl<'a> PdfExporter<'a> { .individual(0, { let num_glyphs = ttf.number_of_glyphs(); (0 .. num_glyphs).map(|g| { - let advance = ttf.glyph_hor_advance(GlyphId(g)); - convert_u16(advance.unwrap_or(0)) + let x = ttf.glyph_hor_advance(GlyphId(g)).unwrap_or(0); + face.convert(x).to_pdf() }) }); @@ -286,7 +281,7 @@ impl<'a> PdfExporter<'a> { .italic_angle(italic_angle) .ascent(ascender) .descent(descender) - .cap_height(cap_height.unwrap_or(ascender)) + .cap_height(cap_height) .stem_v(stem_v) .font_file2(refs.data); @@ -571,3 +566,15 @@ where self.to_layout.iter().copied() } } + +/// Additional methods for [`EmLength`]. +trait EmLengthExt { + /// Convert an em length to a number of PDF font units. + fn to_pdf(self) -> f32; +} + +impl EmLengthExt for EmLength { + fn to_pdf(self) -> f32 { + 1000.0 * self.get() as f32 + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 000000000..72db4518b --- /dev/null +++ b/src/util.rs @@ -0,0 +1,81 @@ +//! Utilities. + +use std::cmp::Ordering; +use std::ops::Range; + +/// Additional methods for slices. +pub trait SliceExt { + /// Split a slice into consecutive groups with the same key. + /// + /// Returns an iterator of pairs of a key and the group with that key. + fn group_by_key(&self, f: F) -> GroupByKey<'_, T, F> + where + F: FnMut(&T) -> K, + K: PartialEq; +} + +impl SliceExt for [T] { + fn group_by_key(&self, f: F) -> GroupByKey<'_, T, F> + where + F: FnMut(&T) -> K, + K: PartialEq, + { + GroupByKey { slice: self, f } + } +} + +/// This struct is produced by [`SliceExt::group_by_key`]. +pub struct GroupByKey<'a, T, F> { + slice: &'a [T], + f: F, +} + +impl<'a, T, K, F> Iterator for GroupByKey<'a, T, F> +where + F: FnMut(&T) -> K, + K: PartialEq, +{ + type Item = (K, &'a [T]); + + fn next(&mut self) -> Option { + let first = self.slice.first()?; + let key = (self.f)(first); + + let mut i = 1; + while self.slice.get(i).map_or(false, |t| (self.f)(t) == key) { + i += 1; + } + + let (head, tail) = self.slice.split_at(i); + self.slice = tail; + Some((key, head)) + } +} + +/// Additional methods for [`Range`]. +pub trait RangeExt { + /// Locate a position relative to a range. + /// + /// This can be used for binary searching the range that contains the + /// position as follows: + /// ``` + /// # use typst::util::RangeExt; + /// assert_eq!( + /// [1..2, 2..7, 7..10].binary_search_by(|r| r.locate(5)), + /// Ok(1), + /// ); + /// ``` + fn locate(&self, pos: usize) -> Ordering; +} + +impl RangeExt for Range { + fn locate(&self, pos: usize) -> Ordering { + if pos < self.start { + Ordering::Greater + } else if pos < self.end { + Ordering::Equal + } else { + Ordering::Less + } + } +} diff --git a/tests/ref/comment.png b/tests/ref/comment.png index 6d5723efd..6654e1255 100644 Binary files a/tests/ref/comment.png and b/tests/ref/comment.png differ diff --git a/tests/ref/control/for.png b/tests/ref/control/for.png index bf6b535c6..f9abf0741 100644 Binary files a/tests/ref/control/for.png and b/tests/ref/control/for.png differ diff --git a/tests/ref/control/invalid.png b/tests/ref/control/invalid.png index e9c64b8a4..bfd3ec2cf 100644 Binary files a/tests/ref/control/invalid.png and b/tests/ref/control/invalid.png differ diff --git a/tests/ref/control/while.png b/tests/ref/control/while.png index 2ea1bcb0a..c0045350e 100644 Binary files a/tests/ref/control/while.png and b/tests/ref/control/while.png differ diff --git a/tests/ref/expand.png b/tests/ref/expand.png index f18e929b0..e07e15e8c 100644 Binary files a/tests/ref/expand.png and b/tests/ref/expand.png differ diff --git a/tests/ref/expr/block.png b/tests/ref/expr/block.png index d8ea84fb9..6b0dd540a 100644 Binary files a/tests/ref/expr/block.png and b/tests/ref/expr/block.png differ diff --git a/tests/ref/expr/call-invalid.png b/tests/ref/expr/call-invalid.png index 8e0c5eb6d..f2f90f095 100644 Binary files a/tests/ref/expr/call-invalid.png and b/tests/ref/expr/call-invalid.png differ diff --git a/tests/ref/expr/call.png b/tests/ref/expr/call.png index f05fb8351..ae05f6173 100644 Binary files a/tests/ref/expr/call.png and b/tests/ref/expr/call.png differ diff --git a/tests/ref/expr/ops.png b/tests/ref/expr/ops.png index e6f2edabf..b566b5b75 100644 Binary files a/tests/ref/expr/ops.png and b/tests/ref/expr/ops.png differ diff --git a/tests/ref/full/coma.png b/tests/ref/full/coma.png index d869d57ee..2d067364a 100644 Binary files a/tests/ref/full/coma.png and b/tests/ref/full/coma.png differ diff --git a/tests/ref/library/circle.png b/tests/ref/library/circle.png index f11286422..8364d42b9 100644 Binary files a/tests/ref/library/circle.png and b/tests/ref/library/circle.png differ diff --git a/tests/ref/library/ellipse.png b/tests/ref/library/ellipse.png index c78c92727..2e52b515c 100644 Binary files a/tests/ref/library/ellipse.png and b/tests/ref/library/ellipse.png differ diff --git a/tests/ref/library/font.png b/tests/ref/library/font.png index 07c657261..44183b3ac 100644 Binary files a/tests/ref/library/font.png and b/tests/ref/library/font.png differ diff --git a/tests/ref/library/lang.png b/tests/ref/library/lang.png index 98a63b6e7..ecb8820a1 100644 Binary files a/tests/ref/library/lang.png and b/tests/ref/library/lang.png differ diff --git a/tests/ref/library/pad.png b/tests/ref/library/pad.png index 3bc0ddd06..0bf0adde7 100644 Binary files a/tests/ref/library/pad.png and b/tests/ref/library/pad.png differ diff --git a/tests/ref/library/page.png b/tests/ref/library/page.png index 9d2a6b959..dfe6f8cd1 100644 Binary files a/tests/ref/library/page.png and b/tests/ref/library/page.png differ diff --git a/tests/ref/library/pagebreak.png b/tests/ref/library/pagebreak.png index ab990c691..b671605c6 100644 Binary files a/tests/ref/library/pagebreak.png and b/tests/ref/library/pagebreak.png differ diff --git a/tests/ref/library/paragraph.png b/tests/ref/library/paragraph.png index 378980170..41742f20d 100644 Binary files a/tests/ref/library/paragraph.png and b/tests/ref/library/paragraph.png differ diff --git a/tests/ref/library/rect.png b/tests/ref/library/rect.png index 56f1003fc..5f6df7b68 100644 Binary files a/tests/ref/library/rect.png and b/tests/ref/library/rect.png differ diff --git a/tests/ref/library/spacing.png b/tests/ref/library/spacing.png index fa403a6df..2205809a6 100644 Binary files a/tests/ref/library/spacing.png and b/tests/ref/library/spacing.png differ diff --git a/tests/ref/library/square.png b/tests/ref/library/square.png index 26469d20a..def2e664f 100644 Binary files a/tests/ref/library/square.png and b/tests/ref/library/square.png differ diff --git a/tests/ref/markup/basic.png b/tests/ref/markup/basic.png index a43fcbcbc..ecac0e8d6 100644 Binary files a/tests/ref/markup/basic.png and b/tests/ref/markup/basic.png differ diff --git a/tests/ref/markup/emph.png b/tests/ref/markup/emph.png index 9b6bda5cd..a43110e91 100644 Binary files a/tests/ref/markup/emph.png and b/tests/ref/markup/emph.png differ diff --git a/tests/ref/markup/escape.png b/tests/ref/markup/escape.png index 9c6f1f599..686471c61 100644 Binary files a/tests/ref/markup/escape.png and b/tests/ref/markup/escape.png differ diff --git a/tests/ref/markup/heading.png b/tests/ref/markup/heading.png index a7de742d2..46b5b637b 100644 Binary files a/tests/ref/markup/heading.png and b/tests/ref/markup/heading.png differ diff --git a/tests/ref/markup/raw.png b/tests/ref/markup/raw.png index a20ca9994..cd2a45490 100644 Binary files a/tests/ref/markup/raw.png and b/tests/ref/markup/raw.png differ diff --git a/tests/ref/markup/strong.png b/tests/ref/markup/strong.png index 4bbf6ac0e..ce96d6a2d 100644 Binary files a/tests/ref/markup/strong.png and b/tests/ref/markup/strong.png differ diff --git a/tests/ref/repr.png b/tests/ref/repr.png index 71f415ef2..b93677f16 100644 Binary files a/tests/ref/repr.png and b/tests/ref/repr.png differ diff --git a/tests/ref/spacing.png b/tests/ref/spacing.png index 454b154fd..82f7e8d23 100644 Binary files a/tests/ref/spacing.png and b/tests/ref/spacing.png differ diff --git a/tests/ref/text/align.png b/tests/ref/text/align.png new file mode 100644 index 000000000..9415214b5 Binary files /dev/null and b/tests/ref/text/align.png differ diff --git a/tests/ref/text/basic.png b/tests/ref/text/basic.png index a06c87638..ef265cbf0 100644 Binary files a/tests/ref/text/basic.png and b/tests/ref/text/basic.png differ diff --git a/tests/ref/text/bidi.png b/tests/ref/text/bidi.png new file mode 100644 index 000000000..0b7a8c2bd Binary files /dev/null and b/tests/ref/text/bidi.png differ diff --git a/tests/ref/text/chinese.png b/tests/ref/text/chinese.png new file mode 100644 index 000000000..fa905df6b Binary files /dev/null and b/tests/ref/text/chinese.png differ diff --git a/tests/ref/text/linebreaks.png b/tests/ref/text/linebreaks.png new file mode 100644 index 000000000..c5a826bd2 Binary files /dev/null and b/tests/ref/text/linebreaks.png differ diff --git a/tests/ref/text/shaping.png b/tests/ref/text/shaping.png index 9af49f160..09f154ab3 100644 Binary files a/tests/ref/text/shaping.png and b/tests/ref/text/shaping.png differ diff --git a/tests/ref/text/whitespace.png b/tests/ref/text/whitespace.png new file mode 100644 index 000000000..35a213206 Binary files /dev/null and b/tests/ref/text/whitespace.png differ diff --git a/tests/typ/library/paragraph.typ b/tests/typ/library/paragraph.typ index 74fb81895..a26aed840 100644 --- a/tests/typ/library/paragraph.typ +++ b/tests/typ/library/paragraph.typ @@ -3,6 +3,7 @@ --- // Test configuring paragraph properties. +// FIXME: Word spacing doesn't work due to new shaping process. #par(spacing: 10pt, leading: 25%, word-spacing: 1pt) But, soft! what light through yonder window breaks? It is the east, and Juliet diff --git a/tests/typ/library/rect.typ b/tests/typ/library/rect.typ index 407134114..9acb0975b 100644 --- a/tests/typ/library/rect.typ +++ b/tests/typ/library/rect.typ @@ -19,7 +19,7 @@ // Not visible, but creates a gap between the boxes above and below // due to line spacing. -#rect(width: 2in, fill: #ff0000) +#rect(width: 1in, fill: #ff0000) // These are in a row! #rect(width: 0.5in, height: 10pt, fill: #D6CD67) diff --git a/tests/typ/markup/basic.typ b/tests/typ/markup/basic.typ index 3e83b9113..bfe3d2cb0 100644 --- a/tests/typ/markup/basic.typ +++ b/tests/typ/markup/basic.typ @@ -3,7 +3,7 @@ --- #let linebreak() = [ // Inside the old line break definition is still active. - #circle(radius: 2pt, fill: #000) \ + #square(length: 3pt, fill: #000) \ ] A \ B \ C diff --git a/tests/typ/text/align.typ b/tests/typ/text/align.typ new file mode 100644 index 000000000..27c650a49 --- /dev/null +++ b/tests/typ/text/align.typ @@ -0,0 +1,28 @@ +// Test text alignment. + +--- +// Test that alignment depends on the paragraph's full width. +#rect[ + Hello World \ + #align(right)[World] +] + +--- +// Test that a line with multiple alignments respects the paragraph's full +// width. +#rect[ + Hello #align(center)[World] \ + Hello from the World +] + +--- +// Test that `start` alignment after `end` alignment doesn't do anything until +// the next line break ... +L #align(right)[R] R + +// ... but make sure it resets to left after the line break. +L #align(right)[R] \ L + +--- +// FIXME: There should be a line break opportunity on alignment change. +LLLLLLLLLLLLL#align(center)[CCCC] diff --git a/tests/typ/text/basic.typ b/tests/typ/text/basic.typ index b424cee82..d5f0de051 100644 --- a/tests/typ/text/basic.typ +++ b/tests/typ/text/basic.typ @@ -1,6 +1,7 @@ // Test simple text. -#page(width: 250pt) +--- +#page(width: 250pt, height: 110pt) But, soft! what light through yonder window breaks? It is the east, and Juliet is the sun. Arise, fair sun, and kill the envious moon, Who is already sick and diff --git a/tests/typ/text/bidi.typ b/tests/typ/text/bidi.typ new file mode 100644 index 000000000..44f0cc15d --- /dev/null +++ b/tests/typ/text/bidi.typ @@ -0,0 +1,49 @@ +// Test bidirectional text. + +--- +// Test reordering with different top-level paragraph directions. +#let text = [Text טֶקסט] +#font("EB Garamond", "Noto Serif Hebrew") +#lang("de") {text} +#lang("he") {text} + +--- +// Test that consecutiv, embedded LTR runs stay LTR. +// Here, we have two runs: "A" and italic "B". +#let text = [أنت A_B_مطرC] +#font("EB Garamond", "Noto Sans Arabic") +#lang("de") {text} +#lang("ar") {text} + +--- +// Test that consecutive, embedded RTL runs stay RTL. +// Here, we have three runs: "גֶ", bold "שֶׁ", and "ם". +#let text = [Aגֶ*שֶׁ*םB] +#font("EB Garamond", "Noto Serif Hebrew") +#lang("de") {text} +#lang("he") {text} + +--- +// Test embedding up to level 4 with isolates. +#font("EB Garamond", "Noto Serif Hebrew", "Twitter Color Emoji") +#lang(dir: rtl) +א\u{2066}A\u{2067}Bב\u{2069}? + +--- +// Test hard line break (leads to two paragraphs in unicode-bidi). +#font("Noto Sans Arabic", "EB Garamond") +#lang("ar") +Life المطر هو الحياة \ +الحياة تمطر is rain. + +--- +// Test spacing. +#font("EB Garamond", "Noto Serif Hebrew") +L #h(1cm) ריווחR \ +Lריווח #h(1cm) R + +--- +// Test inline object. +#font("Noto Serif Hebrew", "EB Garamond") +#lang("he") +קרנפיםRh#image("res/rhino.png", height: 11pt)inoחיים diff --git a/tests/typ/text/chinese.typ b/tests/typ/text/chinese.typ new file mode 100644 index 000000000..0800a2209 --- /dev/null +++ b/tests/typ/text/chinese.typ @@ -0,0 +1,9 @@ +// Test chinese text from Wikipedia. + +--- +#font("Noto Serif CJK SC") + +是美国广播公司电视剧《迷失》第3季的第22和23集,也是全剧的第71集和72集 +由执行制作人戴蒙·林道夫和卡尔顿·库斯编剧,导演则是另一名执行制作人杰克·本德 +节目于2007年5月23日在美国和加拿大首播,共计吸引了1400万美国观众收看 +本集加上插播广告一共也持续有两个小时 diff --git a/tests/typ/text/linebreaks.typ b/tests/typ/text/linebreaks.typ new file mode 100644 index 000000000..4d57834a1 --- /dev/null +++ b/tests/typ/text/linebreaks.typ @@ -0,0 +1,28 @@ +// Test line breaking special cases. + +--- +// Test overlong word that is not directly after a hard break. +This is a spaceexceedinglylongishy. + +--- +// Test two overlong words in a row. +Supercalifragilisticexpialidocious Expialigoricmetrioxidation. + +--- +// Test that there are no unwanted line break opportunities on run change. +This is partly emph_as_ized. + +--- +Hard \ break. + +--- +// Test hard break directly after normal break. +Hard break directly after \ normal break. + +--- +// Test consecutive breaks. +Two consecutive \ \ breaks and three \ \ \ more. + +--- +// Test trailing newline. +Trailing break \ diff --git a/tests/typ/text/shaping.typ b/tests/typ/text/shaping.typ index 567a208d2..ba543e715 100644 --- a/tests/typ/text/shaping.typ +++ b/tests/typ/text/shaping.typ @@ -8,7 +8,7 @@ Le fira // This should just shape nicely. #font("Noto Sans Arabic") -منش إلا بسم الله +دع النص يمطر عليك // This should form a three-member family. #font("Twitter Color Emoji") @@ -26,7 +26,7 @@ Le fira A😀B // Font fallback for entire text. -منش إلا بسم الله +دع النص يمطر عليك // Font fallback in right-to-left text. ب🐈😀سم @@ -36,3 +36,10 @@ Aب😀🏞سمB // Tofus are rendered with the first font. A🐈中文B + +--- +// Test reshaping. + +#font("Noto Serif Hebrew") +#lang("he") +ס \ טֶ diff --git a/tests/typ/text/whitespace.typ b/tests/typ/text/whitespace.typ new file mode 100644 index 000000000..3d7dd2e2f --- /dev/null +++ b/tests/typ/text/whitespace.typ @@ -0,0 +1,17 @@ +// Test whitespace handling. + +--- +// Test that a run consisting only of whitespace isn't trimmed. +A#font("PT Sans")[ ]B + +--- +// Test font change after space. +Left #font("PT Sans")[Right]. + +--- +// Test that space at start of line is not trimmed. +A{"\n"} B + +--- +// Test that trailing space does not force a line break. +LLLLLLLLLLLLLL R _L_ diff --git a/tests/typeset.rs b/tests/typeset.rs index 5c35d6b4c..6aef27463 100644 --- a/tests/typeset.rs +++ b/tests/typeset.rs @@ -20,7 +20,7 @@ use typst::env::{Env, FsIndexExt, ImageResource, ResourceLoader}; use typst::eval::{EvalContext, FuncArgs, FuncValue, Scope, Value}; use typst::exec::State; use typst::geom::{self, Length, Point, Sides, Size}; -use typst::layout::{Element, Fill, Frame, Geometry, Image, Shape, ShapedText}; +use typst::layout::{Element, Fill, Frame, Geometry, Image, Shape, Text}; use typst::library; use typst::parse::{LineMap, Scanner}; use typst::pdf; @@ -413,19 +413,19 @@ fn draw(env: &Env, frames: &[Frame], pixel_per_pt: f32) -> Pixmap { canvas } -fn draw_text(canvas: &mut Pixmap, env: &Env, ts: Transform, shaped: &ShapedText) { - let ttf = env.fonts.face(shaped.face).ttf(); +fn draw_text(canvas: &mut Pixmap, env: &Env, ts: Transform, shaped: &Text) { + let ttf = env.fonts.face(shaped.face_id).ttf(); + let mut x = 0.0; - for (&glyph, &offset) in shaped.glyphs.iter().zip(&shaped.offsets) { - let units_per_em = ttf.units_per_em().unwrap_or(1000); - - let x = offset.to_pt() as f32; - let s = (shaped.size / units_per_em as f64).to_pt() as f32; - let ts = ts.pre_translate(x, 0.0); + for glyph in &shaped.glyphs { + let units_per_em = ttf.units_per_em(); + let s = shaped.size.to_pt() as f32 / units_per_em as f32; + let dx = glyph.x_offset.to_pt() as f32; + let ts = ts.pre_translate(x + dx, 0.0); // Try drawing SVG if present. if let Some(tree) = ttf - .glyph_svg_image(glyph) + .glyph_svg_image(glyph.id) .and_then(|data| std::str::from_utf8(data).ok()) .map(|svg| { let viewbox = format!("viewBox=\"0 0 {0} {0}\" xmlns", units_per_em); @@ -445,19 +445,19 @@ fn draw_text(canvas: &mut Pixmap, env: &Env, ts: Transform, shaped: &ShapedText) } } } - - continue; + } else { + // Otherwise, draw normal outline. + let mut builder = WrappedPathBuilder(tiny_skia::PathBuilder::new()); + if ttf.outline_glyph(glyph.id, &mut builder).is_some() { + let path = builder.0.finish().unwrap(); + let ts = ts.pre_scale(s, -s); + let mut paint = convert_typst_fill(shaped.color); + paint.anti_alias = true; + canvas.fill_path(&path, &paint, FillRule::default(), ts, None); + } } - // Otherwise, draw normal outline. - let mut builder = WrappedPathBuilder(tiny_skia::PathBuilder::new()); - if ttf.outline_glyph(glyph, &mut builder).is_some() { - let path = builder.0.finish().unwrap(); - let ts = ts.pre_scale(s, -s); - let mut paint = convert_typst_fill(shaped.color); - paint.anti_alias = true; - canvas.fill_path(&path, &paint, FillRule::default(), ts, None); - } + x += glyph.x_advance.to_pt() as f32; } }