diff --git a/src/eval/raw.rs b/src/eval/raw.rs index b0f46fc94..a83c363f7 100644 --- a/src/eval/raw.rs +++ b/src/eval/raw.rs @@ -6,7 +6,7 @@ use super::{Fold, Resolve, Smart, StyleChain, Value}; use crate::geom::{ Align, Em, Get, Length, Numeric, Paint, Relative, Spec, SpecAxis, Stroke, }; -use crate::library::text::{ParNode, TextNode}; +use crate::library::text::TextNode; /// The unresolved alignment representation. #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -23,7 +23,7 @@ impl Resolve for RawAlign { type Output = Align; fn resolve(self, styles: StyleChain) -> Self::Output { - let dir = styles.get(ParNode::DIR); + let dir = styles.get(TextNode::DIR); match self { Self::Start => dir.start().into(), Self::End => dir.end().into(), diff --git a/src/eval/styles.rs b/src/eval/styles.rs index 71293f40d..f147d8cf6 100644 --- a/src/eval/styles.rs +++ b/src/eval/styles.rs @@ -66,6 +66,14 @@ impl StyleMap { self.0.push(Entry::Recipe(Recipe::new::(func, span))); } + /// Whether the map contains a style property for the given key. + pub fn contains<'a, K: Key<'a>>(&self, _: K) -> bool { + self.0 + .iter() + .filter_map(|entry| entry.property()) + .any(|property| property.key == TypeId::of::()) + } + /// Make `self` the first link of the `tail` chain. /// /// The resulting style chain contains styles from `self` as well as @@ -261,7 +269,7 @@ where /// /// This trait is not intended to be implemented manually, but rather through /// the `#[node]` proc-macro. -pub trait Key<'a>: 'static { +pub trait Key<'a>: Copy + 'static { /// The unfolded type which this property is stored as in a style map. For /// example, this is [`Toggle`](crate::geom::Length) for the /// [`STRONG`](TextNode::STRONG) property. @@ -680,6 +688,15 @@ impl StyleVec { self.items.len() } + /// Iterate over the contained maps. Note that zipping this with `items()` + /// does not yield the same result as calling `iter()` because this method + /// only returns maps once that are shared by consecutive items. This method + /// is designed for use cases where you want to check, for example, whether + /// any of the maps fulfills a specific property. + pub fn maps(&self) -> impl Iterator { + self.maps.iter().map(|(map, _)| map) + } + /// Iterate over the contained items. pub fn items(&self) -> std::slice::Iter<'_, T> { self.items.iter() diff --git a/src/library/graphics/shape.rs b/src/library/graphics/shape.rs index a159a3af3..236406c0a 100644 --- a/src/library/graphics/shape.rs +++ b/src/library/graphics/shape.rs @@ -26,6 +26,7 @@ impl ShapeNode { /// How to stroke the shape. #[property(resolve, fold)] pub const STROKE: Smart> = Smart::Auto; + /// How much to pad the shape's content. pub const PADDING: Relative = Relative::zero(); diff --git a/src/library/layout/align.rs b/src/library/layout/align.rs index 699a908c4..2a4d039ea 100644 --- a/src/library/layout/align.rs +++ b/src/library/layout/align.rs @@ -1,5 +1,5 @@ use crate::library::prelude::*; -use crate::library::text::ParNode; +use crate::library::text::{HorizontalAlign, ParNode}; /// Align a node along the layouting axes. #[derive(Debug, Hash)] @@ -33,7 +33,7 @@ impl Layout for AlignNode { // Align paragraphs inside the child. let mut passed = StyleMap::new(); if let Some(align) = self.aligns.x { - passed.set(ParNode::ALIGN, align); + passed.set(ParNode::ALIGN, HorizontalAlign(align)); } // Layout the child. diff --git a/src/library/layout/columns.rs b/src/library/layout/columns.rs index 3ef66b406..4963043ef 100644 --- a/src/library/layout/columns.rs +++ b/src/library/layout/columns.rs @@ -1,5 +1,5 @@ use crate::library::prelude::*; -use crate::library::text::ParNode; +use crate::library::text::TextNode; /// Separate a region into multiple equally sized columns. #[derive(Debug, Hash)] @@ -59,7 +59,7 @@ impl Layout for ColumnsNode { // Layout the children. let mut frames = self.child.layout(ctx, &pod, styles)?.into_iter(); - let dir = styles.get(ParNode::DIR); + let dir = styles.get(TextNode::DIR); let total_regions = (frames.len() as f32 / columns as f32).ceil() as usize; let mut finished = vec![]; diff --git a/src/library/layout/page.rs b/src/library/layout/page.rs index 7aa53b237..13583f098 100644 --- a/src/library/layout/page.rs +++ b/src/library/layout/page.rs @@ -17,6 +17,7 @@ impl PageNode { pub const HEIGHT: Smart = Smart::Custom(Paper::A4.height().into()); /// Whether the page is flipped into landscape orientation. pub const FLIPPED: bool = false; + /// The left margin. pub const LEFT: Smart> = Smart::Auto; /// The right margin. @@ -25,10 +26,12 @@ impl PageNode { pub const TOP: Smart> = Smart::Auto; /// The bottom margin. pub const BOTTOM: Smart> = Smart::Auto; - /// The page's background color. - pub const FILL: Option = None; + /// How many columns the page has. pub const COLUMNS: NonZeroUsize = NonZeroUsize::new(1).unwrap(); + /// The page's background color. + pub const FILL: Option = None; + /// The page's header. #[property(referenced)] pub const HEADER: Marginal = Marginal::None; diff --git a/src/library/structure/heading.rs b/src/library/structure/heading.rs index dcf87f908..07e5e6628 100644 --- a/src/library/structure/heading.rs +++ b/src/library/structure/heading.rs @@ -25,6 +25,7 @@ impl HeadingNode { let upscale = (1.6 - 0.1 * level as f64).max(0.75); TextSize(Em::new(upscale).into()) }); + /// Whether text in the heading is strengthend. #[property(referenced)] pub const STRONG: Leveled = Leveled::Value(true); @@ -34,12 +35,14 @@ impl HeadingNode { /// Whether the heading is underlined. #[property(referenced)] pub const UNDERLINE: Leveled = Leveled::Value(false); + /// The extra padding above the heading. #[property(referenced)] pub const ABOVE: Leveled = Leveled::Value(Length::zero().into()); /// The extra padding below the heading. #[property(referenced)] pub const BELOW: Leveled = Leveled::Value(Length::zero().into()); + /// Whether the heading is block-level. #[property(referenced)] pub const BLOCK: Leveled = Leveled::Value(true); diff --git a/src/library/structure/list.rs b/src/library/structure/list.rs index c3eae1af0..02a7cd383 100644 --- a/src/library/structure/list.rs +++ b/src/library/structure/list.rs @@ -33,6 +33,7 @@ impl ListNode { /// How the list is labelled. #[property(referenced)] pub const LABEL: Label = Label::Default; + /// The spacing between the list items of a non-wide list. #[property(resolve)] pub const SPACING: RawLength = RawLength::zero(); @@ -42,6 +43,7 @@ impl ListNode { /// The space between the label and the body of each item. #[property(resolve)] pub const BODY_INDENT: RawLength = Em::new(0.5).into(); + /// The extra padding above the list. #[property(resolve)] pub const ABOVE: RawLength = RawLength::zero(); @@ -137,7 +139,7 @@ pub const UNORDERED: ListKind = 0; /// Ordered list labelling style. pub const ORDERED: ListKind = 1; -/// Either content or a closure mapping to content. +/// How to label a list or enumeration. #[derive(Debug, Clone, PartialEq, Hash)] pub enum Label { /// The default labelling. diff --git a/src/library/structure/table.rs b/src/library/structure/table.rs index 40f25749b..96d3bd5b5 100644 --- a/src/library/structure/table.rs +++ b/src/library/structure/table.rs @@ -21,6 +21,7 @@ impl TableNode { /// How to stroke the cells. #[property(resolve, fold)] pub const STROKE: Option = Some(RawStroke::default()); + /// How much to pad the cells's content. pub const PADDING: Relative = Length::pt(5.0).into(); diff --git a/src/library/text/deco.rs b/src/library/text/deco.rs index b8a0b3cbf..7481b836e 100644 --- a/src/library/text/deco.rs +++ b/src/library/text/deco.rs @@ -24,6 +24,7 @@ impl DecoNode { /// tables if `auto`. #[property(shorthand, resolve, fold)] pub const STROKE: Smart = Smart::Auto; + /// Position of the line relative to the baseline, read from the font tables /// if `auto`. #[property(resolve)] @@ -31,6 +32,7 @@ impl DecoNode { /// Amount that the line will be longer or shorter than its associated text. #[property(resolve)] pub const EXTENT: RawLength = RawLength::zero(); + /// Whether the line skips sections in which it would collide /// with the glyphs. Does not apply to strikethrough. pub const EVADE: bool = true; diff --git a/src/library/text/mod.rs b/src/library/text/mod.rs index b5ccc6366..1d7506890 100644 --- a/src/library/text/mod.rs +++ b/src/library/text/mod.rs @@ -61,6 +61,18 @@ impl TextNode { /// The bottom end of the text bounding box. pub const BOTTOM_EDGE: TextEdge = TextEdge::Metric(VerticalFontMetric::Baseline); + /// An ISO 639-1 language code. + #[property(referenced)] + pub const LANG: Option = None; + /// The direction for text and inline objects. When `auto`, the direction is + /// automatically inferred from the language. + #[property(resolve)] + pub const DIR: Smart = Smart::Auto; + /// Whether to hyphenate text to improve line breaking. When `auto`, words + /// will will be hyphenated if and only if justification is enabled. + #[property(resolve)] + pub const HYPHENATE: Smart = Smart::Auto; + /// Whether to apply kerning ("kern"). pub const KERNING: bool = true; /// Whether small capital glyphs should be used. ("smcp") @@ -241,6 +253,80 @@ castable! { }), } +/// A natural language. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct Lang(EcoString); + +impl Lang { + /// The default direction for the language. + pub fn dir(&self) -> Dir { + match self.0.as_str() { + "ar" | "dv" | "fa" | "he" | "ks" | "pa" | "ps" | "sd" | "ug" | "ur" + | "yi" => Dir::RTL, + _ => Dir::LTR, + } + } + + /// Return the language code as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +castable! { + Lang, + Expected: "string", + Value::Str(string) => Self(string.to_lowercase()), +} + +/// The direction of text and inline objects in their line. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct HorizontalDir(pub Dir); + +castable! { + HorizontalDir, + Expected: "direction", + @dir: Dir => match dir.axis() { + SpecAxis::Horizontal => Self(*dir), + SpecAxis::Vertical => Err("must be horizontal")?, + }, +} + +impl Resolve for Smart { + type Output = Dir; + + fn resolve(self, styles: StyleChain) -> Self::Output { + match self { + Smart::Auto => match styles.get(TextNode::LANG) { + Some(lang) => lang.dir(), + None => Dir::LTR, + }, + Smart::Custom(dir) => dir.0, + } + } +} + +/// Whether to hyphenate text. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Hyphenate(pub bool); + +castable! { + Hyphenate, + Expected: "boolean", + Value::Bool(v) => Self(v), +} + +impl Resolve for Smart { + type Output = bool; + + fn resolve(self, styles: StyleChain) -> Self::Output { + match self { + Smart::Auto => styles.get(ParNode::JUSTIFY), + Smart::Custom(v) => v.0, + } + } +} + /// A stylistic set in a font face. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct StylisticSet(u8); diff --git a/src/library/text/par.rs b/src/library/text/par.rs index 57e2b45d8..765c3bf54 100644 --- a/src/library/text/par.rs +++ b/src/library/text/par.rs @@ -1,10 +1,9 @@ use std::sync::Arc; -use either::Either; use unicode_bidi::{BidiInfo, Level}; use xi_unicode::LineBreakIterator; -use super::{shape, ShapedText, TextNode}; +use super::{shape, Lang, ShapedText, TextNode}; use crate::font::FontStore; use crate::library::layout::Spacing; use crate::library::prelude::*; @@ -27,21 +26,6 @@ pub enum ParChild { #[node] impl ParNode { - /// An ISO 639-1 language code. - #[property(referenced)] - pub const LANG: Option = None; - /// The direction for text and inline objects. - pub const DIR: Dir = Dir::LTR; - /// How to align text and inline objects in their line. - #[property(resolve)] - pub const ALIGN: RawAlign = RawAlign::Start; - /// Whether to justify text in its line. - pub const JUSTIFY: bool = false; - /// How to determine line breaks. - pub const LINEBREAKS: Smart = Smart::Auto; - /// Whether to hyphenate text to improve line breaking. When `auto`, words - /// will will be hyphenated if and only if justification is enabled. - pub const HYPHENATE: Smart = Smart::Auto; /// The spacing between lines. #[property(resolve)] pub const LEADING: RawLength = Em::new(0.65).into(); @@ -52,6 +36,15 @@ impl ParNode { #[property(resolve)] pub const INDENT: RawLength = RawLength::zero(); + /// How to align text and inline objects in their line. + #[property(resolve)] + pub const ALIGN: HorizontalAlign = HorizontalAlign(RawAlign::Start); + /// Whether to justify text in its line. + pub const JUSTIFY: bool = false; + /// How to determine line breaks. + #[property(resolve)] + pub const LINEBREAKS: Smart = Smart::Auto; + fn construct(_: &mut Context, args: &mut Args) -> TypResult { // The paragraph constructor is special: It doesn't create a paragraph // since that happens automatically through markup. Instead, it just @@ -59,45 +52,6 @@ impl ParNode { // adjacent stuff and it styles the contained paragraphs. Ok(Content::Block(args.expect("body")?)) } - - fn set(args: &mut Args) -> TypResult { - let mut styles = StyleMap::new(); - - let lang = args.named::>("lang")?; - let mut dir = - lang.clone().flatten().map(|iso| match iso.to_lowercase().as_str() { - "ar" | "dv" | "fa" | "he" | "ks" | "pa" | "ps" | "sd" | "ug" | "ur" - | "yi" => Dir::RTL, - _ => Dir::LTR, - }); - - if let Some(Spanned { v, span }) = args.named::>("dir")? { - if v.axis() != SpecAxis::Horizontal { - bail!(span, "must be horizontal"); - } - dir = Some(v); - } - - let mut align = None; - if let Some(Spanned { v, span }) = args.named::>("align")? { - if v.axis() != SpecAxis::Horizontal { - bail!(span, "must be horizontal"); - } - align = Some(v); - }; - - styles.set_opt(Self::LANG, lang); - styles.set_opt(Self::DIR, dir); - styles.set_opt(Self::ALIGN, align); - styles.set_opt(Self::JUSTIFY, args.named("justify")?); - styles.set_opt(Self::LINEBREAKS, args.named("linebreaks")?); - styles.set_opt(Self::HYPHENATE, args.named("hyphenate")?); - styles.set_opt(Self::LEADING, args.named("leading")?); - styles.set_opt(Self::SPACING, args.named("spacing")?); - styles.set_opt(Self::INDENT, args.named("indent")?); - - Ok(styles) - } } impl ParNode { @@ -147,7 +101,7 @@ impl Layout for ParNode { let p = prepare(ctx, self, &text, regions, &styles)?; // Break the paragraph into lines. - let lines = linebreak(&p, &mut ctx.fonts, regions.first.x, styles); + let lines = linebreak(&p, &mut ctx.fonts, regions.first.x); // Stack the lines into one frame per region. Ok(stack(&lines, &ctx.fonts, regions, styles)) @@ -182,6 +136,27 @@ impl Merge for ParChild { } } +/// A horizontal alignment. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct HorizontalAlign(pub RawAlign); + +castable! { + HorizontalAlign, + Expected: "alignment", + @align: RawAlign => match align.axis() { + SpecAxis::Horizontal => Self(*align), + SpecAxis::Vertical => Err("must be horizontal")?, + }, +} + +impl Resolve for HorizontalAlign { + type Output = Align; + + fn resolve(self, styles: StyleChain) -> Self::Output { + self.0.resolve(styles) + } +} + /// How to determine line breaks in a paragraph. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub enum Linebreaks { @@ -201,6 +176,20 @@ castable! { }, } +impl Resolve for Smart { + type Output = Linebreaks; + + fn resolve(self, styles: StyleChain) -> Self::Output { + self.unwrap_or_else(|| { + if styles.get(ParNode::JUSTIFY) { + Linebreaks::Optimized + } else { + Linebreaks::Simple + } + }) + } +} + /// A paragraph break. pub struct ParbreakNode; @@ -233,17 +222,35 @@ type Range = std::ops::Range; struct Preparation<'a> { /// Bidirectional text embedding levels for the paragraph. bidi: BidiInfo<'a>, + /// The paragraph's children. + children: &'a StyleVec, /// Spacing, separated text runs and layouted nodes. items: Vec>, /// The ranges of the items in `bidi.text`. ranges: Vec, + /// The shared styles. + styles: StyleChain<'a>, } -impl Preparation<'_> { +impl<'a> Preparation<'a> { + /// Find the item whose range contains the `text_offset`. + fn find(&self, text_offset: usize) -> Option<&ParItem<'a>> { + self.find_idx(text_offset).map(|idx| &self.items[idx]) + } + /// Find the index of the item whose range contains the `text_offset`. - fn find(&self, text_offset: usize) -> Option { + fn find_idx(&self, text_offset: usize) -> Option { self.ranges.binary_search_by(|r| r.locate(text_offset)).ok() } + + /// Get a style property, but only if it is the same for all children of the + /// paragraph. + fn get_shared>(&self, key: K) -> Option { + self.children + .maps() + .all(|map| !map.contains(key)) + .then(|| self.styles.get(key)) + } } /// A prepared item in a paragraph layout. @@ -258,6 +265,16 @@ enum ParItem<'a> { Frame(Frame), } +impl<'a> ParItem<'a> { + /// If this a text item, return it. + fn text(&self) -> Option<&ShapedText<'a>> { + match self { + Self::Text(shaped) => Some(shaped), + _ => None, + } + } +} + /// A layouted line, consisting of a sequence of layouted paragraph items that /// are mostly borrowed from the preparation phase. This type enables you to /// measure the size of a line in a range before comitting to building the @@ -315,10 +332,8 @@ impl<'a> Line<'a> { // How many justifiable glyphs the line contains. fn justifiables(&self) -> usize { let mut count = 0; - for item in self.items() { - if let ParItem::Text(shaped) = item { - count += shaped.justifiables(); - } + for shaped in self.items().filter_map(ParItem::text) { + count += shaped.justifiables(); } count } @@ -326,10 +341,8 @@ impl<'a> Line<'a> { /// How much of the line is stretchable spaces. fn stretch(&self) -> Length { let mut stretch = Length::zero(); - for item in self.items() { - if let ParItem::Text(shaped) = item { - stretch += shaped.stretch(); - } + for shaped in self.items().filter_map(ParItem::text) { + stretch += shaped.stretch(); } stretch } @@ -344,7 +357,7 @@ fn prepare<'a>( regions: &Regions, styles: &'a StyleChain, ) -> TypResult> { - let bidi = BidiInfo::new(&text, match styles.get(ParNode::DIR) { + let bidi = BidiInfo::new(&text, match styles.get(TextNode::DIR) { Dir::LTR => Some(Level::ltr()), Dir::RTL => Some(Level::rtl()), _ => None, @@ -358,7 +371,7 @@ fn prepare<'a>( let styles = map.chain(styles); match child { ParChild::Text(_) => { - // TODO: Also split by language and script. + // TODO: Also split by language. let mut cursor = range.start; for (level, count) in bidi.levels[range].group() { let start = cursor; @@ -402,7 +415,13 @@ fn prepare<'a>( } } - Ok(Preparation { bidi, items, ranges }) + Ok(Preparation { + bidi, + children: &par.0, + items, + ranges, + styles: *styles, + }) } /// Find suitable linebreaks. @@ -410,22 +429,13 @@ fn linebreak<'a>( p: &'a Preparation<'a>, fonts: &mut FontStore, width: Length, - styles: StyleChain, ) -> Vec> { - let breaks = styles.get(ParNode::LINEBREAKS).unwrap_or_else(|| { - if styles.get(ParNode::JUSTIFY) { - Linebreaks::Optimized - } else { - Linebreaks::Simple - } - }); - - let breaker = match breaks { + let breaker = match p.styles.get(ParNode::LINEBREAKS) { Linebreaks::Simple => linebreak_simple, Linebreaks::Optimized => linebreak_optimized, }; - breaker(p, fonts, width, styles) + breaker(p, fonts, width) } /// Perform line breaking in simple first-fit style. This means that we build @@ -435,13 +445,12 @@ fn linebreak_simple<'a>( p: &'a Preparation<'a>, fonts: &mut FontStore, width: Length, - styles: StyleChain, ) -> Vec> { let mut lines = vec![]; let mut start = 0; let mut last = None; - for (end, mandatory, hyphen) in breakpoints(&p.bidi.text, styles) { + for (end, mandatory, hyphen) in breakpoints(p) { // Compute the line and its size. let mut attempt = line(p, fonts, start .. end, mandatory, hyphen); @@ -496,7 +505,6 @@ fn linebreak_optimized<'a>( p: &'a Preparation<'a>, fonts: &mut FontStore, width: Length, - styles: StyleChain, ) -> Vec> { /// The cost of a line or paragraph layout. type Cost = f64; @@ -515,8 +523,8 @@ fn linebreak_optimized<'a>( const MIN_COST: Cost = -MAX_COST; const MIN_RATIO: f64 = -0.15; - let em = styles.get(TextNode::SIZE); - let justify = styles.get(ParNode::JUSTIFY); + let em = p.styles.get(TextNode::SIZE); + let justify = p.styles.get(ParNode::JUSTIFY); // Dynamic programming table. let mut active = 0; @@ -526,7 +534,7 @@ fn linebreak_optimized<'a>( line: line(p, fonts, 0 .. 0, false, false), }]; - for (end, mandatory, hyphen) in breakpoints(&p.bidi.text, styles) { + for (end, mandatory, hyphen) in breakpoints(p) { let k = table.len(); let eof = end == p.bidi.text.len(); let mut best: Option = None; @@ -611,47 +619,104 @@ fn linebreak_optimized<'a>( /// Returns for each breakpoint the text index, whether the break is mandatory /// (after `\n`) and whether a hyphen is required (when breaking inside of a /// word). -fn breakpoints<'a>( - text: &'a str, - styles: StyleChain, -) -> impl Iterator + 'a { - let mut lang = None; - if styles.get(ParNode::HYPHENATE).unwrap_or(styles.get(ParNode::JUSTIFY)) { - lang = styles - .get(ParNode::LANG) - .as_ref() - .and_then(|iso| iso.as_bytes().try_into().ok()) - .and_then(hypher::Lang::from_iso); +fn breakpoints<'a>(p: &'a Preparation) -> impl Iterator + 'a { + Breakpoints { + p, + linebreaks: LineBreakIterator::new(p.bidi.text), + syllables: None, + offset: 0, + suffix: 0, + end: 0, + mandatory: false, + hyphenate: p.get_shared(TextNode::HYPHENATE), + lang: p.get_shared(TextNode::LANG).map(Option::as_ref), + } +} + +/// An iterator over the line break opportunities in a text. +struct Breakpoints<'a> { + /// The paragraph's items. + p: &'a Preparation<'a>, + /// The inner iterator over the unicode line break opportunities. + linebreaks: LineBreakIterator<'a>, + /// Iterator over syllables of the current word. + syllables: Option>, + /// The current text offset. + offset: usize, + /// The trimmed end of the current word. + suffix: usize, + /// The untrimmed end of the current word. + end: usize, + /// Whether the break after the current word is mandatory. + mandatory: bool, + /// Whether to hyphenate if it's the same for all children. + hyphenate: Option, + /// The text language if it's the same for all children. + lang: Option>, +} + +impl Iterator for Breakpoints<'_> { + type Item = (usize, bool, bool); + + fn next(&mut self) -> Option { + // If we're currently in a hyphenated "word", process the next syllable. + if let Some(syllable) = self.syllables.as_mut().and_then(Iterator::next) { + self.offset += syllable.len(); + if self.offset == self.suffix { + self.offset = self.end; + } + + // Filter out hyphenation opportunities where hyphenation was + // actually disabled. + let hyphen = self.offset < self.end; + if hyphen && !self.hyphenate_at(self.offset) { + return self.next(); + } + + return Some((self.offset, self.mandatory && !hyphen, hyphen)); + } + + // Get the next "word". + (self.end, self.mandatory) = self.linebreaks.next()?; + + // Hyphenate the next word. + if self.hyphenate != Some(false) { + if let Some(lang) = self.lang_at(self.offset) { + let word = &self.p.bidi.text[self.offset .. self.end]; + let trimmed = word.trim_end_matches(|c: char| !c.is_alphabetic()); + if !trimmed.is_empty() { + self.suffix = self.offset + trimmed.len(); + self.syllables = Some(hypher::hyphenate(trimmed, lang)); + return self.next(); + } + } + } + + self.offset = self.end; + Some((self.end, self.mandatory, false)) + } +} + +impl Breakpoints<'_> { + /// Whether hyphenation is enabled at the given offset. + fn hyphenate_at(&self, offset: usize) -> bool { + self.hyphenate + .or_else(|| { + let shaped = self.p.find(offset)?.text()?; + Some(shaped.styles.get(TextNode::HYPHENATE)) + }) + .unwrap_or(false) } - let breaks = LineBreakIterator::new(text); - let mut last = 0; + /// The text language at the given offset. + fn lang_at(&self, offset: usize) -> Option { + let lang = self.lang.unwrap_or_else(|| { + let shaped = self.p.find(offset)?.text()?; + shaped.styles.get(TextNode::LANG).as_ref() + })?; - if let Some(lang) = lang { - Either::Left(breaks.flat_map(move |(end, mandatory)| { - // We don't want to confuse the hyphenator with trailing - // punctuation, so we trim it. And if that makes the word empty, we - // need to return the single breakpoint manually because hypher - // would eat it. - let word = &text[last .. end]; - let trimmed = word.trim_end_matches(|c: char| !c.is_alphabetic()); - let suffix = last + trimmed.len(); - let mut start = std::mem::replace(&mut last, end); - if trimmed.is_empty() { - Either::Left([(end, mandatory, false)].into_iter()) - } else { - Either::Right(hypher::hyphenate(trimmed, lang).map(move |syllable| { - start += syllable.len(); - if start == suffix { - start = end; - } - let hyphen = start < end; - (start, mandatory && !hyphen, hyphen) - })) - } - })) - } else { - Either::Right(breaks.map(|(e, m)| (e, m, false))) + let bytes = lang.as_str().as_bytes().try_into().ok()?; + hypher::Lang::from_iso(bytes) } } @@ -664,11 +729,11 @@ fn line<'a>( hyphen: bool, ) -> Line<'a> { // Find the items which bound the text range. - let last_idx = p.find(range.end.saturating_sub(1)).unwrap(); + let last_idx = p.find_idx(range.end.saturating_sub(1)).unwrap(); let first_idx = if range.is_empty() { last_idx } else { - p.find(range.start).unwrap() + p.find_idx(range.start).unwrap() }; // Slice out the relevant items. diff --git a/src/library/text/raw.rs b/src/library/text/raw.rs index f09bc1d07..d96100af3 100644 --- a/src/library/text/raw.rs +++ b/src/library/text/raw.rs @@ -3,7 +3,7 @@ use syntect::easy::HighlightLines; use syntect::highlighting::{FontStyle, Highlighter, Style, Theme, ThemeSet}; use syntect::parsing::SyntaxSet; -use super::{FontFamily, TextNode, Toggle}; +use super::{FontFamily, Hyphenate, TextNode, Toggle}; use crate::library::prelude::*; use crate::source::SourceId; use crate::syntax::{self, RedNode}; @@ -29,6 +29,7 @@ impl RawNode { /// The raw text's font family. Just the normal text family if `none`. #[property(referenced)] pub const FAMILY: Smart = Smart::Custom(FontFamily::new("IBM Plex Mono")); + /// The language to syntax-highlight in. #[property(referenced)] pub const LANG: Option = None; @@ -97,6 +98,9 @@ impl Show for RawNode { }; let mut map = StyleMap::new(); + map.set(TextNode::OVERHANG, false); + map.set(TextNode::HYPHENATE, Smart::Custom(Hyphenate(false))); + if let Smart::Custom(family) = styles.get(Self::FAMILY) { map.set_family(family.clone(), styles); } diff --git a/tests/ref/text/hyphenate.png b/tests/ref/text/hyphenate.png index 24c52de5c..0560d5b70 100644 Binary files a/tests/ref/text/hyphenate.png and b/tests/ref/text/hyphenate.png differ diff --git a/tests/typ/layout/align.typ b/tests/typ/layout/align.typ index f0603b46c..753683de2 100644 --- a/tests/typ/layout/align.typ +++ b/tests/typ/layout/align.typ @@ -25,11 +25,11 @@ // Test start and end alignment. #rotate(-30deg, origin: end + horizon)[Hello] -#set par(lang: "de") +#set text(lang: "de") #align(start)[Start] #align(end)[Ende] -#set par(lang: "ar") +#set text(lang: "ar") #align(start)[يبدأ] #align(end)[نهاية] diff --git a/tests/typ/layout/columns.typ b/tests/typ/layout/columns.typ index 0a5858683..ce291fb2f 100644 --- a/tests/typ/layout/columns.typ +++ b/tests/typ/layout/columns.typ @@ -3,9 +3,8 @@ --- // Test normal operation and RTL directions. #set page(height: 3.25cm, width: 7.05cm, columns: 2) +#set text(lang: "ar", "Noto Sans Arabic", "IBM Plex Serif") #set columns(gutter: 30pt) -#set text("Noto Sans Arabic", "IBM Plex Serif") -#set par(lang: "ar") #rect(fill: conifer, height: 8pt, width: 6pt) وتحفيز العديد من التفاعلات الكيميائية. (DNA) من أهم الأحماض النووية التي تُشكِّل diff --git a/tests/typ/text/bidi.typ b/tests/typ/text/bidi.typ index 5c6663814..a180ad553 100644 --- a/tests/typ/text/bidi.typ +++ b/tests/typ/text/bidi.typ @@ -2,37 +2,35 @@ --- // Test reordering with different top-level paragraph directions. -#let content = [Text טֶקסט] +#let content = par[Text טֶקסט] #set text("IBM Plex Serif") -#par(lang: "he", content) -#par(lang: "de", content) +#text(lang: "he", content) +#text(lang: "de", content) --- // Test that consecutive, embedded LTR runs stay LTR. // Here, we have two runs: "A" and italic "B". -#let content = [أنت A#emph[B]مطرC] +#let content = par[أنت A#emph[B]مطرC] #set text("IBM Plex Serif", "Noto Sans Arabic") -#par(lang: "ar", content) -#par(lang: "de", content) +#text(lang: "ar", content) +#text(lang: "de", content) --- // Test that consecutive, embedded RTL runs stay RTL. // Here, we have three runs: "גֶ", bold "שֶׁ", and "ם". -#let content = [Aגֶ#strong[שֶׁ]םB] +#let content = par[Aגֶ#strong[שֶׁ]םB] #set text("IBM Plex Serif", "Noto Serif Hebrew") -#par(lang: "he", content) -#par(lang: "de", content) +#text(lang: "he", content) +#text(lang: "de", content) --- // Test embedding up to level 4 with isolates. -#set text("IBM Plex Serif") -#set par(dir: rtl) +#set text(dir: rtl, "IBM Plex Serif") א\u{2066}A\u{2067}Bב\u{2069}? --- // Test hard line break (leads to two paragraphs in unicode-bidi). -#set text("Noto Sans Arabic", "IBM Plex Serif") -#set par(lang: "ar") +#set text(lang: "ar", "Noto Sans Arabic", "IBM Plex Serif") Life المطر هو الحياة \ الحياة تمطر is rain. @@ -44,13 +42,12 @@ Lריווח #h(1cm) R --- // Test inline object. -#set text("IBM Plex Serif") -#set par(lang: "he") +#set text(lang: "he", "IBM Plex Serif") קרנפיםRh#image("../../res/rhino.png", height: 11pt)inoחיים --- // Test setting a vertical direction. // Ref: false -// Error: 15-18 must be horizontal -#set par(dir: ttb) +// Error: 16-19 must be horizontal +#set text(dir: ttb) diff --git a/tests/typ/text/hyphenate.typ b/tests/typ/text/hyphenate.typ index 67711ac33..02a332770 100644 --- a/tests/typ/text/hyphenate.typ +++ b/tests/typ/text/hyphenate.typ @@ -1,23 +1,33 @@ // Test hyphenation. --- -// Hyphenate english. -#set page(width: 70pt) -#set par(lang: "en", hyphenate: true) -Warm welcomes to Typst. +// Test hyphenating english and greek. +#set text(hyphenate: true) +#set page(width: auto) +#grid( + columns: (70pt, 60pt), + text(lang: "en")[Warm welcomes to Typst.], + text(lang: "el")[διαμερίσματα. \ λατρευτός], +) --- -// Hyphenate greek. -#set page(width: 60pt) -#set par(lang: "el", hyphenate: true) -διαμερίσματα. \ -λατρευτός +// Test disabling hyphenation for short passages. +#set text(lang: "en", hyphenate: true) + +Welcome to wonderful experiences. \ +Welcome to `wonderful` experiences. \ +Welcome to #text(hyphenate: false)[wonderful] experiences. \ +Welcome to wonde#text(hyphenate: false)[rf]ul experiences. \ + +// Test enabling hyphenation for short passages. +#set text(lang: "en", hyphenate: false) +Welcome to wonderful experiences. \ +Welcome to wo#text(hyphenate: true)[nd]erful experiences. \ --- // Hyphenate between shape runs. -#set par(lang: "en", hyphenate: true) #set page(width: 80pt) - +#set text(lang: "en", hyphenate: true) It's a #emph[Tree]beard. --- @@ -26,5 +36,5 @@ It's a #emph[Tree]beard. // do that. The test passes if there's just one hyphenation between // "net" and "works". #set page(width: 70pt) -#set par(lang: "en", hyphenate: true) +#set text(lang: "en", hyphenate: true) #h(6pt) networks, the rest. diff --git a/tests/typ/text/indent.typ b/tests/typ/text/indent.typ index 6da562f6e..1b48851bc 100644 --- a/tests/typ/text/indent.typ +++ b/tests/typ/text/indent.typ @@ -19,8 +19,8 @@ starts a paragraph without indent. Except if you have another paragraph in them. -#set text(8pt, "Noto Sans Arabic", "IBM Plex Sans") -#set par(lang: "ar", leading: 8pt) +#set text(8pt, lang: "ar", "Noto Sans Arabic", "IBM Plex Sans") +#set par(leading: 8pt) = Arabic دع النص يمطر عليك diff --git a/tests/typ/text/justify.typ b/tests/typ/text/justify.typ index d19249ead..24b6b99eb 100644 --- a/tests/typ/text/justify.typ +++ b/tests/typ/text/justify.typ @@ -1,8 +1,8 @@ --- #set page(width: 180pt) +#set text(lang: "en") #set par( - lang: "en", justify: true, indent: 14pt, spacing: 0pt, diff --git a/tests/typ/text/knuth.typ b/tests/typ/text/knuth.typ index d5af87f84..33249ef49 100644 --- a/tests/typ/text/knuth.typ +++ b/tests/typ/text/knuth.typ @@ -1,6 +1,6 @@ #set page(width: auto, height: auto) -#set par(lang: "en", leading: 4pt, justify: true) -#set text(family: "Latin Modern Roman") +#set par(leading: 4pt, justify: true) +#set text(lang: "en", family: "Latin Modern Roman") #let story = [ In olden times when wishing still helped one, there lived a king whose @@ -16,7 +16,7 @@ #let column(title, linebreaks, hyphenate) = { rect(width: 132pt, fill: rgb("eee"))[ #strong(title) - #par(linebreaks: linebreaks, hyphenate: hyphenate, story) + #par(linebreaks: linebreaks, text(hyphenate: hyphenate, story)) ] } diff --git a/tests/typ/text/microtype.typ b/tests/typ/text/microtype.typ index dbcb49988..7e85bd3fe 100644 --- a/tests/typ/text/microtype.typ +++ b/tests/typ/text/microtype.typ @@ -23,8 +23,7 @@ A#box["]B ‹Book quotes are even smarter.› \ --- -#set par(lang: "ar") -#set text("Noto Sans Arabic", "IBM Plex Sans") +#set text(lang: "ar", "Noto Sans Arabic", "IBM Plex Sans") "المطر هو الحياة" \ المطر هو الحياة