diff --git a/crates/typst/src/layout/inline/collect.rs b/crates/typst/src/layout/inline/collect.rs index f1607460b..b6a847f57 100644 --- a/crates/typst/src/layout/inline/collect.rs +++ b/crates/typst/src/layout/inline/collect.rs @@ -201,7 +201,7 @@ pub fn collect<'a>( ); let peeked = iter.peek().and_then(|(child, _)| { if let Some(elem) = child.to_packed::() { - elem.text().chars().next() + elem.text().chars().find(|c| !is_default_ignorable(*c)) } else if child.is::() { Some('"') } else if child.is::() @@ -302,7 +302,7 @@ impl<'a> Collector<'a> { } fn push_segment(&mut self, segment: Segment<'a>, is_quote: bool) { - if let Some(last) = self.full.chars().last() { + if let Some(last) = self.full.chars().rev().find(|c| !is_default_ignorable(*c)) { self.quoter.last(last, is_quote); } diff --git a/crates/typst/src/layout/inline/linebreak.rs b/crates/typst/src/layout/inline/linebreak.rs index 9deaa92a8..075d24b33 100644 --- a/crates/typst/src/layout/inline/linebreak.rs +++ b/crates/typst/src/layout/inline/linebreak.rs @@ -953,3 +953,8 @@ where } } } + +/// Whether a codepoint is Unicode `Default_Ignorable`. +pub fn is_default_ignorable(c: char) -> bool { + DEFAULT_IGNORABLE_DATA.as_borrowed().contains(c) +} diff --git a/crates/typst/src/layout/inline/mod.rs b/crates/typst/src/layout/inline/mod.rs index f89de1690..821b4f57e 100644 --- a/crates/typst/src/layout/inline/mod.rs +++ b/crates/typst/src/layout/inline/mod.rs @@ -10,7 +10,7 @@ use comemo::{Track, Tracked, TrackedMut}; use self::collect::{collect, Item, Segment, SpanMapper}; use self::finalize::finalize; use self::line::{commit, line, Line}; -use self::linebreak::{linebreak, Breakpoint}; +use self::linebreak::{is_default_ignorable, linebreak, Breakpoint}; use self::prepare::{prepare, Preparation}; use self::shaping::{ cjk_punct_style, is_of_cj_script, shape_range, ShapedGlyph, ShapedText, diff --git a/crates/typst/src/model/outline.rs b/crates/typst/src/model/outline.rs index 090472850..ec1e5f1b8 100644 --- a/crates/typst/src/model/outline.rs +++ b/crates/typst/src/model/outline.rs @@ -483,7 +483,7 @@ impl OutlineEntry { impl Show for Packed { #[typst_macros::time(name = "outline.entry", span = self.span())] - fn show(&self, _: &mut Engine, _: StyleChain) -> SourceResult { + fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult { let mut seq = vec![]; let elem = self.element(); @@ -500,7 +500,11 @@ impl Show for Packed { }; // The body text remains overridable. - seq.push(self.body().clone().linked(Destination::Location(location))); + crate::text::isolate( + self.body().clone().linked(Destination::Location(location)), + styles, + &mut seq, + ); // Add filler symbols between the section name and page number. if let Some(filler) = self.fill() { diff --git a/crates/typst/src/text/mod.rs b/crates/typst/src/text/mod.rs index 7648f08fa..d42e4df8b 100644 --- a/crates/typst/src/text/mod.rs +++ b/crates/typst/src/text/mod.rs @@ -1299,3 +1299,13 @@ cast! { ret }, } + +/// Pushes `text` wrapped in LRE/RLE + PDF to `out`. +pub(crate) fn isolate(text: Content, styles: StyleChain, out: &mut Vec) { + out.push(TextElem::packed(match TextElem::dir_in(styles) { + Dir::RTL => "\u{202B}", + _ => "\u{202A}", + })); + out.push(text); + out.push(TextElem::packed("\u{202C}")); +} diff --git a/crates/typst/src/text/smartquote.rs b/crates/typst/src/text/smartquote.rs index 236d06363..797f0804b 100644 --- a/crates/typst/src/text/smartquote.rs +++ b/crates/typst/src/text/smartquote.rs @@ -123,7 +123,7 @@ impl SmartQuoter { /// Process the last seen character. pub fn last(&mut self, c: char, is_quote: bool) { - self.expect_opening = is_ignorable(c) || is_opening_bracket(c); + self.expect_opening = is_exterior_to_quote(c) || is_opening_bracket(c); self.last_num = c.is_numeric(); if !is_quote { self.prev_quote_type = None; @@ -150,7 +150,7 @@ impl SmartQuoter { self.prev_quote_type = Some(double); quotes.open(double) } else if self.quote_depth > 0 - && (peeked.is_ascii_punctuation() || is_ignorable(peeked)) + && (peeked.is_ascii_punctuation() || is_exterior_to_quote(peeked)) { self.quote_depth -= 1; quotes.close(double) @@ -168,7 +168,7 @@ impl Default for SmartQuoter { } } -fn is_ignorable(c: char) -> bool { +fn is_exterior_to_quote(c: char) -> bool { c.is_whitespace() || is_newline(c) } diff --git a/tests/ref/issue-4476-rtl-title-ending-in-ltr-text.png b/tests/ref/issue-4476-rtl-title-ending-in-ltr-text.png new file mode 100644 index 000000000..09506966e Binary files /dev/null and b/tests/ref/issue-4476-rtl-title-ending-in-ltr-text.png differ diff --git a/tests/ref/smartquote-with-embedding-chars.png b/tests/ref/smartquote-with-embedding-chars.png new file mode 100644 index 000000000..569bfb502 Binary files /dev/null and b/tests/ref/smartquote-with-embedding-chars.png differ diff --git a/tests/suite/model/outline.typ b/tests/suite/model/outline.typ index 085e06ede..2409dbbe3 100644 --- a/tests/suite/model/outline.typ +++ b/tests/suite/model/outline.typ @@ -176,3 +176,10 @@ Ok ... // Error: 2-55 heading must have a location // Hint: 2-55 try using a query or a show rule to customize the outline.entry instead #outline.entry(1, heading[Hello], [World!], none, [1]) + +--- issue-4476-rtl-title-ending-in-ltr-text --- +#set text(lang: "he") +#outline() + += הוקוס Pocus += זוהי כותרת שתורגמה על ידי מחשב diff --git a/tests/suite/text/smartquote.typ b/tests/suite/text/smartquote.typ index 28fcba5b7..02ac4455e 100644 --- a/tests/suite/text/smartquote.typ +++ b/tests/suite/text/smartquote.typ @@ -69,6 +69,11 @@ Some people's thought on this would be #[#set smartquote(enabled: false); "stran "'test' statement" \ "statement 'test'" +--- smartquote-with-embedding-chars --- +#set text(lang: "fr") +"#"\u{202A}"bonjour#"\u{202C}"" \ +#"\u{202A}""bonjour"#"\u{202C}" + --- smartquote-custom --- // Use language quotes for missing keys, allow partial reset #set smartquote(quotes: "«»")