1#[derive(Clone, Debug, PartialEq)]
5#[allow(missing_docs, dead_code)]
7pub(crate) enum Style {
8 Emphasis,
9 Strong,
10 Strikethrough,
11 Code,
12 Link,
13 Underline,
14 Color(crate::Color),
15}
16
17#[derive(Clone, Debug, PartialEq)]
18pub(crate) struct FormattedSpan {
20 pub(crate) range: core::ops::Range<usize>,
22 pub(crate) style: Style,
24}
25
26#[cfg(feature = "std")]
27#[derive(Clone, Debug)]
28enum ListItemType {
29 Ordered(u64),
30 Unordered,
31}
32
33#[derive(Clone, Debug, PartialEq)]
35pub(crate) struct StyledTextParagraph {
36 pub(crate) text: alloc::string::String,
38 pub(crate) formatting: alloc::vec::Vec<FormattedSpan>,
40 pub(crate) links: alloc::vec::Vec<(core::ops::Range<usize>, alloc::string::String)>,
42}
43
44#[cfg(feature = "std")]
46#[derive(Debug, thiserror::Error)]
47#[non_exhaustive]
48pub enum StyledTextError<'a> {
49 #[error("Spans are unbalanced: stack already empty when popped")]
51 Pop,
52 #[error("Spans are unbalanced: stack contained items at end of function")]
54 NotEmpty,
55 #[error("Paragraph not started")]
57 ParagraphNotStarted,
58 #[error("Unimplemented: {:?}", .0)]
60 UnimplementedTag(pulldown_cmark::Tag<'a>),
61 #[error("Unimplemented: {:?}", .0)]
63 UnimplementedEvent(pulldown_cmark::Event<'a>),
64 #[error("Unimplemented: {}", .0)]
66 UnimplementedHtmlEvent(alloc::string::String),
67 #[error("Unimplemented html tag: {}", .0)]
69 UnimplementedHtmlTag(alloc::string::String),
70 #[error("Unexpected {} attribute in html {}", .0, .1)]
72 UnexpectedAttribute(alloc::string::String, alloc::string::String),
73 #[error("Missing color attribute in html {}", .0)]
75 MissingColor(alloc::string::String),
76 #[error("Closing html tag doesn't match the opening tag. Expected {}, got {}", .0, .1)]
78 ClosingTagMismatch(&'a str, alloc::string::String),
79}
80
81#[repr(transparent)]
83#[derive(Debug, PartialEq, Clone, Default)]
84pub struct StyledText {
85 pub(crate) paragraphs: crate::SharedVector<StyledTextParagraph>,
87}
88
89#[cfg(feature = "std")]
90impl StyledText {
91 pub fn parse(string: &str) -> Result<Self, StyledTextError<'_>> {
93 let parser =
94 pulldown_cmark::Parser::new_ext(string, pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
95
96 let mut paragraphs = alloc::vec::Vec::new();
97 let mut list_state_stack: alloc::vec::Vec<Option<u64>> = alloc::vec::Vec::new();
98 let mut style_stack = alloc::vec::Vec::new();
99 let mut current_url = None;
100
101 let begin_paragraph = |paragraphs: &mut alloc::vec::Vec<StyledTextParagraph>,
102 indentation: u32,
103 list_item_type: Option<ListItemType>| {
104 let mut text = alloc::string::String::with_capacity(indentation as usize * 4);
105 for _ in 0..indentation {
106 text.push_str(" ");
107 }
108 match list_item_type {
109 Some(ListItemType::Unordered) => {
110 if indentation % 3 == 0 {
111 text.push_str("• ")
112 } else if indentation % 3 == 1 {
113 text.push_str("◦ ")
114 } else {
115 text.push_str("▪ ")
116 }
117 }
118 Some(ListItemType::Ordered(num)) => text.push_str(&alloc::format!("{}. ", num)),
119 None => {}
120 };
121 paragraphs.push(StyledTextParagraph {
122 text,
123 formatting: Default::default(),
124 links: Default::default(),
125 });
126 };
127
128 for event in parser {
129 let indentation = list_state_stack.len().saturating_sub(1) as _;
130
131 match event {
132 pulldown_cmark::Event::SoftBreak | pulldown_cmark::Event::HardBreak => {
133 begin_paragraph(&mut paragraphs, indentation, None);
134 }
135 pulldown_cmark::Event::End(pulldown_cmark::TagEnd::List(_)) => {
136 if list_state_stack.pop().is_none() {
137 return Err(StyledTextError::Pop);
138 }
139 }
140 pulldown_cmark::Event::End(
141 pulldown_cmark::TagEnd::Paragraph | pulldown_cmark::TagEnd::Item,
142 ) => {}
143 pulldown_cmark::Event::Start(tag) => {
144 let style = match tag {
145 pulldown_cmark::Tag::Paragraph => {
146 begin_paragraph(&mut paragraphs, indentation, None);
147 continue;
148 }
149 pulldown_cmark::Tag::Item => {
150 begin_paragraph(
151 &mut paragraphs,
152 indentation,
153 Some(match list_state_stack.last().copied() {
154 Some(Some(index)) => ListItemType::Ordered(index),
155 _ => ListItemType::Unordered,
156 }),
157 );
158 if let Some(state) = list_state_stack.last_mut() {
159 *state = state.map(|state| state + 1);
160 }
161 continue;
162 }
163 pulldown_cmark::Tag::List(index) => {
164 list_state_stack.push(index);
165 continue;
166 }
167 pulldown_cmark::Tag::Strong => Style::Strong,
168 pulldown_cmark::Tag::Emphasis => Style::Emphasis,
169 pulldown_cmark::Tag::Strikethrough => Style::Strikethrough,
170 pulldown_cmark::Tag::Link { dest_url, .. } => {
171 current_url = Some(dest_url);
172 Style::Link
173 }
174
175 pulldown_cmark::Tag::Heading { .. }
176 | pulldown_cmark::Tag::Image { .. }
177 | pulldown_cmark::Tag::DefinitionList
178 | pulldown_cmark::Tag::DefinitionListTitle
179 | pulldown_cmark::Tag::DefinitionListDefinition
180 | pulldown_cmark::Tag::TableHead
181 | pulldown_cmark::Tag::TableRow
182 | pulldown_cmark::Tag::TableCell
183 | pulldown_cmark::Tag::HtmlBlock
184 | pulldown_cmark::Tag::Superscript
185 | pulldown_cmark::Tag::Subscript
186 | pulldown_cmark::Tag::Table(_)
187 | pulldown_cmark::Tag::MetadataBlock(_)
188 | pulldown_cmark::Tag::BlockQuote(_)
189 | pulldown_cmark::Tag::CodeBlock(_)
190 | pulldown_cmark::Tag::FootnoteDefinition(_) => {
191 return Err(StyledTextError::UnimplementedTag(tag));
192 }
193 };
194
195 style_stack.push((
196 style,
197 paragraphs.last().ok_or(StyledTextError::ParagraphNotStarted)?.text.len(),
198 ));
199 }
200 pulldown_cmark::Event::Text(text) => {
201 paragraphs
202 .last_mut()
203 .ok_or(StyledTextError::ParagraphNotStarted)?
204 .text
205 .push_str(&text);
206 }
207 pulldown_cmark::Event::End(_) => {
208 let (style, start) = if let Some(value) = style_stack.pop() {
209 value
210 } else {
211 return Err(StyledTextError::Pop);
212 };
213
214 let paragraph =
215 paragraphs.last_mut().ok_or(StyledTextError::ParagraphNotStarted)?;
216 let end = paragraph.text.len();
217
218 if let Some(url) = current_url.take() {
219 paragraph.links.push((start..end, url.into()));
220 }
221
222 paragraph.formatting.push(FormattedSpan { range: start..end, style });
223 }
224 pulldown_cmark::Event::Code(text) => {
225 let paragraph =
226 paragraphs.last_mut().ok_or(StyledTextError::ParagraphNotStarted)?;
227 let start = paragraph.text.len();
228 paragraph.text.push_str(&text);
229 paragraph.formatting.push(FormattedSpan {
230 range: start..paragraph.text.len(),
231 style: Style::Code,
232 });
233 }
234 pulldown_cmark::Event::InlineHtml(html) => {
235 if html.starts_with("</") {
236 let (style, start) = if let Some(value) = style_stack.pop() {
237 value
238 } else {
239 return Err(StyledTextError::Pop);
240 };
241
242 let expected_tag = match &style {
243 Style::Color(_) => "</font>",
244 Style::Underline => "</u>",
245 other => std::unreachable!(
246 "Got unexpected closing style {:?} with html {}. This error should have been caught earlier.",
247 other,
248 html
249 ),
250 };
251
252 if (&*html) != expected_tag {
253 return Err(StyledTextError::ClosingTagMismatch(
254 expected_tag,
255 (&*html).into(),
256 ));
257 }
258
259 let paragraph =
260 paragraphs.last_mut().ok_or(StyledTextError::ParagraphNotStarted)?;
261 let end = paragraph.text.len();
262 paragraph.formatting.push(FormattedSpan { range: start..end, style });
263 } else {
264 let mut expecting_color_attribute = false;
265
266 for token in htmlparser::Tokenizer::from(&*html) {
267 match token {
268 Ok(htmlparser::Token::ElementStart { local: tag_type, .. }) => {
269 match &*tag_type {
270 "u" => {
271 style_stack.push((
272 Style::Underline,
273 paragraphs
274 .last()
275 .ok_or(StyledTextError::ParagraphNotStarted)?
276 .text
277 .len(),
278 ));
279 }
280 "font" => {
281 expecting_color_attribute = true;
282 }
283 _ => {
284 return Err(StyledTextError::UnimplementedHtmlTag(
285 (&*tag_type).into(),
286 ));
287 }
288 }
289 }
290 Ok(htmlparser::Token::Attribute {
291 local: key,
292 value: Some(value),
293 ..
294 }) => match &*key {
295 "color" => {
296 if !expecting_color_attribute {
297 return Err(StyledTextError::UnexpectedAttribute(
298 (&*key).into(),
299 (&*html).into(),
300 ));
301 }
302 expecting_color_attribute = false;
303
304 let value =
305 i_slint_common::color_parsing::parse_color_literal(
306 &*value,
307 )
308 .or_else(|| {
309 i_slint_common::color_parsing::named_colors()
310 .get(&*value)
311 .copied()
312 })
313 .expect("invalid color value");
314
315 style_stack.push((
316 Style::Color(crate::Color::from_argb_encoded(value)),
317 paragraphs
318 .last()
319 .ok_or(StyledTextError::ParagraphNotStarted)?
320 .text
321 .len(),
322 ));
323 }
324 _ => {
325 return Err(StyledTextError::UnexpectedAttribute(
326 (&*key).into(),
327 (&*html).into(),
328 ));
329 }
330 },
331 Ok(htmlparser::Token::ElementEnd { .. }) => {}
332 _ => {
333 return Err(StyledTextError::UnimplementedHtmlEvent(
334 alloc::format!("{:?}", token),
335 ));
336 }
337 }
338 }
339
340 if expecting_color_attribute {
341 return Err(StyledTextError::MissingColor((&*html).into()));
342 }
343 }
344 }
345 pulldown_cmark::Event::Rule
346 | pulldown_cmark::Event::TaskListMarker(_)
347 | pulldown_cmark::Event::FootnoteReference(_)
348 | pulldown_cmark::Event::InlineMath(_)
349 | pulldown_cmark::Event::DisplayMath(_)
350 | pulldown_cmark::Event::Html(_) => {
351 return Err(StyledTextError::UnimplementedEvent(event));
352 }
353 }
354 }
355
356 if !style_stack.is_empty() {
357 return Err(StyledTextError::NotEmpty);
358 }
359
360 Ok(StyledText { paragraphs: (¶graphs[..]).into() })
361 }
362}
363
364#[test]
365fn markdown_parsing() {
366 assert_eq!(
367 StyledText::parse("hello *world*").unwrap().paragraphs,
368 [StyledTextParagraph {
369 text: "hello world".into(),
370 formatting: alloc::vec![FormattedSpan { range: 6..11, style: Style::Emphasis }],
371 links: alloc::vec::Vec::new()
372 }]
373 );
374
375 assert_eq!(
376 StyledText::parse(
377 "
378- line 1
379- line 2
380 "
381 )
382 .unwrap()
383 .paragraphs,
384 [
385 StyledTextParagraph {
386 text: "• line 1".into(),
387 formatting: alloc::vec::Vec::new(),
388 links: alloc::vec::Vec::new()
389 },
390 StyledTextParagraph {
391 text: "• line 2".into(),
392 formatting: alloc::vec::Vec::new(),
393 links: alloc::vec::Vec::new()
394 }
395 ]
396 );
397
398 assert_eq!(
399 StyledText::parse(
400 "
4011. a
4022. b
4034. c
404 "
405 )
406 .unwrap()
407 .paragraphs,
408 [
409 StyledTextParagraph {
410 text: "1. a".into(),
411 formatting: alloc::vec::Vec::new(),
412 links: alloc::vec::Vec::new()
413 },
414 StyledTextParagraph {
415 text: "2. b".into(),
416 formatting: alloc::vec::Vec::new(),
417 links: alloc::vec::Vec::new()
418 },
419 StyledTextParagraph {
420 text: "3. c".into(),
421 formatting: alloc::vec::Vec::new(),
422 links: alloc::vec::Vec::new()
423 }
424 ]
425 );
426
427 assert_eq!(
428 StyledText::parse(
429 "
430Normal _italic_ **strong** ~~strikethrough~~ `code`
431new *line*
432"
433 )
434 .unwrap()
435 .paragraphs,
436 [
437 StyledTextParagraph {
438 text: "Normal italic strong strikethrough code".into(),
439 formatting: alloc::vec![
440 FormattedSpan { range: 7..13, style: Style::Emphasis },
441 FormattedSpan { range: 14..20, style: Style::Strong },
442 FormattedSpan { range: 21..34, style: Style::Strikethrough },
443 FormattedSpan { range: 35..39, style: Style::Code }
444 ],
445 links: alloc::vec::Vec::new()
446 },
447 StyledTextParagraph {
448 text: "new line".into(),
449 formatting: alloc::vec![FormattedSpan { range: 4..8, style: Style::Emphasis },],
450 links: alloc::vec::Vec::new()
451 }
452 ]
453 );
454
455 assert_eq!(
456 StyledText::parse(
457 "
458- root
459 - child
460 - grandchild
461 - great grandchild
462"
463 )
464 .unwrap()
465 .paragraphs,
466 [
467 StyledTextParagraph {
468 text: "• root".into(),
469 formatting: alloc::vec::Vec::new(),
470 links: alloc::vec::Vec::new()
471 },
472 StyledTextParagraph {
473 text: " ◦ child".into(),
474 formatting: alloc::vec::Vec::new(),
475 links: alloc::vec::Vec::new()
476 },
477 StyledTextParagraph {
478 text: " ▪ grandchild".into(),
479 formatting: alloc::vec::Vec::new(),
480 links: alloc::vec::Vec::new()
481 },
482 StyledTextParagraph {
483 text: " • great grandchild".into(),
484 formatting: alloc::vec::Vec::new(),
485 links: alloc::vec::Vec::new()
486 },
487 ]
488 );
489
490 assert_eq!(
491 StyledText::parse("hello [*world*](https://example.com)").unwrap().paragraphs,
492 [StyledTextParagraph {
493 text: "hello world".into(),
494 formatting: alloc::vec![
495 FormattedSpan { range: 6..11, style: Style::Emphasis },
496 FormattedSpan { range: 6..11, style: Style::Link }
497 ],
498 links: alloc::vec![(6..11, "https://example.com".into())]
499 }]
500 );
501
502 assert_eq!(
503 StyledText::parse("<u>hello world</u>").unwrap().paragraphs,
504 [StyledTextParagraph {
505 text: "hello world".into(),
506 formatting: alloc::vec![FormattedSpan { range: 0..11, style: Style::Underline },],
507 links: alloc::vec::Vec::new()
508 }]
509 );
510
511 assert_eq!(
512 StyledText::parse(r#"<font color="blue">hello world</font>"#).unwrap().paragraphs,
513 [StyledTextParagraph {
514 text: "hello world".into(),
515 formatting: alloc::vec![FormattedSpan {
516 range: 0..11,
517 style: Style::Color(crate::Color::from_rgb_u8(0, 0, 255))
518 },],
519 links: alloc::vec::Vec::new()
520 }]
521 );
522
523 assert_eq!(
524 StyledText::parse(r#"<u><font color="red">hello world</font></u>"#).unwrap().paragraphs,
525 [StyledTextParagraph {
526 text: "hello world".into(),
527 formatting: alloc::vec![
528 FormattedSpan {
529 range: 0..11,
530 style: Style::Color(crate::Color::from_rgb_u8(255, 0, 0))
531 },
532 FormattedSpan { range: 0..11, style: Style::Underline },
533 ],
534 links: alloc::vec::Vec::new()
535 }]
536 );
537}
538
539pub fn get_raw_text(styled_text: &StyledText) -> alloc::borrow::Cow<'_, str> {
540 match styled_text.paragraphs.as_slice() {
541 [] => "".into(),
542 [paragraph] => paragraph.text.as_str().into(),
543 _ => {
544 let mut result = alloc::string::String::new();
545 for paragraph in styled_text.paragraphs.iter() {
546 if !result.is_empty() {
547 result.push('\n');
548 }
549 result.push_str(paragraph.text.as_str());
550 }
551 result.into()
552 }
553 }
554}
555
556#[cfg(feature = "ffi")]
558pub mod ffi {
559 #![allow(unsafe_code)]
560
561 use super::*;
562
563 #[unsafe(no_mangle)]
564 pub unsafe extern "C" fn slint_styled_text_new(out: *mut StyledText) {
566 unsafe {
567 core::ptr::write(out, Default::default());
568 }
569 }
570
571 #[unsafe(no_mangle)]
572 pub unsafe extern "C" fn slint_styled_text_drop(text: *const StyledText) {
574 unsafe {
575 core::ptr::read(text);
576 }
577 }
578
579 #[unsafe(no_mangle)]
580 pub extern "C" fn slint_styled_text_eq(a: &StyledText, b: &StyledText) -> bool {
582 a == b
583 }
584
585 #[unsafe(no_mangle)]
586 pub unsafe extern "C" fn slint_styled_text_clone(out: *mut StyledText, ss: &StyledText) {
588 unsafe { core::ptr::write(out, ss.clone()) }
589 }
590}
591
592pub fn escape_markdown(text: &str) -> alloc::string::String {
593 let mut out = alloc::string::String::with_capacity(text.len());
594
595 for c in text.chars() {
596 match c {
597 '*' => out.push_str("\\*"),
598 '<' => out.push_str("<"),
599 '>' => out.push_str(">"),
600 '_' => out.push_str("\\_"),
601 '#' => out.push_str("\\#"),
602 '-' => out.push_str("\\-"),
603 '`' => out.push_str("\\`"),
604 '&' => out.push_str("\\&"),
605 _ => out.push(c),
606 }
607 }
608
609 out
610}
611
612pub fn parse_markdown(_text: &str) -> StyledText {
613 #[cfg(feature = "std")]
614 {
615 StyledText::parse(_text).unwrap()
616 }
617 #[cfg(not(feature = "std"))]
618 Default::default()
619}