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
753 lines
30 KiB
Rust
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![] }]
|
|
)
|
|
}
|