Files
qbz/crates/vendor/i-slint-common-1.16.1/styled_text.rs
T
vicrodh fa3ee1e410 fix(fonts): pre-register macOS CJK fallback families in vendored i-slint-common
fontique's CoreText-based Han-script fallback fails to resolve a family on
modern macOS (the suggested PingFang is not always in the scanned family
map), so Japanese kanji rendered blank while kana worked. Vendor
i-slint-common 1.16.1 and pre-register Hiragino / PingFang / Apple SD
Gothic Neo / Arial Unicode MS in the fallback map at collection creation
(Japanese-biased default; the map takes priority over the CoreText query).
macOS-only cfg; Linux keeps its working fontconfig fallback. Type-checked
against aarch64-apple-darwin.

cc
2026-07-03 10:00:31 -06:00

753 lines
30 KiB
Rust

// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
#[derive(Clone, Debug, PartialEq)]
/// Styles that can be applied to text spans
#[allow(missing_docs, dead_code)]
pub enum Style {
Emphasis,
Strong,
Strikethrough,
Code,
Link,
Underline,
// ARGB encoded
Color(u32),
}
#[derive(Clone, Debug, PartialEq)]
/// A style and a text span
pub struct FormattedSpan {
/// Span of text to style
pub range: core::ops::Range<usize>,
/// The style to apply
pub style: Style,
}
#[cfg(feature = "markdown")]
#[derive(Clone, Debug)]
enum ListItemType {
Ordered(u64),
Unordered,
}
/// A section of styled text, split up by a linebreak
#[derive(Clone, Debug, PartialEq)]
pub struct StyledTextParagraph {
/// The raw paragraph text
pub text: alloc::string::String,
/// Formatting styles and spans
pub formatting: alloc::vec::Vec<FormattedSpan>,
/// Locations of clickable links within the paragraph
pub links: alloc::vec::Vec<(core::ops::Range<usize>, alloc::string::String)>,
}
/// Error type returned by `StyledText::parse`
#[cfg(feature = "markdown")]
#[derive(Debug, derive_more::Error, derive_more::Display, PartialEq)]
#[non_exhaustive]
pub enum StyledTextError<'a> {
/// Spans are unbalanced: stack already empty when popped
#[display("Spans are unbalanced: stack already empty when popped")]
Pop,
/// Spans are unbalanced: stack contained items at end of function
#[display("Spans are unbalanced: stack contained items at end of function")]
NotEmpty,
/// Paragraph not started
#[display("Paragraph not started")]
ParagraphNotStarted,
/// Unimplemented markdown tag
#[display("Unimplemented tag: {:?}", _0.to_end())]
UnimplementedTag(#[error(not(source))] pulldown_cmark::Tag<'a>),
/// Unimplemented markdown event
#[display("Unimplemented event: {_0:?}")]
UnimplementedEvent(#[error(not(source))] pulldown_cmark::Event<'a>),
/// Unimplemented html event
#[display("Unimplemented html: {_0}")]
UnimplementedHtmlEvent(#[error(not(source))] alloc::string::String),
/// Unimplemented html tag
#[display("Unimplemented html tag: {_0}")]
UnimplementedHtmlTag(#[error(not(source))] alloc::string::String),
/// Unimplemented html attribute
#[display("Unexpected {_0} attribute in html {_1}")]
UnexpectedAttribute(#[error(not(source))] alloc::string::String, alloc::string::String),
/// Missing color attribute in html
#[display("Missing color attribute in html {_0}")]
MissingColor(#[error(not(source))] alloc::string::String),
/// Closing html tag doesn't match the opening tag
#[display("Closing html tag doesn't match the opening tag. Expected {_0}, got {_1}")]
ClosingTagMismatch(#[error(not(source))] &'a str, alloc::string::String),
/// Argument index out of range
#[display("Argument index {_0} out of range: {_1} arguments provided")]
ArgumentOutOfRange(usize, usize),
/// Format string placeholders count mismatch
#[display("Format string contains {_0} placeholders, but {_1} arguments were provided")]
PlaceholderCountMismatch(usize, usize),
#[display("Interpolating multiple styled text paragraphs is not currently implemented")]
MultiParagraphInterpolation,
/// Invalid color value
#[display("Invalid color value '{_0}'")]
InvalidColor(#[error(not(source))] alloc::string::String),
}
#[cfg(feature = "markdown")]
pub fn paragraph_from_plain_text(text: alloc::string::String) -> StyledTextParagraph {
StyledTextParagraph { text, formatting: Default::default(), links: Default::default() }
}
#[cfg(feature = "markdown")]
/// This is the character for private use that is used to make interpolation possible in markdown.
pub const MARKDOWN_INTERPOLATION_PLACEHOLDER: char = '\u{e541}';
#[cfg(feature = "markdown")]
pub fn parse_interpolated<S: AsRef<[StyledTextParagraph]>>(
format_string: &str,
args: &[S],
) -> impl Iterator<Item = Result<StyledTextParagraph, StyledTextError<'static>>> {
let mut parser = pulldown_cmark::Parser::new_ext(
format_string,
pulldown_cmark::Options::ENABLE_STRIKETHROUGH,
);
let mut list_state_stack: alloc::vec::Vec<Option<u64>> = alloc::vec::Vec::new();
let mut style_stack = alloc::vec::Vec::new();
let mut current_url = None;
let mut arg_index = 0;
let begin_paragraph = |indentation: u32, list_item_type: Option<ListItemType>| {
let mut text = alloc::string::String::with_capacity(indentation as usize * 4);
for _ in 0..indentation {
text.push_str(" ");
}
match list_item_type {
Some(ListItemType::Unordered) => {
let remainder = indentation % 3;
if remainder == 0 {
text.push_str("• ")
} else if remainder == 1 {
text.push_str("◦ ")
} else {
text.push_str("▪ ")
}
}
Some(ListItemType::Ordered(num)) => text.push_str(&alloc::format!("{}. ", num)),
None => {}
};
StyledTextParagraph { text, formatting: Default::default(), links: Default::default() }
};
let mut current_paragraph = None;
std::iter::from_fn(move || {
let mut substitute = |paragraph: &mut StyledTextParagraph,
string: &str|
-> Result<(), StyledTextError<'static>> {
let mut pos = 0;
while let Some(mut p) = string[pos..].find(MARKDOWN_INTERPOLATION_PLACEHOLDER) {
p += pos;
paragraph.text.push_str(&string[pos..p]);
if let Some(arg) = args.get(arg_index) {
let arg_paragraphs = arg.as_ref();
match arg_paragraphs {
[arg_paragraph] => {
let offset = paragraph.text.len();
paragraph.text.push_str(&arg_paragraph.text);
paragraph.formatting.extend(
arg_paragraph.formatting.iter().cloned().map(|mut f| {
f.range.start += offset;
f.range.end += offset;
f
}),
);
paragraph.links.extend(arg_paragraph.links.iter().cloned().map(
|(mut range, link)| {
range.start += offset;
range.end += offset;
(range, link)
},
));
}
[] => {
// No paragraphs in the argument, so nothing to add
}
_ => {
return Err(StyledTextError::MultiParagraphInterpolation);
}
}
} else {
return Err(StyledTextError::ArgumentOutOfRange(arg_index, args.len()));
}
arg_index += 1;
p += MARKDOWN_INTERPOLATION_PLACEHOLDER.len_utf8();
pos = p;
}
paragraph.text.push_str(&string[pos..]);
Ok(())
};
for event in &mut parser {
let indentation = list_state_stack.len().saturating_sub(1) as _;
match event {
pulldown_cmark::Event::SoftBreak | pulldown_cmark::Event::HardBreak => {
if let Some(paragraph) =
current_paragraph.replace(begin_paragraph(indentation, None))
{
return Some(Ok(paragraph));
}
}
pulldown_cmark::Event::End(pulldown_cmark::TagEnd::List(_)) => {
if list_state_stack.pop().is_none() {
return Some(Err(StyledTextError::Pop));
}
}
pulldown_cmark::Event::End(
pulldown_cmark::TagEnd::Paragraph | pulldown_cmark::TagEnd::Item,
) => {}
pulldown_cmark::Event::Start(tag) => {
let style = match tag {
pulldown_cmark::Tag::Paragraph => {
if let Some(paragraph) =
current_paragraph.replace(begin_paragraph(indentation, None))
{
return Some(Ok(paragraph));
}
continue;
}
pulldown_cmark::Tag::Item => {
let old_paragraph = current_paragraph.replace(begin_paragraph(
indentation,
Some(match list_state_stack.last().copied() {
Some(Some(index)) => ListItemType::Ordered(index),
_ => ListItemType::Unordered,
}),
));
if let Some(state) = list_state_stack.last_mut() {
*state = state.map(|state| state + 1);
}
if let Some(paragraph) = old_paragraph {
return Some(Ok(paragraph));
}
continue;
}
pulldown_cmark::Tag::List(index) => {
list_state_stack.push(index);
continue;
}
pulldown_cmark::Tag::Strong => Style::Strong,
pulldown_cmark::Tag::Emphasis => Style::Emphasis,
pulldown_cmark::Tag::Strikethrough => Style::Strikethrough,
pulldown_cmark::Tag::Link { dest_url, .. } => {
current_url = Some(dest_url);
Style::Link
}
pulldown_cmark::Tag::Heading { .. }
| pulldown_cmark::Tag::Image { .. }
| pulldown_cmark::Tag::DefinitionList
| pulldown_cmark::Tag::DefinitionListTitle
| pulldown_cmark::Tag::DefinitionListDefinition
| pulldown_cmark::Tag::TableHead
| pulldown_cmark::Tag::TableRow
| pulldown_cmark::Tag::TableCell
| pulldown_cmark::Tag::HtmlBlock
| pulldown_cmark::Tag::Superscript
| pulldown_cmark::Tag::Subscript
| pulldown_cmark::Tag::Table(_)
| pulldown_cmark::Tag::MetadataBlock(_)
| pulldown_cmark::Tag::BlockQuote(_)
| pulldown_cmark::Tag::CodeBlock(_)
| pulldown_cmark::Tag::FootnoteDefinition(_) => {
return Some(Err(StyledTextError::UnimplementedTag(tag.into_static())));
}
};
let paragraph = match current_paragraph.as_mut() {
Some(paragraph) => paragraph,
None => return Some(Err(StyledTextError::ParagraphNotStarted)),
};
style_stack.push((style, paragraph.text.len()));
}
pulldown_cmark::Event::Text(text) => {
let paragraph = match current_paragraph.as_mut() {
Some(paragraph) => paragraph,
None => return Some(Err(StyledTextError::ParagraphNotStarted)),
};
if let Err(err) = substitute(paragraph, &text) {
return Some(Err(err));
};
}
pulldown_cmark::Event::End(_) => {
let (style, start) = if let Some(value) = style_stack.pop() {
value
} else {
return Some(Err(StyledTextError::Pop));
};
let paragraph = match current_paragraph.as_mut() {
Some(paragraph) => paragraph,
None => return Some(Err(StyledTextError::ParagraphNotStarted)),
};
let end = paragraph.text.len();
if let Some(url) = current_url.take() {
paragraph.links.push((start..end, url.into()));
}
paragraph.formatting.push(FormattedSpan { range: start..end, style });
}
pulldown_cmark::Event::Code(text) => {
let paragraph = match current_paragraph.as_mut() {
Some(paragraph) => paragraph,
None => return Some(Err(StyledTextError::ParagraphNotStarted)),
};
let start = paragraph.text.len();
if let Err(err) = substitute(paragraph, &text) {
return Some(Err(err));
}
paragraph.formatting.push(FormattedSpan {
range: start..paragraph.text.len(),
style: Style::Code,
});
}
pulldown_cmark::Event::InlineHtml(html) => {
if html.starts_with("</") {
let (style, start) = if let Some(value) = style_stack.pop() {
value
} else {
return Some(Err(StyledTextError::Pop));
};
let expected_tag = match &style {
Style::Color(_) => "</font>",
Style::Underline => "</u>",
other => std::unreachable!(
"Got unexpected closing style {:?} with html {}. This error should have been caught earlier.",
other,
html
),
};
if (&*html) != expected_tag {
return Some(Err(StyledTextError::ClosingTagMismatch(
expected_tag,
(&*html).into(),
)));
}
let paragraph = match current_paragraph.as_mut() {
Some(paragraph) => paragraph,
None => return Some(Err(StyledTextError::ParagraphNotStarted)),
};
let end = paragraph.text.len();
paragraph.formatting.push(FormattedSpan { range: start..end, style });
} else {
let mut expecting_color_attribute = false;
for token in htmlparser::Tokenizer::from(&*html) {
match token {
Ok(htmlparser::Token::ElementStart { local: tag_type, .. }) => {
match &*tag_type {
"u" => {
let paragraph = match current_paragraph.as_mut() {
Some(paragraph) => paragraph,
None => {
return Some(Err(
StyledTextError::ParagraphNotStarted,
));
}
};
style_stack
.push((Style::Underline, paragraph.text.len()));
}
"font" => {
expecting_color_attribute = true;
}
_ => {
return Some(Err(
StyledTextError::UnimplementedHtmlTag(
(&*tag_type).into(),
),
));
}
}
}
Ok(htmlparser::Token::Attribute {
local: key,
value: Some(value),
..
}) => match &*key {
"color" => {
if !expecting_color_attribute {
return Some(Err(
StyledTextError::UnexpectedAttribute(
(&*key).into(),
(&*html).into(),
),
));
}
expecting_color_attribute = false;
let value =
match crate::color_parsing::parse_color_literal(&value)
.or_else(|| {
crate::color_parsing::named_colors()
.get(&*value)
.copied()
}) {
Some(value) => value,
None => {
return Some(Err(
StyledTextError::InvalidColor(
value.to_string(),
),
));
}
};
let paragraph = match current_paragraph.as_mut() {
Some(paragraph) => paragraph,
None => {
return Some(Err(
StyledTextError::ParagraphNotStarted,
));
}
};
style_stack
.push((Style::Color(value), paragraph.text.len()));
}
_ => {
return Some(Err(StyledTextError::UnexpectedAttribute(
(&*key).into(),
(&*html).into(),
)));
}
},
Ok(htmlparser::Token::ElementEnd { .. }) => {}
_ => {
return Some(Err(StyledTextError::UnimplementedHtmlEvent(
alloc::format!("{:?}", token),
)));
}
}
}
if expecting_color_attribute {
return Some(Err(StyledTextError::MissingColor((&*html).into())));
}
}
}
pulldown_cmark::Event::Rule
| pulldown_cmark::Event::TaskListMarker(_)
| pulldown_cmark::Event::FootnoteReference(_)
| pulldown_cmark::Event::InlineMath(_)
| pulldown_cmark::Event::DisplayMath(_)
| pulldown_cmark::Event::Html(_) => {
return Some(Err(StyledTextError::UnimplementedEvent(event.into_static())));
}
}
}
if arg_index != args.len() {
return Some(Err(StyledTextError::PlaceholderCountMismatch(arg_index, args.len())));
}
if !style_stack.is_empty() {
return Some(Err(StyledTextError::NotEmpty));
}
current_paragraph.take().map(Ok)
})
}
#[cfg(feature = "markdown")]
#[test]
fn markdown_parsing() {
assert_eq!(
parse_interpolated::<&[_]>("hello *world*", &[]).collect::<Result<Vec<_>, _>>().unwrap(),
[StyledTextParagraph {
text: "hello world".into(),
formatting: alloc::vec![FormattedSpan { range: 6..11, style: Style::Emphasis }],
links: alloc::vec::Vec::new()
}]
);
assert_eq!(
parse_interpolated::<&[_]>(
"
- line 1
- line 2
",
&[]
)
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[
StyledTextParagraph {
text: "• line 1".into(),
formatting: alloc::vec::Vec::new(),
links: alloc::vec::Vec::new()
},
StyledTextParagraph {
text: "• line 2".into(),
formatting: alloc::vec::Vec::new(),
links: alloc::vec::Vec::new()
}
]
);
assert_eq!(
parse_interpolated::<&[_]>(
"
1. a
2. b
4. c
",
&[]
)
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[
StyledTextParagraph {
text: "1. a".into(),
formatting: alloc::vec::Vec::new(),
links: alloc::vec::Vec::new()
},
StyledTextParagraph {
text: "2. b".into(),
formatting: alloc::vec::Vec::new(),
links: alloc::vec::Vec::new()
},
StyledTextParagraph {
text: "3. c".into(),
formatting: alloc::vec::Vec::new(),
links: alloc::vec::Vec::new()
}
]
);
assert_eq!(
parse_interpolated::<&[_]>(
"
Normal _italic_ **strong** ~~strikethrough~~ `code`
new *line*
",
&[]
)
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[
StyledTextParagraph {
text: "Normal italic strong strikethrough code".into(),
formatting: alloc::vec![
FormattedSpan { range: 7..13, style: Style::Emphasis },
FormattedSpan { range: 14..20, style: Style::Strong },
FormattedSpan { range: 21..34, style: Style::Strikethrough },
FormattedSpan { range: 35..39, style: Style::Code }
],
links: alloc::vec::Vec::new()
},
StyledTextParagraph {
text: "new line".into(),
formatting: alloc::vec![FormattedSpan { range: 4..8, style: Style::Emphasis },],
links: alloc::vec::Vec::new()
}
]
);
assert_eq!(
parse_interpolated::<&[_]>(
"
- root
- child
- grandchild
- great grandchild
",
&[]
)
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[
StyledTextParagraph {
text: "• root".into(),
formatting: alloc::vec::Vec::new(),
links: alloc::vec::Vec::new()
},
StyledTextParagraph {
text: " ◦ child".into(),
formatting: alloc::vec::Vec::new(),
links: alloc::vec::Vec::new()
},
StyledTextParagraph {
text: " ▪ grandchild".into(),
formatting: alloc::vec::Vec::new(),
links: alloc::vec::Vec::new()
},
StyledTextParagraph {
text: " • great grandchild".into(),
formatting: alloc::vec::Vec::new(),
links: alloc::vec::Vec::new()
},
]
);
assert_eq!(
parse_interpolated::<&[_]>("hello [*world*](https://example.com)", &[])
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[StyledTextParagraph {
text: "hello world".into(),
formatting: alloc::vec![
FormattedSpan { range: 6..11, style: Style::Emphasis },
FormattedSpan { range: 6..11, style: Style::Link }
],
links: alloc::vec![(6..11, "https://example.com".into())]
}]
);
assert_eq!(
parse_interpolated::<&[_]>("<u>hello world</u>", &[])
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[StyledTextParagraph {
text: "hello world".into(),
formatting: alloc::vec![FormattedSpan { range: 0..11, style: Style::Underline },],
links: alloc::vec::Vec::new()
}]
);
assert_eq!(
parse_interpolated::<&[_]>(r#"<font color="blue">hello world</font>"#, &[])
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[StyledTextParagraph {
text: "hello world".into(),
formatting: alloc::vec![FormattedSpan {
range: 0..11,
style: Style::Color(0xff_00_00_ff)
},],
links: alloc::vec::Vec::new()
}]
);
assert_eq!(
parse_interpolated::<&[_]>(r#"<u><font color="red">hello world</font></u>"#, &[])
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[StyledTextParagraph {
text: "hello world".into(),
formatting: alloc::vec![
FormattedSpan { range: 0..11, style: Style::Color(0xff_ff_00_00) },
FormattedSpan { range: 0..11, style: Style::Underline },
],
links: alloc::vec::Vec::new()
}]
);
assert_eq!(
parse_interpolated::<&[_]>(r#"<u><font color="\#a">hello world</font></u>"#, &[])
.collect::<Result<Vec<_>, _>>()
.unwrap_err(),
StyledTextError::InvalidColor(r"\#a".into())
);
}
#[cfg(feature = "markdown")]
#[test]
fn markdown_parsing_interpolated() {
assert_eq!(
parse_interpolated(
&format!("Text: *{MARKDOWN_INTERPOLATION_PLACEHOLDER}*"),
&[&[paragraph_from_plain_text("italic".into())]]
)
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[StyledTextParagraph {
text: "Text: italic".into(),
formatting: alloc::vec![FormattedSpan { range: 6..12, style: Style::Emphasis }],
links: alloc::vec![]
}]
);
assert_eq!(
parse_interpolated(
&format!("Escaped text: {MARKDOWN_INTERPOLATION_PLACEHOLDER}"),
&[&[paragraph_from_plain_text("*bold*".into())]]
)
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[StyledTextParagraph {
text: "Escaped text: *bold*".into(),
formatting: alloc::vec![],
links: alloc::vec![]
}]
);
assert_eq!(
parse_interpolated(
&format!("Code block text: `{MARKDOWN_INTERPOLATION_PLACEHOLDER}`"),
&[&[paragraph_from_plain_text("*bold*".into())]]
)
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[StyledTextParagraph {
text: "Code block text: *bold*".into(),
formatting: alloc::vec![FormattedSpan { range: 17..23, style: Style::Code }],
links: alloc::vec![]
}]
);
assert_eq!(
parse_interpolated(
&format!(
"**{MARKDOWN_INTERPOLATION_PLACEHOLDER}** {MARKDOWN_INTERPOLATION_PLACEHOLDER}"
),
&[
alloc::vec![paragraph_from_plain_text("Hello".into())],
parse_interpolated::<&[_]>("*World*", &[]).collect::<Result<_, _>>().unwrap()
]
)
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[StyledTextParagraph {
text: "Hello World".into(),
formatting: alloc::vec![
FormattedSpan { range: 0..5, style: Style::Strong },
FormattedSpan { range: 6..11, style: Style::Emphasis }
],
links: alloc::vec![]
}]
);
assert_eq!(
parse_interpolated(
&format!("<u>{MARKDOWN_INTERPOLATION_PLACEHOLDER}</u>"),
&[parse_interpolated::<&[_]>("*underline_and_italic*", &[])
.collect::<Result<Vec<_>, _>>()
.unwrap()]
)
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[StyledTextParagraph {
text: "underline_and_italic".into(),
formatting: alloc::vec![
FormattedSpan { range: 0..20, style: Style::Emphasis },
FormattedSpan { range: 0..20, style: Style::Underline },
],
links: alloc::vec![]
}]
);
// Empty paragraph list might be caused by a StyledText::default()
assert_eq!(
parse_interpolated(&format!("{MARKDOWN_INTERPOLATION_PLACEHOLDER}"), &[[]])
.collect::<Result<Vec<_>, _>>()
.unwrap(),
[StyledTextParagraph { text: "".into(), formatting: alloc::vec![], links: alloc::vec![] }]
)
}