diff options
| author | Laurenz <laurmaedje@gmail.com> | 2022-11-29 13:37:25 +0100 |
|---|---|---|
| committer | Laurenz <laurmaedje@gmail.com> | 2022-11-29 14:18:13 +0100 |
| commit | 0efe669278a5e1c3f2985eba2f3360e91159c54a (patch) | |
| tree | 502712857c48f0decb5e698257c0a96d358a436e /library/src/text | |
| parent | 836692e73cff0356e409a9ba5b4887b86809d4ca (diff) | |
Reorganize library and tests
Diffstat (limited to 'library/src/text')
| -rw-r--r-- | library/src/text/deco.rs | 4 | ||||
| -rw-r--r-- | library/src/text/link.rs | 66 | ||||
| -rw-r--r-- | library/src/text/misc.rs | 146 | ||||
| -rw-r--r-- | library/src/text/mod.rs | 179 | ||||
| -rw-r--r-- | library/src/text/par.rs | 1215 | ||||
| -rw-r--r-- | library/src/text/quotes.rs | 20 | ||||
| -rw-r--r-- | library/src/text/shaping.rs | 7 |
7 files changed, 179 insertions, 1458 deletions
diff --git a/library/src/text/deco.rs b/library/src/text/deco.rs index 33c13e90..e81b219c 100644 --- a/library/src/text/deco.rs +++ b/library/src/text/deco.rs @@ -12,7 +12,7 @@ pub struct DecoNode<const L: DecoLine>(pub Content); pub type UnderlineNode = DecoNode<UNDERLINE>; /// Typeset stricken-through text. -pub type StrikethroughNode = DecoNode<STRIKETHROUGH>; +pub type StrikeNode = DecoNode<STRIKETHROUGH>; /// Typeset overlined text. pub type OverlineNode = DecoNode<OVERLINE>; @@ -65,7 +65,7 @@ impl<const L: DecoLine> Show for DecoNode<L> { /// /// For more details, see [`DecoNode`]. #[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub(super) struct Decoration { +pub struct Decoration { pub line: DecoLine, pub stroke: PartialStroke<Abs>, pub offset: Smart<Abs>, diff --git a/library/src/text/link.rs b/library/src/text/link.rs deleted file mode 100644 index 1b87def2..00000000 --- a/library/src/text/link.rs +++ /dev/null @@ -1,66 +0,0 @@ -use super::TextNode; -use crate::prelude::*; - -/// Link text and other elements to a destination. -#[derive(Debug, Hash)] -pub struct LinkNode { - /// The destination the link points to. - pub dest: Destination, - /// How the link is represented. - pub body: Content, -} - -impl LinkNode { - /// Create a link node from a URL with its bare text. - pub fn from_url(url: EcoString) -> Self { - let mut text = url.as_str(); - for prefix in ["mailto:", "tel:"] { - text = text.trim_start_matches(prefix); - } - let shorter = text.len() < url.len(); - let body = TextNode::packed(if shorter { text.into() } else { url.clone() }); - Self { dest: Destination::Url(url), body } - } -} - -#[node(Show, Finalize)] -impl LinkNode { - /// A destination the text should be linked to. - #[property(skip, referenced)] - pub(crate) const DEST: Option<Destination> = None; - - fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> { - let dest = args.expect::<Destination>("destination")?; - Ok(match dest { - Destination::Url(url) => match args.eat()? { - Some(body) => Self { dest: Destination::Url(url), body }, - None => Self::from_url(url), - }, - Destination::Internal(_) => Self { dest, body: args.expect("body")? }, - } - .pack()) - } - - fn field(&self, name: &str) -> Option<Value> { - match name { - "url" => Some(match &self.dest { - Destination::Url(url) => Value::Str(url.clone().into()), - Destination::Internal(loc) => Value::Dict(loc.encode()), - }), - "body" => Some(Value::Content(self.body.clone())), - _ => None, - } - } -} - -impl Show for LinkNode { - fn show(&self, _: Tracked<dyn World>, _: StyleChain) -> Content { - self.body.clone() - } -} - -impl Finalize for LinkNode { - fn finalize(&self, realized: Content) -> Content { - realized.styled(Self::DEST, Some(self.dest.clone())) - } -} diff --git a/library/src/text/misc.rs b/library/src/text/misc.rs new file mode 100644 index 00000000..15ef9a63 --- /dev/null +++ b/library/src/text/misc.rs @@ -0,0 +1,146 @@ +use super::TextNode; +use crate::prelude::*; + +/// A text space. +#[derive(Debug, Hash)] +pub struct SpaceNode; + +#[node(Unlabellable, Behave)] +impl SpaceNode { + fn construct(_: &Vm, _: &mut Args) -> SourceResult<Content> { + Ok(Self.pack()) + } +} + +impl Unlabellable for SpaceNode {} + +impl Behave for SpaceNode { + fn behaviour(&self) -> Behaviour { + Behaviour::Weak(2) + } +} + +/// A line break. +#[derive(Debug, Hash)] +pub struct LinebreakNode { + pub justify: bool, +} + +#[node(Behave)] +impl LinebreakNode { + fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> { + let justify = args.named("justify")?.unwrap_or(false); + Ok(Self { justify }.pack()) + } +} + +impl Behave for LinebreakNode { + fn behaviour(&self) -> Behaviour { + Behaviour::Destructive + } +} + +/// Strong content, rendered in boldface by default. +#[derive(Debug, Hash)] +pub struct StrongNode(pub Content); + +#[node(Show)] +impl StrongNode { + fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> { + Ok(Self(args.expect("body")?).pack()) + } + + fn field(&self, name: &str) -> Option<Value> { + match name { + "body" => Some(Value::Content(self.0.clone())), + _ => None, + } + } +} + +impl Show for StrongNode { + fn show(&self, _: Tracked<dyn World>, _: StyleChain) -> Content { + self.0.clone().styled(TextNode::BOLD, Toggle) + } +} + +/// Emphasized content, rendered with an italic font by default. +#[derive(Debug, Hash)] +pub struct EmphNode(pub Content); + +#[node(Show)] +impl EmphNode { + fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> { + Ok(Self(args.expect("body")?).pack()) + } + + fn field(&self, name: &str) -> Option<Value> { + match name { + "body" => Some(Value::Content(self.0.clone())), + _ => None, + } + } +} + +impl Show for EmphNode { + fn show(&self, _: Tracked<dyn World>, _: StyleChain) -> Content { + self.0.clone().styled(TextNode::ITALIC, Toggle) + } +} + +/// A toggle that turns on and off alternatingly if folded. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Toggle; + +impl Fold for Toggle { + type Output = bool; + + fn fold(self, outer: Self::Output) -> Self::Output { + !outer + } +} + +/// Convert a string or content to lowercase. +pub fn lower(_: &Vm, args: &mut Args) -> SourceResult<Value> { + case(Case::Lower, args) +} + +/// Convert a string or content to uppercase. +pub fn upper(_: &Vm, args: &mut Args) -> SourceResult<Value> { + case(Case::Upper, args) +} + +/// Change the case of text. +fn case(case: Case, args: &mut Args) -> SourceResult<Value> { + let Spanned { v, span } = args.expect("string or content")?; + Ok(match v { + Value::Str(v) => Value::Str(case.apply(&v).into()), + Value::Content(v) => Value::Content(v.styled(TextNode::CASE, Some(case))), + v => bail!(span, "expected string or content, found {}", v.type_name()), + }) +} + +/// A case transformation on text. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum Case { + /// Everything is lowercased. + Lower, + /// Everything is uppercased. + Upper, +} + +impl Case { + /// Apply the case to a string. + pub fn apply(self, text: &str) -> String { + match self { + Self::Lower => text.to_lowercase(), + Self::Upper => text.to_uppercase(), + } + } +} + +/// Display text in small capitals. +pub fn smallcaps(_: &Vm, args: &mut Args) -> SourceResult<Value> { + let body: Content = args.expect("content")?; + Ok(Value::Content(body.styled(TextNode::SMALLCAPS, true))) +} diff --git a/library/src/text/mod.rs b/library/src/text/mod.rs index 3fd30e9e..c9298fee 100644 --- a/library/src/text/mod.rs +++ b/library/src/text/mod.rs @@ -1,16 +1,15 @@ -//! Text handling and paragraph layout. +//! Text handling. mod deco; -mod link; -mod par; +mod misc; mod quotes; mod raw; mod shaping; mod shift; pub use self::deco::*; -pub use self::link::*; -pub use self::par::*; +pub use self::misc::*; +pub use self::quotes::*; pub use self::raw::*; pub use self::shaping::*; pub use self::shift::*; @@ -21,7 +20,7 @@ use rustybuzz::Tag; use typst::font::{FontMetrics, FontStretch, FontStyle, FontWeight, VerticalFontMetric}; use typst::util::EcoString; -use self::quotes::*; +use crate::layout::ParNode; use crate::prelude::*; /// A single run of text with the same style. @@ -114,19 +113,19 @@ impl TextNode { /// Whether the font weight should be increased by 300. #[property(skip, fold)] - const BOLD: Toggle = false; + pub const BOLD: Toggle = false; /// Whether the font style should be inverted. #[property(skip, fold)] - const ITALIC: Toggle = false; + pub const ITALIC: Toggle = false; /// A case transformation that should be applied to the text. #[property(skip)] - const CASE: Option<Case> = None; + pub const CASE: Option<Case> = None; /// Whether small capital glyphs should be used. ("smcp") #[property(skip)] - const SMALLCAPS: bool = false; + pub const SMALLCAPS: bool = false; /// Decorative lines. #[property(skip, fold)] - const DECO: Decoration = vec![]; + pub const DECO: Decoration = vec![]; fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> { // The text constructor is special: It doesn't create a text node. @@ -409,161 +408,3 @@ impl Fold for FontFeatures { self } } - -/// A text space. -#[derive(Debug, Hash)] -pub struct SpaceNode; - -#[node(Unlabellable, Behave)] -impl SpaceNode { - fn construct(_: &Vm, _: &mut Args) -> SourceResult<Content> { - Ok(Self.pack()) - } -} - -impl Unlabellable for SpaceNode {} - -impl Behave for SpaceNode { - fn behaviour(&self) -> Behaviour { - Behaviour::Weak(2) - } -} - -/// A line break. -#[derive(Debug, Hash)] -pub struct LinebreakNode { - pub justify: bool, -} - -#[node(Behave)] -impl LinebreakNode { - fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> { - let justify = args.named("justify")?.unwrap_or(false); - Ok(Self { justify }.pack()) - } -} - -impl Behave for LinebreakNode { - fn behaviour(&self) -> Behaviour { - Behaviour::Destructive - } -} - -/// A smart quote. -#[derive(Debug, Hash)] -pub struct SmartQuoteNode { - pub double: bool, -} - -#[node] -impl SmartQuoteNode { - fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> { - let double = args.named("double")?.unwrap_or(true); - Ok(Self { double }.pack()) - } -} - -/// Convert a string or content to lowercase. -pub fn lower(_: &Vm, args: &mut Args) -> SourceResult<Value> { - case(Case::Lower, args) -} - -/// Convert a string or content to uppercase. -pub fn upper(_: &Vm, args: &mut Args) -> SourceResult<Value> { - case(Case::Upper, args) -} - -/// Change the case of text. -fn case(case: Case, args: &mut Args) -> SourceResult<Value> { - let Spanned { v, span } = args.expect("string or content")?; - Ok(match v { - Value::Str(v) => Value::Str(case.apply(&v).into()), - Value::Content(v) => Value::Content(v.styled(TextNode::CASE, Some(case))), - v => bail!(span, "expected string or content, found {}", v.type_name()), - }) -} - -/// A case transformation on text. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -pub enum Case { - /// Everything is uppercased. - Upper, - /// Everything is lowercased. - Lower, -} - -impl Case { - /// Apply the case to a string. - pub fn apply(self, text: &str) -> String { - match self { - Self::Upper => text.to_uppercase(), - Self::Lower => text.to_lowercase(), - } - } -} - -/// Display text in small capitals. -pub fn smallcaps(_: &Vm, args: &mut Args) -> SourceResult<Value> { - let body: Content = args.expect("content")?; - Ok(Value::Content(body.styled(TextNode::SMALLCAPS, true))) -} - -/// Strong content, rendered in boldface by default. -#[derive(Debug, Hash)] -pub struct StrongNode(pub Content); - -#[node(Show)] -impl StrongNode { - fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> { - Ok(Self(args.expect("body")?).pack()) - } - - fn field(&self, name: &str) -> Option<Value> { - match name { - "body" => Some(Value::Content(self.0.clone())), - _ => None, - } - } -} - -impl Show for StrongNode { - fn show(&self, _: Tracked<dyn World>, _: StyleChain) -> Content { - self.0.clone().styled(TextNode::BOLD, Toggle) - } -} - -/// Emphasized content, rendered with an italic font by default. -#[derive(Debug, Hash)] -pub struct EmphNode(pub Content); - -#[node(Show)] -impl EmphNode { - fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> { - Ok(Self(args.expect("body")?).pack()) - } - - fn field(&self, name: &str) -> Option<Value> { - match name { - "body" => Some(Value::Content(self.0.clone())), - _ => None, - } - } -} - -impl Show for EmphNode { - fn show(&self, _: Tracked<dyn World>, _: StyleChain) -> Content { - self.0.clone().styled(TextNode::ITALIC, Toggle) - } -} - -/// A toggle that turns on and off alternatingly if folded. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -pub struct Toggle; - -impl Fold for Toggle { - type Output = bool; - - fn fold(self, outer: Self::Output) -> Self::Output { - !outer - } -} diff --git a/library/src/text/par.rs b/library/src/text/par.rs deleted file mode 100644 index 3c722d84..00000000 --- a/library/src/text/par.rs +++ /dev/null @@ -1,1215 +0,0 @@ -use unicode_bidi::{BidiInfo, Level as BidiLevel}; -use unicode_script::{Script, UnicodeScript}; -use xi_unicode::LineBreakIterator; - -use typst::model::Key; - -use super::{ - shape, Lang, LinebreakNode, Quoter, Quotes, ShapedText, SmartQuoteNode, SpaceNode, - TextNode, -}; -use crate::layout::{HNode, Spacing}; -use crate::prelude::*; - -/// Arrange text, spacing and inline-level nodes into a paragraph. -#[derive(Hash)] -pub struct ParNode(pub StyleVec<Content>); - -#[node] -impl ParNode { - /// The indent the first line of a consecutive paragraph should have. - #[property(resolve)] - pub const INDENT: Length = Length::zero(); - /// The spacing between lines. - #[property(resolve)] - pub const LEADING: Length = Em::new(0.65).into(); - /// How to align text and inline objects in their line. - #[property(resolve)] - pub const ALIGN: HorizontalAlign = HorizontalAlign(GenAlign::Start); - /// Whether to justify text in its line. - pub const JUSTIFY: bool = false; - /// How to determine line breaks. - pub const LINEBREAKS: Smart<Linebreaks> = Smart::Auto; - - fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> { - // The paragraph constructor is special: It doesn't create a paragraph - // node. Instead, it just ensures that the passed content lives in a - // separate paragraph and styles it. - Ok(Content::sequence(vec![ - ParbreakNode.pack(), - args.expect("body")?, - ParbreakNode.pack(), - ])) - } -} - -impl ParNode { - /// Layout the paragraph into a collection of lines. - #[comemo::memoize] - pub fn layout( - &self, - world: Tracked<dyn World>, - styles: StyleChain, - regions: &Regions, - consecutive: bool, - ) -> SourceResult<Fragment> { - // Collect all text into one string for BiDi analysis. - let (text, segments) = collect(self, &styles, consecutive); - - // Perform BiDi analysis and then prepare paragraph layout by building a - // representation on which we can do line breaking without layouting - // each and every line from scratch. - let p = prepare(world, self, &text, segments, regions, styles)?; - - // Break the paragraph into lines. - let lines = linebreak(&p, regions.first.x); - - // Stack the lines into one frame per region. - finalize(&p, &lines, regions) - } -} - -impl Debug for ParNode { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - f.write_str("Par ")?; - self.0.fmt(f) - } -} - -/// A horizontal alignment. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -pub struct HorizontalAlign(pub GenAlign); - -castable! { - HorizontalAlign, - Expected: "alignment", - @align: GenAlign => match align.axis() { - Axis::X => Self(*align), - Axis::Y => 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 { - /// Determine the linebreaks in a simple first-fit style. - Simple, - /// Optimize the linebreaks for the whole paragraph. - Optimized, -} - -castable! { - Linebreaks, - Expected: "string", - Value::Str(string) => match string.as_str() { - "simple" => Self::Simple, - "optimized" => Self::Optimized, - _ => Err(r#"expected "simple" or "optimized""#)?, - }, -} - -/// A paragraph break. -#[derive(Debug, Hash)] -pub struct ParbreakNode; - -#[node(Unlabellable)] -impl ParbreakNode { - fn construct(_: &Vm, _: &mut Args) -> SourceResult<Content> { - Ok(Self.pack()) - } -} - -impl Unlabellable for ParbreakNode {} - -/// Repeats content to fill a line. -#[derive(Debug, Hash)] -pub struct RepeatNode(pub Content); - -#[node(Layout, Inline)] -impl RepeatNode { - fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> { - Ok(Self(args.expect("body")?).pack()) - } -} - -impl Layout for RepeatNode { - fn layout( - &self, - world: Tracked<dyn World>, - styles: StyleChain, - regions: &Regions, - ) -> SourceResult<Fragment> { - self.0.layout(world, styles, regions) - } -} - -impl Inline for RepeatNode {} - -/// Range of a substring of text. -type Range = std::ops::Range<usize>; - -// The characters by which spacing, inline content and pins are replaced in the -// paragraph's full text. -const SPACING_REPLACE: char = ' '; // Space -const NODE_REPLACE: char = '\u{FFFC}'; // Object Replacement Character - -/// A paragraph representation in which children are already layouted and text -/// is already preshaped. -/// -/// In many cases, we can directly reuse these results when constructing a line. -/// Only when a line break falls onto a text index that is not safe-to-break per -/// rustybuzz, we have to reshape that portion. -struct Preparation<'a> { - /// The compilation environment. - world: Tracked<'a, dyn World>, - /// Bidirectional text embedding levels for the paragraph. - bidi: BidiInfo<'a>, - /// Text runs, spacing and layouted nodes. - items: Vec<Item<'a>>, - /// The styles shared by all children. - styles: StyleChain<'a>, - /// Whether to hyphenate if it's the same for all children. - hyphenate: Option<bool>, - /// The text language if it's the same for all children. - lang: Option<Lang>, - /// The paragraph's resolved alignment. - align: Align, - /// Whether to justify the paragraph. - justify: bool, -} - -impl<'a> Preparation<'a> { - /// Find the item that contains the given `text_offset`. - fn find(&self, text_offset: usize) -> Option<&Item<'a>> { - let mut cursor = 0; - for item in &self.items { - let end = cursor + item.len(); - if (cursor..end).contains(&text_offset) { - return Some(item); - } - cursor = end; - } - None - } - - /// Return the items that intersect the given `text_range`. - /// - /// Returns the expanded range around the items and the items. - fn slice(&self, text_range: Range) -> (Range, &[Item<'a>]) { - let mut cursor = 0; - let mut start = 0; - let mut end = 0; - let mut expanded = text_range.clone(); - - for (i, item) in self.items.iter().enumerate() { - if cursor <= text_range.start { - start = i; - expanded.start = cursor; - } - - let len = item.len(); - if cursor < text_range.end || cursor + len <= text_range.end { - end = i + 1; - expanded.end = cursor + len; - } else { - break; - } - - cursor += len; - } - - (expanded, &self.items[start..end]) - } -} - -/// A segment of one or multiple collapsed children. -#[derive(Debug, Copy, Clone)] -enum Segment<'a> { - /// One or multiple collapsed text or text-equivalent children. Stores how - /// long the segment is (in bytes of the full text string). - Text(usize), - /// Horizontal spacing between other segments. - Spacing(Spacing), - /// Arbitrary inline-level content. - Inline(&'a Content), -} - -impl Segment<'_> { - /// The text length of the item. - fn len(&self) -> usize { - match *self { - Self::Text(len) => len, - Self::Spacing(_) => SPACING_REPLACE.len_utf8(), - Self::Inline(_) => NODE_REPLACE.len_utf8(), - } - } -} - -/// A prepared item in a paragraph layout. -#[derive(Debug)] -enum Item<'a> { - /// A shaped text run with consistent style and direction. - Text(ShapedText<'a>), - /// Absolute spacing between other items. - Absolute(Abs), - /// Fractional spacing between other items. - Fractional(Fr), - /// Layouted inline-level content. - Frame(Frame), - /// A repeating node that fills the remaining space in a line. - Repeat(&'a RepeatNode, StyleChain<'a>), -} - -impl<'a> Item<'a> { - /// If this a text item, return it. - fn text(&self) -> Option<&ShapedText<'a>> { - match self { - Self::Text(shaped) => Some(shaped), - _ => None, - } - } - - /// The text length of the item. - fn len(&self) -> usize { - match self { - Self::Text(shaped) => shaped.text.len(), - Self::Absolute(_) | Self::Fractional(_) => SPACING_REPLACE.len_utf8(), - Self::Frame(_) | Self::Repeat(_, _) => NODE_REPLACE.len_utf8(), - } - } - - /// The natural layouted width of the item. - fn width(&self) -> Abs { - match self { - Self::Text(shaped) => shaped.width, - Self::Absolute(v) => *v, - Self::Frame(frame) => frame.width(), - Self::Fractional(_) | Self::Repeat(_, _) => Abs::zero(), - } - } -} - -/// 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 -/// line's frame. -/// -/// At most two paragraph items must be created individually for this line: The -/// first and last one since they may be broken apart by the start or end of the -/// line, respectively. But even those can partially reuse previous results when -/// the break index is safe-to-break per rustybuzz. -struct Line<'a> { - /// Bidi information about the paragraph. - bidi: &'a BidiInfo<'a>, - /// The trimmed range the line spans in the paragraph. - trimmed: Range, - /// The untrimmed end where the line ends. - end: usize, - /// A reshaped text item if the line sliced up a text item at the start. - first: Option<Item<'a>>, - /// Inner items which don't need to be reprocessed. - inner: &'a [Item<'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<Item<'a>>, - /// The width of the line. - width: Abs, - /// Whether the line should be justified. - justify: bool, - /// Whether the line ends with a hyphen or dash, either naturally or through - /// hyphenation. - dash: bool, -} - -impl<'a> Line<'a> { - /// Iterate over the line's items. - fn items(&self) -> impl Iterator<Item = &Item<'a>> { - self.first.iter().chain(self.inner).chain(&self.last) - } - - /// Return items that intersect the given `text_range`. - fn slice(&self, text_range: Range) -> impl Iterator<Item = &Item<'a>> { - let mut cursor = self.trimmed.start; - let mut start = 0; - let mut end = 0; - - for (i, item) in self.items().enumerate() { - if cursor <= text_range.start { - start = i; - } - - let len = item.len(); - if cursor < text_range.end || cursor + len <= text_range.end { - end = i + 1; - } else { - break; - } - - cursor += len; - } - - self.items().skip(start).take(end - start) - } - - /// How many justifiable glyphs the line contains. - fn justifiables(&self) -> usize { - let mut count = 0; - for shaped in self.items().filter_map(Item::text) { - count += shaped.justifiables(); - } - count - } - - /// How much of the line is stretchable spaces. - fn stretch(&self) -> Abs { - let mut stretch = Abs::zero(); - for shaped in self.items().filter_map(Item::text) { - stretch += shaped.stretch(); - } - stretch - } - - /// The sum of fractions in the line. - fn fr(&self) -> Fr { - self.items() - .filter_map(|item| match item { - Item::Fractional(fr) => Some(*fr), - Item::Repeat(_, _) => Some(Fr::one()), - _ => None, - }) - .sum() - } -} - -/// Collect all text of the paragraph into one string. This also performs -/// string-level preprocessing like case transformations. -fn collect<'a>( - par: &'a ParNode, - styles: &'a StyleChain<'a>, - consecutive: bool, -) -> (String, Vec<(Segment<'a>, StyleChain<'a>)>) { - let mut full = String::new(); - let mut quoter = Quoter::new(); - let mut segments = vec![]; - let mut iter = par.0.iter().peekable(); - - if consecutive { - let indent = styles.get(ParNode::INDENT); - if !indent.is_zero() - && par - .0 - .items() - .find_map(|child| { - if child.is::<TextNode>() || child.is::<SmartQuoteNode>() { - Some(true) - } else if child.has::<dyn Inline>() { - Some(false) - } else { - None - } - }) - .unwrap_or_default() - { - full.push(SPACING_REPLACE); - segments.push((Segment::Spacing(indent.into()), *styles)); - } - } - - while let Some((child, map)) = iter.next() { - let styles = styles.chain(map); - let segment = if child.is::<SpaceNode>() { - full.push(' '); - Segment::Text(1) - } else if let Some(node) = child.to::<TextNode>() { - let prev = full.len(); - if let Some(case) = styles.get(TextNode::CASE) { - full.push_str(&case.apply(&node.0)); - } else { - full.push_str(&node.0); - } - Segment::Text(full.len() - prev) - } else if let Some(node) = child.to::<LinebreakNode>() { - let c = if node.justify { '\u{2028}' } else { '\n' }; - full.push(c); - Segment::Text(c.len_utf8()) - } else if let Some(node) = child.to::<SmartQuoteNode>() { - let prev = full.len(); - if styles.get(TextNode::SMART_QUOTES) { - let lang = styles.get(TextNode::LANG); - let region = styles.get(TextNode::REGION); - let quotes = Quotes::from_lang(lang, region); - let peeked = iter.peek().and_then(|(child, _)| { - if let Some(node) = child.to::<TextNode>() { - node.0.chars().next() - } else if child.is::<SmartQuoteNode>() { - Some('"') - } else if child.is::<SpaceNode>() || child.is::<HNode>() { - Some(SPACING_REPLACE) - } else { - Some(NODE_REPLACE) - } - }); - - full.push_str(quoter.quote("es, node.double, peeked)); - } else { - full.push(if node.double { '"' } else { '\'' }); - } - Segment::Text(full.len() - prev) - } else if let Some(&node) = child.to::<HNode>() { - full.push(SPACING_REPLACE); - Segment::Spacing(node.amount) - } else if child.has::<dyn Inline>() { - full.push(NODE_REPLACE); - Segment::Inline(child) - } else { - panic!("unexpected par child: {child:?}"); - }; - - if let Some(last) = full.chars().last() { - quoter.last(last); - } - - if let (Some((Segment::Text(last_len), last_styles)), Segment::Text(len)) = - (segments.last_mut(), segment) - { - if *last_styles == styles { - *last_len += len; - continue; - } - } - - segments.push((segment, styles)); - } - - (full, segments) -} - -/// Prepare paragraph layout by shaping the whole paragraph and layouting all -/// contained inline-level content. -fn prepare<'a>( - world: Tracked<'a, dyn World>, - par: &'a ParNode, - text: &'a str, - segments: Vec<(Segment<'a>, StyleChain<'a>)>, - regions: &Regions, - styles: StyleChain<'a>, -) -> SourceResult<Preparation<'a>> { - let bidi = BidiInfo::new( - text, - match styles.get(TextNode::DIR) { - Dir::LTR => Some(BidiLevel::ltr()), - Dir::RTL => Some(BidiLevel::rtl()), - _ => None, - }, - ); - - let mut cursor = 0; - let mut items = vec![]; - - // Shape / layout the children and collect them into items. - for (segment, styles) in segments { - let end = cursor + segment.len(); - match segment { - Segment::Text(_) => { - shape_range(&mut items, world, &bidi, cursor..end, styles); - } - Segment::Spacing(spacing) => match spacing { - Spacing::Relative(v) => { - let resolved = v.resolve(styles).relative_to(regions.base.x); - items.push(Item::Absolute(resolved)); - } - Spacing::Fractional(v) => { - items.push(Item::Fractional(v)); - } - }, - Segment::Inline(inline) => { - if let Some(repeat) = inline.to::<RepeatNode>() { - items.push(Item::Repeat(repeat, styles)); - } else { - let size = Size::new(regions.first.x, regions.base.y); - let pod = Regions::one(size, regions.base, Axes::splat(false)); - let mut frame = inline.layout(world, styles, &pod)?.into_frame(); - frame.translate(Point::with_y(styles.get(TextNode::BASELINE))); - items.push(Item::Frame(frame)); - } - } - } - - cursor = end; - } - - Ok(Preparation { - world, - bidi, - items, - styles, - hyphenate: shared_get(styles, &par.0, TextNode::HYPHENATE), - lang: shared_get(styles, &par.0, TextNode::LANG), - align: styles.get(ParNode::ALIGN), - justify: styles.get(ParNode::JUSTIFY), - }) -} - -/// Group a range of text by BiDi level and script, shape the runs and generate -/// items for them. -fn shape_range<'a>( - items: &mut Vec<Item<'a>>, - world: Tracked<dyn World>, - bidi: &BidiInfo<'a>, - range: Range, - styles: StyleChain<'a>, -) { - let mut process = |text, level: BidiLevel| { - let dir = if level.is_ltr() { Dir::LTR } else { Dir::RTL }; - let shaped = shape(world, text, styles, dir); - items.push(Item::Text(shaped)); - }; - - let mut prev_level = BidiLevel::ltr(); - let mut prev_script = Script::Unknown; - let mut cursor = range.start; - - // Group by embedding level and script. - for i in cursor..range.end { - if !bidi.text.is_char_boundary(i) { - continue; - } - - let level = bidi.levels[i]; - let script = - bidi.text[i..].chars().next().map_or(Script::Unknown, |c| c.script()); - - if level != prev_level || !is_compatible(script, prev_script) { - if cursor < i { - process(&bidi.text[cursor..i], prev_level); - } - cursor = i; - prev_level = level; - prev_script = script; - } else if is_generic_script(prev_script) { - prev_script = script; - } - } - - process(&bidi.text[cursor..range.end], prev_level); -} - -/// Whether this is not a specific script. -fn is_generic_script(script: Script) -> bool { - matches!(script, Script::Unknown | Script::Common | Script::Inherited) -} - -/// Whether these script can be part of the same shape run. -fn is_compatible(a: Script, b: Script) -> bool { - is_generic_script(a) || is_generic_script(b) || a == b -} - -/// Get a style property, but only if it is the same for all children of the -/// paragraph. -fn shared_get<'a, K: Key>( - styles: StyleChain<'a>, - children: &StyleVec<Content>, - key: K, -) -> Option<K::Output<'a>> { - children - .styles() - .all(|map| !map.contains(key)) - .then(|| styles.get(key)) -} - -/// Find suitable linebreaks. -fn linebreak<'a>(p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> { - let linebreaks = p.styles.get(ParNode::LINEBREAKS).unwrap_or_else(|| { - if p.styles.get(ParNode::JUSTIFY) { - Linebreaks::Optimized - } else { - Linebreaks::Simple - } - }); - - match linebreaks { - Linebreaks::Simple => linebreak_simple(p, width), - Linebreaks::Optimized => linebreak_optimized(p, width), - } -} - -/// Perform line breaking in simple first-fit style. This means that we build -/// lines greedily, always taking the longest possible line. This may lead to -/// very unbalanced line, but is fast and simple. -fn linebreak_simple<'a>(p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> { - let mut lines = vec![]; - let mut start = 0; - let mut last = None; - - for (end, mandatory, hyphen) in breakpoints(p) { - // Compute the line and its size. - let mut attempt = line(p, start..end, mandatory, hyphen); - - // If the line doesn't fit anymore, we push the last fitting attempt - // into the stack and rebuild the line from the attempt's end. The - // resulting line cannot be broken up further. - if !width.fits(attempt.width) { - if let Some((last_attempt, last_end)) = last.take() { - lines.push(last_attempt); - start = last_end; - attempt = line(p, start..end, mandatory, hyphen); - } - } - - // Finish the current line if there is a mandatory line break (i.e. - // due to "\n") or if the line doesn't fit horizontally already - // since then no shorter line will be possible. - if mandatory || !width.fits(attempt.width) { - lines.push(attempt); - start = end; - last = None; - } else { - last = Some((attempt, end)); - } - } - - if let Some((line, _)) = last { - lines.push(line); - } - - lines -} - -/// Perform line breaking in optimized Knuth-Plass style. Here, we use more -/// context to determine the line breaks than in the simple first-fit style. For -/// example, we might choose to cut a line short even though there is still a -/// bit of space to improve the fit of one of the following lines. The -/// Knuth-Plass algorithm is based on the idea of "cost". A line which has a -/// very tight or very loose fit has a higher cost than one that is just right. -/// Ending a line with a hyphen incurs extra cost and endings two successive -/// lines with hyphens even more. -/// -/// To find the layout with the minimal total cost the algorithm uses dynamic -/// programming: For each possible breakpoint it determines the optimal -/// paragraph layout _up to that point_. It walks over all possible start points -/// for a line ending at that point and finds the one for which the cost of the -/// line plus the cost of the optimal paragraph up to the start point (already -/// computed and stored in dynamic programming table) is minimal. The final -/// result is simply the layout determined for the last breakpoint at the end of -/// text. -fn linebreak_optimized<'a>(p: &'a Preparation<'a>, width: Abs) -> Vec<Line<'a>> { - /// The cost of a line or paragraph layout. - type Cost = f64; - - /// An entry in the dynamic programming table. - struct Entry<'a> { - pred: usize, - total: Cost, - line: Line<'a>, - } - - // Cost parameters. - const HYPH_COST: Cost = 0.5; - const CONSECUTIVE_DASH_COST: Cost = 30.0; - const MAX_COST: Cost = 1_000_000.0; - const MIN_COST: Cost = -MAX_COST; - const MIN_RATIO: f64 = -0.15; - - // Dynamic programming table. - let mut active = 0; - let mut table = vec![Entry { - pred: 0, - total: 0.0, - line: line(p, 0..0, false, false), - }]; - - let em = p.styles.get(TextNode::SIZE); - - for (end, mandatory, hyphen) in breakpoints(p) { - let k = table.len(); - let eof = end == p.bidi.text.len(); - let mut best: Option<Entry> = None; - - // Find the optimal predecessor. - for (i, pred) in table.iter_mut().enumerate().skip(active) { - // Layout the line. - let start = pred.line.end; - let attempt = line(p, start..end, mandatory, hyphen); - - // Determine how much the line's spaces would need to be stretched - // to make it the desired width. - let delta = width - attempt.width; - let mut ratio = delta / attempt.stretch(); - if ratio.is_infinite() { - ratio = delta / (em / 2.0); - } - - // At some point, it doesn't matter any more. - ratio = ratio.min(10.0); - - // Determine the cost of the line. - let min_ratio = if attempt.justify { MIN_RATIO } else { 0.0 }; - let mut cost = if ratio < min_ratio { - // The line is overfull. This is the case if - // - justification is on, but we'd need to shrink too much - // - justification is off and the line just doesn't fit - // Since any longer line will also be overfull, we can deactive - // this breakpoint. - active = i + 1; - MAX_COST - } else if mandatory || eof { - // This is a mandatory break and the line is not overfull, so it - // has minimum cost. All breakpoints before this one become - // inactive since no line can span above the mandatory break. - active = k; - MIN_COST + if attempt.justify { ratio.powi(3).abs() } else { 0.0 } - } else { - // Normal line with cost of |ratio^3|. - ratio.powi(3).abs() - }; - - // Penalize hyphens. - if hyphen { - cost += HYPH_COST; - } - - // Penalize two consecutive dashes (not necessarily hyphens) extra. - if attempt.dash && pred.line.dash { - cost += CONSECUTIVE_DASH_COST; - } - - // The total cost of this line and its chain of predecessors. - let total = pred.total + cost; - - // If this attempt is better than what we had before, take it! - if best.as_ref().map_or(true, |best| best.total >= total) { - best = Some(Entry { pred: i, total, line: attempt }); - } - } - - table.push(best.unwrap()); - } - - // Retrace the best path. - let mut lines = vec![]; - let mut idx = table.len() - 1; - while idx != 0 { - table.truncate(idx + 1); - let entry = table.pop().unwrap(); - lines.push(entry.line); - idx = entry.pred; - } - - lines.reverse(); - lines -} - -/// Determine all possible points in the text where lines can broken. -/// -/// 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>(p: &'a Preparation) -> Breakpoints<'a> { - Breakpoints { - p, - linebreaks: LineBreakIterator::new(p.bidi.text), - syllables: None, - offset: 0, - suffix: 0, - end: 0, - mandatory: false, - } -} - -/// 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<hypher::Syllables<'a>>, - /// 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, -} - -impl Iterator for Breakpoints<'_> { - type Item = (usize, bool, bool); - - fn next(&mut self) -> Option<Self::Item> { - // 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(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.p.hyphenate != Some(false) { - if let Some(lang) = self.lang(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(&self, offset: usize) -> bool { - self.p - .hyphenate - .or_else(|| { - let shaped = self.p.find(offset)?.text()?; - Some(shaped.styles.get(TextNode::HYPHENATE)) - }) - .unwrap_or(false) - } - - /// The text language at the given offset. - fn lang(&self, offset: usize) -> Option<hypher::Lang> { - let lang = self.p.lang.or_else(|| { - let shaped = self.p.find(offset)?.text()?; - Some(shaped.styles.get(TextNode::LANG)) - })?; - - let bytes = lang.as_str().as_bytes().try_into().ok()?; - hypher::Lang::from_iso(bytes) - } -} - -/// Create a line which spans the given range. -fn line<'a>( - p: &'a Preparation, - mut range: Range, - mandatory: bool, - hyphen: bool, -) -> Line<'a> { - let end = range.end; - let mut justify = p.justify && end < p.bidi.text.len() && !mandatory; - - if range.is_empty() { - return Line { - bidi: &p.bidi, - end, - trimmed: range, - first: None, - inner: &[], - last: None, - width: Abs::zero(), - justify, - dash: false, - }; - } - - // Slice out the relevant items. - let (expanded, mut inner) = p.slice(range.clone()); - let mut width = Abs::zero(); - - // Reshape the last item if it's split in half or hyphenated. - let mut last = None; - let mut dash = false; - if let Some((Item::Text(shaped), before)) = inner.split_last() { - // Compute the range we want to shape, trimming whitespace at the - // end of the line. - let base = expanded.end - shaped.text.len(); - let start = range.start.max(base); - let text = &p.bidi.text[start..range.end]; - let trimmed = text.trim_end(); - range.end = start + trimmed.len(); - - // Deal with hyphens, dashes and justification. - let shy = trimmed.ends_with('\u{ad}'); - dash = hyphen || shy || trimmed.ends_with(['-', '–', '—']); - justify |= text.ends_with('\u{2028}'); - - // Usually, we don't want to shape an empty string because: - // - We don't want the height of trimmed whitespace in a different - // font to be considered for the line height. - // - Even if it's in the same font, its unnecessary. - // - // There is one exception though. When the whole line is empty, we - // need the shaped empty string to make the line the appropriate - // height. That is the case exactly if the string is empty and there - // are no other items in the line. - if hyphen || start + shaped.text.len() > range.end { - if hyphen || start < range.end || before.is_empty() { - let shifted = start - base..range.end - base; - let mut reshaped = shaped.reshape(p.world, shifted); - if hyphen || shy { - reshaped.push_hyphen(p.world); - } - width += reshaped.width; - last = Some(Item::Text(reshaped)); - } - - inner = before; - } - } - - // Reshape the start item if it's split in half. - let mut first = None; - if let Some((Item::Text(shaped), after)) = inner.split_first() { - // Compute the range we want to shape. - let base = expanded.start; - let end = range.end.min(base + shaped.text.len()); - - // Reshape if necessary. - if range.start + shaped.text.len() > end { - if range.start < end { - let shifted = range.start - base..end - base; - let reshaped = shaped.reshape(p.world, shifted); - width += reshaped.width; - first = Some(Item::Text(reshaped)); - } - - inner = after; - } - } - - // Measure the inner items. - for item in inner { - width += item.width(); - } - - Line { - bidi: &p.bidi, - trimmed: range, - end, - first, - inner, - last, - width, - justify, - dash, - } -} - -/// Combine layouted lines into one frame per region. -fn finalize( - p: &Preparation, - lines: &[Line], - regions: &Regions, -) -> SourceResult<Fragment> { - // Determine the paragraph's width: Full width of the region if we - // should expand or there's fractional spacing, fit-to-width otherwise. - let mut width = regions.first.x; - if !regions.expand.x && lines.iter().all(|line| line.fr().is_zero()) { - width = lines.iter().map(|line| line.width).max().unwrap_or_default(); - } - - // Stack the lines into one frame per region. - lines - .iter() - .map(|line| commit(p, line, regions.base, width)) - .collect::<SourceResult<_>>() - .map(Fragment::frames) -} - -/// Commit to a line and build its frame. -fn commit(p: &Preparation, line: &Line, base: Size, width: Abs) -> SourceResult<Frame> { - let mut remaining = width - line.width; - let mut offset = Abs::zero(); - - // Reorder the line from logical to visual order. - let reordered = reorder(line); - - // Handle hanging punctuation to the left. - if let Some(Item::Text(text)) = reordered.first() { - if let Some(glyph) = text.glyphs.first() { - if !text.dir.is_positive() - && text.styles.get(TextNode::OVERHANG) - && (reordered.len() > 1 || text.glyphs.len() > 1) - { - let amount = overhang(glyph.c) * glyph.x_advance.at(text.size); - offset -= amount; - remaining += amount; - } - } - } - - // Handle hanging punctuation to the right. - if let Some(Item::Text(text)) = reordered.last() { - if let Some(glyph) = text.glyphs.last() { - if text.dir.is_positive() - && text.styles.get(TextNode::OVERHANG) - && (reordered.len() > 1 || text.glyphs.len() > 1) - { - let amount = overhang(glyph.c) * glyph.x_advance.at(text.size); - remaining += amount; - } - } - } - - // Determine how much to justify each space. - let fr = line.fr(); - let mut justification = Abs::zero(); - if remaining < Abs::zero() || (line.justify && fr.is_zero()) { - let justifiables = line.justifiables(); - if justifiables > 0 { - justification = remaining / justifiables as f64; - remaining = Abs::zero(); - } - } - - let mut top = Abs::zero(); - let mut bottom = Abs::zero(); - - // Build the frames and determine the height and baseline. - let mut frames = vec![]; - for item in reordered { - let mut push = |offset: &mut Abs, frame: Frame| { - let width = frame.width(); - top.set_max(frame.baseline()); - bottom.set_max(frame.size().y - frame.baseline()); - frames.push((*offset, frame)); - *offset += width; - }; - - match item { - Item::Absolute(v) => { - offset += *v; - } - Item::Fractional(v) => { - offset += v.share(fr, remaining); - } - Item::Text(shaped) => { - let frame = shaped.build(p.world, justification); - push(&mut offset, frame); - } - Item::Frame(frame) => { - push(&mut offset, frame.clone()); - } - Item::Repeat(repeat, styles) => { - let before = offset; - let fill = Fr::one().share(fr, remaining); - let size = Size::new(fill, base.y); - let pod = Regions::one(size, base, Axes::new(false, false)); - let frame = repeat.layout(p.world, *styles, &pod)?.into_frame(); - let width = frame.width(); - let count = (fill / width).floor(); - let remaining = fill % width; - let apart = remaining / (count - 1.0); - if count == 1.0 { - offset += p.align.position(remaining); - } - if width > Abs::zero() { - for _ in 0..(count as usize).min(1000) { - push(&mut offset, frame.clone()); - offset += apart; - } - } - offset = before + fill; - } - } - } - - // Remaining space is distributed now. - if !fr.is_zero() { - remaining = Abs::zero(); - } - - let size = Size::new(width, top + bottom); - let mut output = Frame::new(size); - output.set_baseline(top); - - // Construct the line's frame. - for (offset, frame) in frames { - let x = offset + p.align.position(remaining); - let y = top - frame.baseline(); - output.push_frame(Point::new(x, y), frame); - } - - Ok(output) -} - -/// Return a line's items in visual order. -fn reorder<'a>(line: &'a Line<'a>) -> Vec<&Item<'a>> { - let mut reordered = vec![]; - - // The bidi crate doesn't like empty lines. - if line.trimmed.is_empty() { - return line.slice(line.trimmed.clone()).collect(); - } - - // Find the paragraph that contains the line. - let para = line - .bidi - .paragraphs - .iter() - .find(|para| para.range.contains(&line.trimmed.start)) - .unwrap(); - - // Compute the reordered ranges in visual order (left to right). - let (levels, runs) = line.bidi.visual_runs(para, line.trimmed.clone()); - - // Collect the reordered items. - for run in runs { - // Skip reset L1 runs because handling them would require reshaping - // again in some cases. - if line.bidi.levels[run.start] != levels[run.start] { - continue; - } - - let prev = reordered.len(); - reordered.extend(line.slice(run.clone())); - - if levels[run.start].is_rtl() { - reordered[prev..].reverse(); - } - } - - reordered -} - -/// How much a character should hang into the end margin. -/// -/// For more discussion, see: -/// https://recoveringphysicist.com/21/ -fn overhang(c: char) -> f64 { - match c { - // Dashes. - '–' | '—' => 0.2, - '-' => 0.55, - - // Punctuation. - '.' | ',' => 0.8, - ':' | ';' => 0.3, - - // Arabic and Ideographic - '\u{60C}' | '\u{6D4}' => 0.4, - '\u{3001}' | '\u{3002}' => 1.0, - - _ => 0.0, - } -} diff --git a/library/src/text/quotes.rs b/library/src/text/quotes.rs index 87a965af..95cf1ad9 100644 --- a/library/src/text/quotes.rs +++ b/library/src/text/quotes.rs @@ -1,10 +1,24 @@ use typst::syntax::is_newline; -use super::{Lang, Region}; +use crate::prelude::*; + +/// A smart quote. +#[derive(Debug, Hash)] +pub struct SmartQuoteNode { + pub double: bool, +} + +#[node] +impl SmartQuoteNode { + fn construct(_: &Vm, args: &mut Args) -> SourceResult<Content> { + let double = args.named("double")?.unwrap_or(true); + Ok(Self { double }.pack()) + } +} /// State machine for smart quote subtitution. #[derive(Debug, Clone)] -pub(super) struct Quoter { +pub struct Quoter { /// How many quotes have been opened. quote_depth: usize, /// Whether an opening quote might follow. @@ -68,7 +82,7 @@ fn is_opening_bracket(c: char) -> bool { } /// Decides which quotes to subtitute smart quotes with. -pub(super) struct Quotes<'s> { +pub struct Quotes<'s> { /// The opening single quote. pub single_open: &'s str, /// The closing single quote. diff --git a/library/src/text/shaping.rs b/library/src/text/shaping.rs index 6ce4d671..687f2a87 100644 --- a/library/src/text/shaping.rs +++ b/library/src/text/shaping.rs @@ -6,6 +6,7 @@ use typst::font::{Font, FontVariant}; use typst::util::SliceExt; use super::*; +use crate::meta::LinkNode; use crate::prelude::*; /// The result of shaping text. @@ -13,7 +14,7 @@ use crate::prelude::*; /// 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(super) struct ShapedText<'a> { +pub struct ShapedText<'a> { /// The text that was shaped. pub text: &'a str, /// The text direction. @@ -32,7 +33,7 @@ pub(super) struct ShapedText<'a> { /// A single glyph resulting from shaping. #[derive(Debug, Clone)] -pub(super) struct ShapedGlyph { +pub struct ShapedGlyph { /// The font the glyph is contained in. pub font: Font, /// The glyph's index in the font. @@ -314,7 +315,7 @@ struct ShapingContext<'a> { } /// Shape text into [`ShapedText`]. -pub(super) fn shape<'a>( +pub fn shape<'a>( world: Tracked<dyn World>, text: &'a str, styles: StyleChain<'a>, |
