From 34fa8df044f1491069c9ae69f1c1e73d635c8955 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Sun, 10 Apr 2022 23:23:50 +0200 Subject: [PATCH] Move language-related properties from `par` to `text` Closes #67 --- src/eval/raw.rs | 4 +- src/eval/styles.rs | 19 +- src/library/graphics/shape.rs | 1 + src/library/layout/align.rs | 4 +- src/library/layout/columns.rs | 4 +- src/library/layout/page.rs | 7 +- src/library/structure/heading.rs | 3 + src/library/structure/list.rs | 4 +- src/library/structure/table.rs | 1 + src/library/text/deco.rs | 2 + src/library/text/mod.rs | 86 +++++++++ src/library/text/par.rs | 319 +++++++++++++++++++------------ src/library/text/raw.rs | 6 +- tests/ref/text/hyphenate.png | Bin 6196 -> 20764 bytes tests/typ/layout/align.typ | 4 +- tests/typ/layout/columns.typ | 3 +- tests/typ/text/bidi.typ | 31 ++- tests/typ/text/hyphenate.typ | 34 ++-- tests/typ/text/indent.typ | 4 +- tests/typ/text/justify.typ | 2 +- tests/typ/text/knuth.typ | 6 +- tests/typ/text/microtype.typ | 3 +- 22 files changed, 368 insertions(+), 179 deletions(-) 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 24c52de5cc87e27875a350deceebd887b8b5927f..0560d5b70390592df2f341a51998dcbc623570c0 100644 GIT binary patch literal 20764 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 Pwuir5vM?qZ;vW1RPzTXu literal 6196 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
}KdzDeB`N#XEkw$O$q6Fn_huotPe zrSIL}`a%X?&stnkceCEM%kSX2HMb23Z+&=VAQLsQoYG`X*xSH)R ztA?@&4c?%>neq9A0PU@vvozvWIauR|pTl*~KD6wq-9M{Axxv(XY>PLVn1D1SpxBrE zNMgC&-oY|SwWI{-u2-B6nL;{&?T|Pp!PN{wPPmnGB5G`1z!Su`n<0H`>!Ex)UJGrb z`&{g)8*XilubDj+t2=QK!`D%YO+`zHkq^--7h_pb2ulFoRk6OHMO@cw6( ze5C?8{{ccil>YM^m$Z;+*8J2Jc_fROVvcp21h4kmu-sKkE3HU$b{0n^zZ_cE6Eyl29gQkvfs{Ux^g0tnu)AW z$Q~Hv;-UX?T4NgfU@^}xL9jy7Is7+YkEFQQ%9l4Jv0D;K_+2EGTAr4r5-|Zc3JJox ze`ar@n(hy+cOlY0KkrOR^?h;Mg@ot{5pZll2@FO7$E6bNoaUX(kir1f4rbZf2pCp( zPkg*68O0F16IW#x^&g&5Y>7z2!%^?&6)Ao(*sYxTokh vxXk5mP5ieh`j6OfbNt5uzF;*}{1y`F^NK^OpGENR?*MgWEu|_2%kcjJUQOie 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") "المطر هو الحياة" \ المطر هو الحياة