From 4d8a9863d78a9151e744ed8468a0463234cf06b9 Mon Sep 17 00:00:00 2001 From: T0mstone Date: Mon, 23 Jun 2025 15:34:25 +0200 Subject: [PATCH 01/10] Allow multi-character symbols/variants --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/typst-eval/src/markup.rs | 4 +- crates/typst-eval/src/math.rs | 2 +- crates/typst-layout/src/math/text.rs | 43 +++++---- .../typst-library/src/foundations/symbol.rs | 90 ++++++++++--------- crates/typst-library/src/math/accent.rs | 4 +- crates/typst-library/src/math/matrix.rs | 2 +- crates/typst-library/src/symbols.rs | 2 +- crates/typst-realize/src/lib.rs | 8 +- 10 files changed, 85 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 550c4141a..509cfd0da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -413,7 +413,7 @@ dependencies = [ [[package]] name = "codex" version = "0.1.1" -source = "git+https://github.com/typst/codex?rev=a5428cb#a5428cb9c81a41354d44b44dbd5a16a710bbd928" +source = "git+https://github.com/typst/codex?rev=2f7efc3#2f7efc3b824632bcc917cebf4ae91caeca224fbc" [[package]] name = "color-print" diff --git a/Cargo.toml b/Cargo.toml index 6cc59ee89..00f1220fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ clap = { version = "4.4", features = ["derive", "env", "wrap_help"] } clap_complete = "4.2.1" clap_mangen = "0.2.10" codespan-reporting = "0.11" -codex = { git = "https://github.com/typst/codex", rev = "a5428cb" } +codex = { git = "https://github.com/typst/codex", rev = "2f7efc3" } color-print = "0.3.6" comemo = "0.4" csv = "1" diff --git a/crates/typst-eval/src/markup.rs b/crates/typst-eval/src/markup.rs index 9118ded56..6b5daf5b6 100644 --- a/crates/typst-eval/src/markup.rs +++ b/crates/typst-eval/src/markup.rs @@ -123,7 +123,7 @@ impl Eval for ast::Escape<'_> { type Output = Value; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(Value::Symbol(Symbol::single(self.get()))) + Ok(Value::Symbol(Symbol::runtime_char(self.get()))) } } @@ -131,7 +131,7 @@ impl Eval for ast::Shorthand<'_> { type Output = Value; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(Value::Symbol(Symbol::single(self.get()))) + Ok(Value::Symbol(Symbol::runtime_char(self.get()))) } } diff --git a/crates/typst-eval/src/math.rs b/crates/typst-eval/src/math.rs index 0e271a089..50e396212 100644 --- a/crates/typst-eval/src/math.rs +++ b/crates/typst-eval/src/math.rs @@ -49,7 +49,7 @@ impl Eval for ast::MathShorthand<'_> { type Output = Value; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(Value::Symbol(Symbol::single(self.get()))) + Ok(Value::Symbol(Symbol::runtime_char(self.get()))) } } diff --git a/crates/typst-layout/src/math/text.rs b/crates/typst-layout/src/math/text.rs index 67dc0a2c8..0db9f0f1d 100644 --- a/crates/typst-layout/src/math/text.rs +++ b/crates/typst-layout/src/math/text.rs @@ -120,25 +120,32 @@ pub fn layout_symbol( // Switch dotless char to normal when we have the dtls OpenType feature. // This should happen before the main styling pass. let dtls = style_dtls(); - let (unstyled_c, symbol_styles) = match try_dotless(elem.text) { - Some(c) if has_dtls_feat(ctx.font) => (c, styles.chain(&dtls)), - _ => (elem.text, styles), - }; - let c = styled_char(styles, unstyled_c, true); - let fragment: MathFragment = - match GlyphFragment::new_char(ctx.font, symbol_styles, c, elem.span()) { - Ok(mut glyph) => { - adjust_glyph_layout(&mut glyph, ctx, styles); - glyph.into() - } - Err(_) => { - // Not in the math font, fallback to normal inline text layout. - // TODO: Should replace this with proper fallback in [`GlyphFragment::new`]. - layout_inline_text(c.encode_utf8(&mut [0; 4]), elem.span(), ctx, styles)? - .into() - } + for c in elem.text.chars() { + let (unstyled_c, symbol_styles) = match try_dotless(c) { + Some(c) if has_dtls_feat(ctx.font) => (c, styles.chain(&dtls)), + _ => (c, styles), }; - ctx.push(fragment); + let c = styled_char(styles, unstyled_c, true); + let fragment: MathFragment = + match GlyphFragment::new_char(ctx.font, symbol_styles, c, elem.span()) { + Ok(mut glyph) => { + adjust_glyph_layout(&mut glyph, ctx, styles); + glyph.into() + } + Err(_) => { + // Not in the math font, fallback to normal inline text layout. + // TODO: Should replace this with proper fallback in [`GlyphFragment::new`]. + layout_inline_text( + c.encode_utf8(&mut [0; 4]), + elem.span(), + ctx, + styles, + )? + .into() + } + }; + ctx.push(fragment); + } Ok(()) } diff --git a/crates/typst-library/src/foundations/symbol.rs b/crates/typst-library/src/foundations/symbol.rs index f57bb0c2a..84682bfe7 100644 --- a/crates/typst-library/src/foundations/symbol.rs +++ b/crates/typst-library/src/foundations/symbol.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeSet, HashMap}; -use std::fmt::{self, Debug, Display, Formatter, Write}; +use std::fmt::{self, Debug, Display, Formatter}; use std::sync::Arc; use codex::ModifierSet; @@ -52,7 +52,7 @@ pub struct Symbol(Repr); #[derive(Clone, Eq, PartialEq, Hash)] enum Repr { /// A native symbol that has no named variant. - Single(char), + Single(&'static str), /// A native symbol with multiple named variants. Complex(&'static [Variant<&'static str>]), /// A symbol with multiple named variants, where some modifiers may have @@ -61,9 +61,9 @@ enum Repr { Modified(Arc<(List, ModifierSet)>), } -/// A symbol variant, consisting of a set of modifiers, a character, and an +/// A symbol variant, consisting of a set of modifiers, the variant's value, and an /// optional deprecation message. -type Variant = (ModifierSet, char, Option); +type Variant = (ModifierSet, S, Option); /// A collection of symbols. #[derive(Clone, Eq, PartialEq, Hash)] @@ -73,9 +73,9 @@ enum List { } impl Symbol { - /// Create a new symbol from a single character. - pub const fn single(c: char) -> Self { - Self(Repr::Single(c)) + /// Create a new symbol from a single value. + pub const fn single(value: &'static str) -> Self { + Self(Repr::Single(value)) } /// Create a symbol with a static variant list. @@ -85,6 +85,11 @@ impl Symbol { Self(Repr::Complex(list)) } + /// Create a symbol from a runtime char. + pub fn runtime_char(c: char) -> Self { + Self::runtime(Box::new([(ModifierSet::default(), c.into(), None)])) + } + /// Create a symbol with a runtime variant list. #[track_caller] pub fn runtime(list: Box<[Variant]>) -> Self { @@ -92,10 +97,10 @@ impl Symbol { Self(Repr::Modified(Arc::new((List::Runtime(list), ModifierSet::default())))) } - /// Get the symbol's character. - pub fn get(&self) -> char { + /// Get the symbol's value. + pub fn get(&self) -> &str { match &self.0 { - Repr::Single(c) => *c, + Repr::Single(value) => value, Repr::Complex(_) => ModifierSet::<&'static str>::default() .best_match_in(self.variants().map(|(m, c, _)| (m, c))) .unwrap(), @@ -108,27 +113,27 @@ impl Symbol { /// Try to get the function associated with the symbol, if any. pub fn func(&self) -> StrResult { match self.get() { - '⌈' => Ok(crate::math::ceil::func()), - '⌊' => Ok(crate::math::floor::func()), - '–' => Ok(crate::math::accent::dash::func()), - '⋅' | '\u{0307}' => Ok(crate::math::accent::dot::func()), - '¨' => Ok(crate::math::accent::dot_double::func()), - '\u{20db}' => Ok(crate::math::accent::dot_triple::func()), - '\u{20dc}' => Ok(crate::math::accent::dot_quad::func()), - '∼' => Ok(crate::math::accent::tilde::func()), - '´' => Ok(crate::math::accent::acute::func()), - '˝' => Ok(crate::math::accent::acute_double::func()), - '˘' => Ok(crate::math::accent::breve::func()), - 'ˇ' => Ok(crate::math::accent::caron::func()), - '^' => Ok(crate::math::accent::hat::func()), - '`' => Ok(crate::math::accent::grave::func()), - '¯' => Ok(crate::math::accent::macron::func()), - '○' => Ok(crate::math::accent::circle::func()), - '→' => Ok(crate::math::accent::arrow::func()), - '←' => Ok(crate::math::accent::arrow_l::func()), - '↔' => Ok(crate::math::accent::arrow_l_r::func()), - '⇀' => Ok(crate::math::accent::harpoon::func()), - '↼' => Ok(crate::math::accent::harpoon_lt::func()), + "⌈" => Ok(crate::math::ceil::func()), + "⌊" => Ok(crate::math::floor::func()), + "–" => Ok(crate::math::accent::dash::func()), + "⋅" | "\u{0307}" => Ok(crate::math::accent::dot::func()), + "¨" => Ok(crate::math::accent::dot_double::func()), + "\u{20db}" => Ok(crate::math::accent::dot_triple::func()), + "\u{20dc}" => Ok(crate::math::accent::dot_quad::func()), + "∼" => Ok(crate::math::accent::tilde::func()), + "´" => Ok(crate::math::accent::acute::func()), + "˝" => Ok(crate::math::accent::acute_double::func()), + "˘" => Ok(crate::math::accent::breve::func()), + "ˇ" => Ok(crate::math::accent::caron::func()), + "^" => Ok(crate::math::accent::hat::func()), + "`" => Ok(crate::math::accent::grave::func()), + "¯" => Ok(crate::math::accent::macron::func()), + "○" => Ok(crate::math::accent::circle::func()), + "→" => Ok(crate::math::accent::arrow::func()), + "←" => Ok(crate::math::accent::arrow_l::func()), + "↔" => Ok(crate::math::accent::arrow_l_r::func()), + "⇀" => Ok(crate::math::accent::harpoon::func()), + "↼" => Ok(crate::math::accent::harpoon_lt::func()), _ => bail!("symbol {self} is not callable"), } } @@ -163,7 +168,7 @@ impl Symbol { /// The characters that are covered by this symbol. pub fn variants(&self) -> impl Iterator> { match &self.0 { - Repr::Single(c) => Variants::Single(Some(*c).into_iter()), + Repr::Single(value) => Variants::Single(Some(*value).into_iter()), Repr::Complex(list) => Variants::Static(list.iter()), Repr::Modified(arc) => arc.0.variants(), } @@ -279,7 +284,7 @@ impl Symbol { impl Display for Symbol { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - f.write_char(self.get()) + f.write_str(self.get()) } } @@ -362,7 +367,7 @@ impl Serialize for Symbol { where S: Serializer, { - serializer.serialize_char(self.get()) + serializer.serialize_str(self.get()) } } @@ -377,11 +382,12 @@ impl List { } /// A value that can be cast to a symbol. -pub struct SymbolVariant(EcoString, char); +pub struct SymbolVariant(EcoString, EcoString); cast! { SymbolVariant, - c: char => Self(EcoString::new(), c), + c: char => Self(EcoString::new(), c.into()), + s: EcoString => Self(EcoString::new(), s), array: Array => { let mut iter = array.into_iter(); match (iter.next(), iter.next(), iter.next()) { @@ -393,7 +399,7 @@ cast! { /// Iterator over variants. enum Variants<'a> { - Single(std::option::IntoIter), + Single(std::option::IntoIter<&'static str>), Static(std::slice::Iter<'static, Variant<&'static str>>), Runtime(std::slice::Iter<'a, Variant>), } @@ -406,7 +412,7 @@ impl<'a> Iterator for Variants<'a> { Self::Single(iter) => Some((ModifierSet::default(), iter.next()?, None)), Self::Static(list) => list.next().copied(), Self::Runtime(list) => { - list.next().map(|(m, c, d)| (m.as_deref(), *c, d.as_deref())) + list.next().map(|(m, s, d)| (m.as_deref(), s.as_str(), d.as_deref())) } } } @@ -415,21 +421,21 @@ impl<'a> Iterator for Variants<'a> { /// A single character. #[elem(Repr, PlainText)] pub struct SymbolElem { - /// The symbol's character. + /// The symbol's value. #[required] - pub text: char, // This is called `text` for consistency with `TextElem`. + pub text: EcoString, // This is called `text` for consistency with `TextElem`. } impl SymbolElem { /// Create a new packed symbol element. - pub fn packed(text: impl Into) -> Content { + pub fn packed(text: impl Into) -> Content { Self::new(text.into()).pack() } } impl PlainText for Packed { fn plain_text(&self, text: &mut EcoString) { - text.push(self.text); + text.push_str(&self.text); } } diff --git a/crates/typst-library/src/math/accent.rs b/crates/typst-library/src/math/accent.rs index c8569ea23..e22fddd93 100644 --- a/crates/typst-library/src/math/accent.rs +++ b/crates/typst-library/src/math/accent.rs @@ -189,7 +189,7 @@ cast! { self => self.0.into_value(), v: char => Self::new(v), v: Content => match v.to_packed::() { - Some(elem) => Self::new(elem.text), - None => bail!("expected a symbol"), + Some(elem) if elem.text.chars().count() == 1 => Self::new(elem.text.chars().next().unwrap()), + _ => bail!("expected a single-character symbol"), }, } diff --git a/crates/typst-library/src/math/matrix.rs b/crates/typst-library/src/math/matrix.rs index b6c4654ed..823aa02c1 100644 --- a/crates/typst-library/src/math/matrix.rs +++ b/crates/typst-library/src/math/matrix.rs @@ -281,7 +281,7 @@ cast! { Delimiter, self => self.0.into_value(), _: NoneValue => Self::none(), - v: Symbol => Self::char(v.get())?, + v: Symbol => Self::char(v.get().parse::().map_err(|_| "symbol value is longer than one character")?)?, v: char => Self::char(v)?, } diff --git a/crates/typst-library/src/symbols.rs b/crates/typst-library/src/symbols.rs index 0588ace95..92f847e0b 100644 --- a/crates/typst-library/src/symbols.rs +++ b/crates/typst-library/src/symbols.rs @@ -39,7 +39,7 @@ impl From for Scope { impl From for Symbol { fn from(symbol: codex::Symbol) -> Self { match symbol { - codex::Symbol::Single(c) => Symbol::single(c), + codex::Symbol::Single(value) => Symbol::single(value), codex::Symbol::Multi(list) => Symbol::list(list), } } diff --git a/crates/typst-realize/src/lib.rs b/crates/typst-realize/src/lib.rs index 526f4631a..09090abc8 100644 --- a/crates/typst-realize/src/lib.rs +++ b/crates/typst-realize/src/lib.rs @@ -302,9 +302,7 @@ fn visit_kind_rules<'a>( // textual elements via `TEXTUAL` grouping. However, in math, this is // not desirable, so we just do it on a per-element basis. if let Some(elem) = content.to_packed::() { - if let Some(m) = - find_regex_match_in_str(elem.text.encode_utf8(&mut [0; 4]), styles) - { + if let Some(m) = find_regex_match_in_str(elem.text.as_str(), styles) { visit_regex_match(s, &[(content, styles)], m)?; return Ok(true); } @@ -325,7 +323,7 @@ fn visit_kind_rules<'a>( // Symbols in non-math content transparently convert to `TextElem` so we // don't have to handle them in non-math layout. if let Some(elem) = content.to_packed::() { - let mut text = TextElem::packed(elem.text).spanned(elem.span()); + let mut text = TextElem::packed(elem.text.clone()).spanned(elem.span()); if let Some(label) = elem.label() { text.set_label(label); } @@ -1240,7 +1238,7 @@ fn visit_regex_match<'a>( let len = if let Some(elem) = content.to_packed::() { elem.text.len() } else if let Some(elem) = content.to_packed::() { - elem.text.len_utf8() + elem.text.len() } else { 1 // The rest are Ascii, so just one byte. }; From 7dd35230445f7462e78f4c0799d98dbfd47628bf Mon Sep 17 00:00:00 2001 From: T0mstone Date: Thu, 10 Jul 2025 01:27:29 +0200 Subject: [PATCH 02/10] Improve error messages Using "codepoint" is more accurate and lines up with what typst's standard library uses --- crates/typst-library/src/math/accent.rs | 2 +- crates/typst-library/src/math/matrix.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/math/accent.rs b/crates/typst-library/src/math/accent.rs index aebd33475..6c1a34cf6 100644 --- a/crates/typst-library/src/math/accent.rs +++ b/crates/typst-library/src/math/accent.rs @@ -189,6 +189,6 @@ cast! { v: char => Self::new(v), v: Content => match v.to_packed::() { Some(elem) if elem.text.chars().count() == 1 => Self::new(elem.text.chars().next().unwrap()), - _ => bail!("expected a single-character symbol"), + _ => bail!("expected a single-codepoint symbol"), }, } diff --git a/crates/typst-library/src/math/matrix.rs b/crates/typst-library/src/math/matrix.rs index 708160158..d96312094 100644 --- a/crates/typst-library/src/math/matrix.rs +++ b/crates/typst-library/src/math/matrix.rs @@ -274,7 +274,7 @@ cast! { Delimiter, self => self.0.into_value(), _: NoneValue => Self::none(), - v: Symbol => Self::char(v.get().parse::().map_err(|_| "symbol value is longer than one character")?)?, + v: Symbol => Self::char(v.get().parse::().map_err(|_| "expected a single-codepoint symbol")?)?, v: char => Self::char(v)?, } From 3fba007c1368666efe2550fd625f0336356f14b3 Mon Sep 17 00:00:00 2001 From: T0mstone Date: Thu, 10 Jul 2025 02:03:44 +0200 Subject: [PATCH 03/10] Fix symbol repr --- crates/typst-library/src/foundations/symbol.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/typst-library/src/foundations/symbol.rs b/crates/typst-library/src/foundations/symbol.rs index 84682bfe7..3bc50660f 100644 --- a/crates/typst-library/src/foundations/symbol.rs +++ b/crates/typst-library/src/foundations/symbol.rs @@ -310,7 +310,7 @@ impl Debug for List { impl crate::foundations::Repr for Symbol { fn repr(&self) -> EcoString { match &self.0 { - Repr::Single(c) => eco_format!("symbol(\"{}\")", *c), + Repr::Single(value) => eco_format!("symbol({})", value.repr()), Repr::Complex(variants) => { eco_format!( "symbol{}", @@ -346,15 +346,15 @@ fn repr_variants<'a>( // that contain all applied modifiers. applied_modifiers.iter().all(|am| modifiers.contains(am)) }) - .map(|(modifiers, c, _)| { + .map(|(modifiers, value, _)| { let trimmed_modifiers = modifiers.into_iter().filter(|&m| !applied_modifiers.contains(m)); if trimmed_modifiers.clone().all(|m| m.is_empty()) { - eco_format!("\"{c}\"") + value.repr() } else { let trimmed_modifiers = trimmed_modifiers.collect::>().join("."); - eco_format!("(\"{}\", \"{}\")", trimmed_modifiers, c) + eco_format!("({}, {})", trimmed_modifiers.repr(), value.repr()) } }) .collect::>(), From fd35268a8898f17a2b22561e1dff6ea8a58bdee9 Mon Sep 17 00:00:00 2001 From: T0mstone Date: Thu, 10 Jul 2025 02:04:51 +0200 Subject: [PATCH 04/10] cleanup --- crates/typst-library/src/foundations/symbol.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/typst-library/src/foundations/symbol.rs b/crates/typst-library/src/foundations/symbol.rs index 3bc50660f..c743ea763 100644 --- a/crates/typst-library/src/foundations/symbol.rs +++ b/crates/typst-library/src/foundations/symbol.rs @@ -102,10 +102,10 @@ impl Symbol { match &self.0 { Repr::Single(value) => value, Repr::Complex(_) => ModifierSet::<&'static str>::default() - .best_match_in(self.variants().map(|(m, c, _)| (m, c))) + .best_match_in(self.variants().map(|(m, v, _)| (m, v))) .unwrap(), Repr::Modified(arc) => { - arc.1.best_match_in(self.variants().map(|(m, c, _)| (m, c))).unwrap() + arc.1.best_match_in(self.variants().map(|(m, v, _)| (m, v))).unwrap() } } } @@ -168,7 +168,7 @@ impl Symbol { /// The characters that are covered by this symbol. pub fn variants(&self) -> impl Iterator> { match &self.0 { - Repr::Single(value) => Variants::Single(Some(*value).into_iter()), + Repr::Single(value) => Variants::Single(std::iter::once(*value)), Repr::Complex(list) => Variants::Static(list.iter()), Repr::Modified(arc) => arc.0.variants(), } @@ -291,7 +291,7 @@ impl Display for Symbol { impl Debug for Repr { fn fmt(&self, f: &mut Formatter) -> fmt::Result { match self { - Self::Single(c) => Debug::fmt(c, f), + Self::Single(value) => Debug::fmt(value, f), Self::Complex(list) => list.fmt(f), Self::Modified(lists) => lists.fmt(f), } @@ -399,7 +399,7 @@ cast! { /// Iterator over variants. enum Variants<'a> { - Single(std::option::IntoIter<&'static str>), + Single(std::iter::Once<&'static str>), Static(std::slice::Iter<'static, Variant<&'static str>>), Runtime(std::slice::Iter<'a, Variant>), } From 476096c2db5f4d539a4cb0c4cb24e4e1a36a022e Mon Sep 17 00:00:00 2001 From: T0mstone Date: Thu, 10 Jul 2025 02:28:00 +0200 Subject: [PATCH 05/10] Fix ide and docs --- crates/typst-ide/src/complete.rs | 6 +++--- docs/src/lib.rs | 17 ++++++++++++----- docs/src/model.rs | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index bc5b3e10e..9c7d9d2f0 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -98,7 +98,7 @@ pub enum CompletionKind { /// A font family. Font, /// A symbol. - Symbol(char), + Symbol(EcoString), } /// Complete in comments. Or rather, don't! @@ -450,7 +450,7 @@ fn field_access_completions( for modifier in symbol.modifiers() { if let Ok(modified) = symbol.clone().modified((), modifier) { ctx.completions.push(Completion { - kind: CompletionKind::Symbol(modified.get()), + kind: CompletionKind::Symbol(modified.get().into()), label: modifier.into(), apply: None, detail: None, @@ -1385,7 +1385,7 @@ impl<'a> CompletionContext<'a> { kind: kind.unwrap_or_else(|| match value { Value::Func(_) => CompletionKind::Func, Value::Type(_) => CompletionKind::Type, - Value::Symbol(s) => CompletionKind::Symbol(s.get()), + Value::Symbol(s) => CompletionKind::Symbol(s.get().into()), _ => CompletionKind::Constant, }), label, diff --git a/docs/src/lib.rs b/docs/src/lib.rs index e3eb21f98..063f1676f 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -718,9 +718,13 @@ fn symbols_model(resolver: &dyn Resolver, group: &GroupData) -> SymbolsModel { } }; - for (variant, c, deprecation) in symbol.variants() { + for (variant, value, deprecation) in symbol.variants() { + let value_char = value.parse::().ok(); + let shorthand = |list: &[(&'static str, char)]| { - list.iter().copied().find(|&(_, x)| x == c).map(|(s, _)| s) + value_char.and_then(|c| { + list.iter().copied().find(|&(_, x)| x == c).map(|(s, _)| s) + }) }; let name = complete(variant); @@ -729,9 +733,12 @@ fn symbols_model(resolver: &dyn Resolver, group: &GroupData) -> SymbolsModel { name, markup_shorthand: shorthand(typst::syntax::ast::Shorthand::LIST), math_shorthand: shorthand(typst::syntax::ast::MathShorthand::LIST), - math_class: typst_utils::default_math_class(c).map(math_class_name), - codepoint: c as _, - accent: typst::math::Accent::combine(c).is_some(), + math_class: value_char.and_then(|c| { + typst_utils::default_math_class(c).map(math_class_name) + }), + value: value.into(), + accent: value_char + .is_some_and(|c| typst::math::Accent::combine(c).is_some()), alternates: symbol .variants() .filter(|(other, _, _)| other != &variant) diff --git a/docs/src/model.rs b/docs/src/model.rs index 801c60c7f..d061a5c45 100644 --- a/docs/src/model.rs +++ b/docs/src/model.rs @@ -159,7 +159,7 @@ pub struct SymbolsModel { #[serde(rename_all = "camelCase")] pub struct SymbolModel { pub name: EcoString, - pub codepoint: u32, + pub value: EcoString, pub accent: bool, pub alternates: Vec, pub markup_shorthand: Option<&'static str>, From 5e202843c1259eec597dc936a3f1f18cf9d52bd4 Mon Sep 17 00:00:00 2001 From: T0mstone Date: Wed, 23 Jul 2025 12:52:12 +0200 Subject: [PATCH 06/10] Add basic multi-char symbol layout Not perfect, but should handle most cases Co-authored-by: Max --- crates/typst-layout/src/math/fragment.rs | 1 + crates/typst-layout/src/math/text.rs | 65 +++++++++++------------- 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/crates/typst-layout/src/math/fragment.rs b/crates/typst-layout/src/math/fragment.rs index 3497587c0..f73929057 100644 --- a/crates/typst-layout/src/math/fragment.rs +++ b/crates/typst-layout/src/math/fragment.rs @@ -300,6 +300,7 @@ impl GlyphFragment { ); let buffer = rustybuzz::shape_with_plan(font.rusty(), &plan, buffer); + // TODO: deal with multiple glyphs. if buffer.len() != 1 { bail!(span, "did not get a single glyph after shaping {}", text); } diff --git a/crates/typst-layout/src/math/text.rs b/crates/typst-layout/src/math/text.rs index e358b93db..ddbfb3339 100644 --- a/crates/typst-layout/src/math/text.rs +++ b/crates/typst-layout/src/math/text.rs @@ -129,44 +129,41 @@ pub fn layout_symbol( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - assert!( - elem.text.len() <= 4 && elem.text.chars().count() == 1, - "TODO: layout multi-char symbol" - ); - let elem_char = elem - .text - .chars() - .next() - .expect("TODO: should an empty symbol value forbidden?"); - - // Switch dotless char to normal when we have the dtls OpenType feature. - // This should happen before the main styling pass. - let dtls = style_dtls(); - let (unstyled_c, symbol_styles) = match try_dotless(elem_char) { - Some(c) if has_dtls_feat(ctx.font) => (c, styles.chain(&dtls)), - _ => (elem_char, styles), - }; - let variant = styles.get(EquationElem::variant); let bold = styles.get(EquationElem::bold); let italic = styles.get(EquationElem::italic); + let dtls = style_dtls(); + let has_dtls_feat = has_dtls_feat(ctx.font); + for cluster in elem.text.graphemes(true) { + // Switch dotless char to normal when we have the dtls OpenType feature. + // This should happen before the main styling pass. + let mut enable_dtls = false; + let text: EcoString = cluster + .chars() + .flat_map(|mut c| { + if has_dtls_feat && let Some(d) = try_dotless(c) { + enable_dtls = true; + c = d; + } + to_style(c, MathStyle::select(c, variant, bold, italic)) + }) + .collect(); + let styles = if enable_dtls { styles.chain(&dtls) } else { styles }; - let style = MathStyle::select(unstyled_c, variant, bold, italic); - let text: EcoString = to_style(unstyled_c, style).collect(); - - let fragment: MathFragment = - match GlyphFragment::new(ctx.font, symbol_styles, &text, elem.span()) { - Ok(mut glyph) => { - adjust_glyph_layout(&mut glyph, ctx, styles); - glyph.into() - } - Err(_) => { - // Not in the math font, fallback to normal inline text layout. - // TODO: Should replace this with proper fallback in [`GlyphFragment::new`]. - layout_inline_text(&text, elem.span(), ctx, styles)?.into() - } - }; - ctx.push(fragment); + let fragment: MathFragment = + match GlyphFragment::new(ctx.font, styles, &text, elem.span()) { + Ok(mut glyph) => { + adjust_glyph_layout(&mut glyph, ctx, styles); + glyph.into() + } + Err(_) => { + // Not in the math font, fallback to normal inline text layout. + // TODO: Should replace this with proper fallback in [`GlyphFragment::new`]. + layout_inline_text(&text, elem.span(), ctx, styles)?.into() + } + }; + ctx.push(fragment); + } Ok(()) } From e48fe5e301b4d5dd719ffdd1c677ee545477b5b5 Mon Sep 17 00:00:00 2001 From: T0mstone Date: Wed, 23 Jul 2025 13:06:09 +0200 Subject: [PATCH 07/10] docs: Ignore variation selectors for math class and accent --- docs/src/lib.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/src/lib.rs b/docs/src/lib.rs index ca17f6c14..64f5309a2 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -720,24 +720,24 @@ fn symbols_model(resolver: &dyn Resolver, group: &GroupData) -> SymbolsModel { for (variant, value, deprecation) in symbol.variants() { let value_char = value.parse::().ok(); - let shorthand = |list: &[(&'static str, char)]| { value_char.and_then(|c| { list.iter().copied().find(|&(_, x)| x == c).map(|(s, _)| s) }) }; + let base_char = base_char(value); let name = complete(variant); list.push(SymbolModel { name, markup_shorthand: shorthand(typst::syntax::ast::Shorthand::LIST), math_shorthand: shorthand(typst::syntax::ast::MathShorthand::LIST), - math_class: value_char.and_then(|c| { + math_class: base_char.and_then(|c| { typst_utils::default_math_class(c).map(math_class_name) }), value: value.into(), - accent: value_char + accent: base_char .is_some_and(|c| typst::math::Accent::combine(c).is_some()), alternates: symbol .variants() @@ -778,6 +778,13 @@ pub fn urlify(title: &str) -> EcoString { .collect() } +/// Convert a string to a `char`, ignoring any suffixed variation selectors. +fn base_char(value: &str) -> Option { + value.trim_end_matches(|c: char| { + matches!(c, '\u{180B}'..='\u{180D}' | '\u{180F}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}') + }).parse::().ok() +} + /// Extract the first line of documentation. fn oneliner(docs: &str) -> EcoString { let paragraph = docs.split("\n\n").next().unwrap_or_default(); From 91189f4061b5931f4ef9c8f56dc31e832a3b9844 Mon Sep 17 00:00:00 2001 From: T0mstone Date: Wed, 23 Jul 2025 13:16:41 +0200 Subject: [PATCH 08/10] Error for empty symbol variant values --- crates/typst-library/src/foundations/symbol.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/typst-library/src/foundations/symbol.rs b/crates/typst-library/src/foundations/symbol.rs index 3368355f1..e4b12766d 100644 --- a/crates/typst-library/src/foundations/symbol.rs +++ b/crates/typst-library/src/foundations/symbol.rs @@ -271,6 +271,14 @@ impl Symbol { } } + if v.1.is_empty() { + if v.0.is_empty() { + bail!(span, "default variant is empty"); + } else { + bail!(span, "variant is empty: {}", v.0.repr()); + } + } + seen.insert(hash, i); } From 70f619e89600e954b431298e0eb69010b633e134 Mon Sep 17 00:00:00 2001 From: T0mstone Date: Wed, 23 Jul 2025 14:04:17 +0200 Subject: [PATCH 09/10] Add tests, fix bug, and improve symbol constructor errors --- .../typst-library/src/foundations/symbol.rs | 49 +++++++++++------- tests/ref/symbol-constructor.png | Bin 511 -> 558 bytes tests/suite/symbols/symbol.typ | 14 +++++ 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/crates/typst-library/src/foundations/symbol.rs b/crates/typst-library/src/foundations/symbol.rs index e4b12766d..c2e0a6ec7 100644 --- a/crates/typst-library/src/foundations/symbol.rs +++ b/crates/typst-library/src/foundations/symbol.rs @@ -8,7 +8,7 @@ use serde::{Serialize, Serializer}; use typst_syntax::{Span, Spanned, is_ident}; use typst_utils::hash128; -use crate::diag::{DeprecationSink, SourceResult, StrResult, bail}; +use crate::diag::{DeprecationSink, SourceResult, StrResult, bail, error}; use crate::foundations::{ Array, Content, Func, NativeElement, NativeFunc, Packed, PlainText, Repr as _, cast, elem, func, scope, ty, @@ -231,15 +231,30 @@ impl Symbol { // A list of modifiers, cleared & reused in each iteration. let mut modifiers = Vec::new(); + let mut errors = ecow::eco_vec![]; + // Validate the variants. - for (i, &Spanned { ref v, span }) in variants.iter().enumerate() { + 'variants: for (i, &Spanned { ref v, span }) in variants.iter().enumerate() { modifiers.clear(); + if v.1.is_empty() { + errors.push(if v.0.is_empty() { + error!(span, "empty default variant") + } else { + error!(span, "empty variant: {}", v.0.repr()) + }); + } + if !v.0.is_empty() { // Collect all modifiers. for modifier in v.0.split('.') { if !is_ident(modifier) { - bail!(span, "invalid symbol modifier: {}", modifier.repr()); + errors.push(error!( + span, + "invalid symbol modifier: {}", + modifier.repr() + )); + continue 'variants; } modifiers.push(modifier); } @@ -250,37 +265,34 @@ impl Symbol { // Ensure that there are no duplicate modifiers. if let Some(ms) = modifiers.windows(2).find(|ms| ms[0] == ms[1]) { - bail!( + errors.push(error!( span, "duplicate modifier within variant: {}", ms[0].repr(); hint: "modifiers are not ordered, so each one may appear only once" - ) + )); + continue 'variants; } // Check whether we had this set of modifiers before. let hash = hash128(&modifiers); if let Some(&i) = seen.get(&hash) { - if v.0.is_empty() { - bail!(span, "duplicate default variant"); + errors.push(if v.0.is_empty() { + error!(span, "duplicate default variant") } else if v.0 == variants[i].v.0 { - bail!(span, "duplicate variant: {}", v.0.repr()); + error!(span, "duplicate variant: {}", v.0.repr()) } else { - bail!( + error!( span, "duplicate variant: {}", v.0.repr(); hint: "variants with the same modifiers are identical, regardless of their order" ) - } - } - - if v.1.is_empty() { - if v.0.is_empty() { - bail!(span, "default variant is empty"); - } else { - bail!(span, "variant is empty: {}", v.0.repr()); - } + }); + continue 'variants; } seen.insert(hash, i); } + if !errors.is_empty() { + return Err(errors); + } let list = variants .into_iter() @@ -394,7 +406,6 @@ pub struct SymbolVariant(EcoString, EcoString); cast! { SymbolVariant, - c: char => Self(EcoString::new(), c.into()), s: EcoString => Self(EcoString::new(), s), array: Array => { let mut iter = array.into_iter(); diff --git a/tests/ref/symbol-constructor.png b/tests/ref/symbol-constructor.png index e6db9491d1e7a8bef885085f0935ae853b6f1c31..0cccd70f17361c75db741c0d14b8d7f0ef9ca884 100644 GIT binary patch delta 533 zcmV+w0_y$$1Fi&+B!BfuL_t(|+U=D&Pa8oLfJv7+RqC|qQbqiVB+3s+sx+yRBBDvd z&0-{sEF)}-Ls;fqFsvDj!4mgjzyi!=4q9G_zOzj9H#nmBvV^!k{#+m~ zZ!fjcKg4IZF1L}|YM=~Btv-S{y)9HVPYRI0cFwTBD~U4_RB$N3@P5)QW*-AMc(W1Z zyRrcd;C)EJ@PCf1g3|}%VKlf2_W_c4yAr{N{BVNi|2&y9JPLtzfRR-&hPN7ZOC8d2 zQMt*(*-fC-#aTG5%8}A+PaM!xkCh+Yb@|x*ddkMXsYk-#3^=Aigx5Tj9bw04A&Bzu zHJTrQhe4|jR>ir^{*NwfnIR?#-}a3}!CYLFV+X6P~oi$_cgx7hfh z7)u!mwpt*%L?%}QP7^XH?tV@#}5R0x3Wff1p*HR<&x=rgu|8 zh8U^zAn842jor!;dO!~=sqCR)D48ZCVu>Y2o{q3S=Ntztl zUt4)kt|yezlk|l%4H79Z(~2rE5vRJ*CI~TA4P~(*_91>-)x!}-; z2H(DQKoV6qet&)8eQ@6c?A?Mk{5Yvv`h|{TUekZc=!|TiicB6UvKPFA)AmN6@+uV&=+15WnP@IH783wA5zjhoI;E8u#EPg zhP`e;ZarvtFH4sZxQyuwXImtO*nw)6R7TJo+$O_#bdg Date: Wed, 23 Jul 2025 14:22:16 +0200 Subject: [PATCH 10/10] Make docs align with actual behavior --- docs/src/lib.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/src/lib.rs b/docs/src/lib.rs index 64f5309a2..242b65ca7 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -720,24 +720,26 @@ fn symbols_model(resolver: &dyn Resolver, group: &GroupData) -> SymbolsModel { for (variant, value, deprecation) in symbol.variants() { let value_char = value.parse::().ok(); + let shorthand = |list: &[(&'static str, char)]| { value_char.and_then(|c| { list.iter().copied().find(|&(_, x)| x == c).map(|(s, _)| s) }) }; - let base_char = base_char(value); let name = complete(variant); list.push(SymbolModel { name, markup_shorthand: shorthand(typst::syntax::ast::Shorthand::LIST), math_shorthand: shorthand(typst::syntax::ast::MathShorthand::LIST), - math_class: base_char.and_then(|c| { + // Matches `typst_layout::math::GlyphFragment::new` + math_class: value.chars().next().and_then(|c| { typst_utils::default_math_class(c).map(math_class_name) }), value: value.into(), - accent: base_char + // Matches casting `Symbol` to `Accent` + accent: value_char .is_some_and(|c| typst::math::Accent::combine(c).is_some()), alternates: symbol .variants() @@ -778,13 +780,6 @@ pub fn urlify(title: &str) -> EcoString { .collect() } -/// Convert a string to a `char`, ignoring any suffixed variation selectors. -fn base_char(value: &str) -> Option { - value.trim_end_matches(|c: char| { - matches!(c, '\u{180B}'..='\u{180D}' | '\u{180F}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}') - }).parse::().ok() -} - /// Extract the first line of documentation. fn oneliner(docs: &str) -> EcoString { let paragraph = docs.split("\n\n").next().unwrap_or_default();