1use crate::rich_common::{
14 decode_html_entities, parse_tag, parse_tg_time_format, tg_time_flags_to_format,
15};
16use ferogram_tl_types as tl;
17
18#[cfg(not(feature = "html5ever"))]
19pub fn parse_html(html: &str) -> (String, Vec<tl::enums::MessageEntity>) {
20 let mut out = String::with_capacity(html.len());
21 let mut ents = Vec::new();
22 let mut stack: Vec<(HtmlTag, i32, Option<String>)> = Vec::new();
23 let mut utf16_off: i32 = 0;
24
25 let bytes = html.as_bytes();
26 let len = bytes.len();
27 let mut i = 0;
28
29 while i < len {
30 if bytes[i] == b'<' {
31 let tag_start = i + 1;
32 let mut j = tag_start;
33 while j < len && bytes[j] != b'>' {
34 j += 1;
35 }
36 let tag_content = &html[tag_start..j];
37 i = j + 1;
38
39 let is_close = tag_content.starts_with('/');
40 let tag_str = if is_close {
41 tag_content[1..].trim()
42 } else {
43 tag_content.trim()
44 };
45 let (tag_name, attrs) = parse_tag(tag_str);
46
47 if is_close {
48 if tag_name == "code"
50 && let Some(pos) = stack
51 .iter()
52 .rposition(|(t, _, _)| matches!(t, HtmlTag::CodeInPre))
53 {
54 stack.remove(pos);
55 continue;
56 }
57
58 if let Some(pos) = stack.iter().rposition(|(t, _, _)| t.closes_with(tag_name)) {
59 let (htag, start_off, extra) = stack.remove(pos);
60 let length = utf16_off - start_off;
61 if length > 0 {
62 let entity: Option<tl::enums::MessageEntity> = match htag {
63 HtmlTag::Bold => Some(tl::enums::MessageEntity::Bold(
64 tl::types::MessageEntityBold {
65 offset: start_off,
66 length,
67 },
68 )),
69 HtmlTag::Italic => Some(tl::enums::MessageEntity::Italic(
70 tl::types::MessageEntityItalic {
71 offset: start_off,
72 length,
73 },
74 )),
75 HtmlTag::Underline => Some(tl::enums::MessageEntity::Underline(
76 tl::types::MessageEntityUnderline {
77 offset: start_off,
78 length,
79 },
80 )),
81 HtmlTag::Strike => Some(tl::enums::MessageEntity::Strike(
82 tl::types::MessageEntityStrike {
83 offset: start_off,
84 length,
85 },
86 )),
87 HtmlTag::Spoiler | HtmlTag::SpanSpoiler => {
88 Some(tl::enums::MessageEntity::Spoiler(
89 tl::types::MessageEntitySpoiler {
90 offset: start_off,
91 length,
92 },
93 ))
94 }
95 HtmlTag::Code => Some(tl::enums::MessageEntity::Code(
96 tl::types::MessageEntityCode {
97 offset: start_off,
98 length,
99 },
100 )),
101 HtmlTag::CodeInPre => None,
102 HtmlTag::Pre => {
103 Some(tl::enums::MessageEntity::Pre(tl::types::MessageEntityPre {
104 offset: start_off,
105 length,
106 language: extra.unwrap_or_default(),
107 }))
108 }
109 HtmlTag::Link(url) => {
110 const PFX: &str = "tg://user?id=";
111 if let Some(s) = url.strip_prefix(PFX) {
112 s.parse::<i64>().ok().map(|uid| {
113 tl::enums::MessageEntity::MentionName(
114 tl::types::MessageEntityMentionName {
115 offset: start_off,
116 length,
117 user_id: uid,
118 },
119 )
120 })
121 } else {
122 Some(tl::enums::MessageEntity::TextUrl(
123 tl::types::MessageEntityTextUrl {
124 offset: start_off,
125 length,
126 url,
127 },
128 ))
129 }
130 }
131 HtmlTag::CustomEmoji(id) => {
132 Some(tl::enums::MessageEntity::CustomEmoji(
133 tl::types::MessageEntityCustomEmoji {
134 offset: start_off,
135 length,
136 document_id: id,
137 },
138 ))
139 }
140 HtmlTag::Blockquote { collapsed } => {
141 Some(tl::enums::MessageEntity::Blockquote(
142 tl::types::MessageEntityBlockquote {
143 collapsed,
144 offset: start_off,
145 length,
146 },
147 ))
148 }
149 HtmlTag::TgTime {
150 unix,
151 relative,
152 short_time,
153 long_time,
154 short_date,
155 long_date,
156 day_of_week,
157 } => Some(tl::enums::MessageEntity::FormattedDate(
158 tl::types::MessageEntityFormattedDate {
159 relative,
160 short_time,
161 long_time,
162 short_date,
163 long_date,
164 day_of_week,
165 offset: start_off,
166 length,
167 date: unix,
168 },
169 )),
170 HtmlTag::Unknown => None,
171 };
172 if let Some(e) = entity {
173 ents.push(e);
174 }
175 }
176 }
177 } else {
178 let htag: HtmlTag = match tag_name {
180 "b" | "strong" => HtmlTag::Bold,
181 "i" | "em" => HtmlTag::Italic,
182 "u" | "ins" => HtmlTag::Underline, "s" | "del" | "strike" => HtmlTag::Strike,
184 "tg-spoiler" => HtmlTag::Spoiler,
185 "span" => {
186 if attrs.iter().any(|(k, v)| k == "class" && v == "tg-spoiler") {
187 HtmlTag::SpanSpoiler
188 } else {
189 HtmlTag::Unknown
190 }
191 }
192 "blockquote" => {
193 let collapsed = attrs.iter().any(|(k, _)| k == "expandable");
194 HtmlTag::Blockquote { collapsed }
195 }
196 "tg-time" => {
197 let unix: i32 = attrs
198 .iter()
199 .find(|(k, _)| k == "unix")
200 .and_then(|(_, v)| v.parse::<i32>().ok())
201 .unwrap_or(0);
202 let fmt = attrs
203 .iter()
204 .find(|(k, _)| k == "format")
205 .map(|(_, v)| v.as_str())
206 .unwrap_or("");
207 let (relative, short_time, long_time, short_date, long_date, day_of_week) =
208 parse_tg_time_format(fmt);
209 HtmlTag::TgTime {
210 unix,
211 relative,
212 short_time,
213 long_time,
214 short_date,
215 long_date,
216 day_of_week,
217 }
218 }
219 "code" => {
220 if let Some(last) = stack.last_mut()
222 && matches!(last.0, HtmlTag::Pre)
223 {
224 let lang = attrs
225 .iter()
226 .find(|(k, _)| k == "class")
227 .and_then(|(_, v)| v.strip_prefix("language-"))
228 .map(|s| s.to_string())
229 .unwrap_or_default();
230 last.2 = Some(lang);
231 stack.push((HtmlTag::CodeInPre, utf16_off, None));
232 continue;
233 }
234 HtmlTag::Code
235 }
236 "pre" => HtmlTag::Pre,
237 "a" => HtmlTag::Link(
238 attrs
239 .iter()
240 .find(|(k, _)| k == "href")
241 .map(|(_, v)| v.clone())
242 .unwrap_or_default(),
243 ),
244 "tg-emoji" => HtmlTag::CustomEmoji(
245 attrs
246 .iter()
247 .find(|(k, _)| k == "emoji-id")
248 .and_then(|(_, v)| v.parse::<i64>().ok())
249 .unwrap_or(0),
250 ),
251 "br" => {
252 out.push('\n');
253 utf16_off += 1;
254 continue;
255 }
256 _ => HtmlTag::Unknown,
257 };
258 stack.push((htag, utf16_off, None));
259 }
260 } else {
261 let text_start = i;
262 while i < len && bytes[i] != b'<' {
263 i += 1;
264 }
265 let decoded = decode_html_entities(&html[text_start..i]);
266 for ch in decoded.chars() {
267 out.push(ch);
268 utf16_off += ch.len_utf16() as i32;
269 }
270 }
271 }
272
273 (out, ents)
274}
275
276#[cfg(not(feature = "html5ever"))]
277#[cfg(not(feature = "html5ever"))]
278#[cfg(not(feature = "html5ever"))]
280#[cfg(not(feature = "html5ever"))]
281#[derive(Debug, Clone)]
282enum HtmlTag {
283 Bold,
284 Italic,
285 Underline,
286 Strike,
287 Spoiler,
288 SpanSpoiler,
289 Code,
290 CodeInPre,
291 Pre,
292 Link(String),
293 CustomEmoji(i64),
294 Blockquote {
295 collapsed: bool,
296 },
297 TgTime {
298 unix: i32,
299 relative: bool,
300 short_time: bool,
301 long_time: bool,
302 short_date: bool,
303 long_date: bool,
304 day_of_week: bool,
305 },
306 Unknown,
307}
308
309#[cfg(not(feature = "html5ever"))]
310impl HtmlTag {
311 fn closes_with(&self, tag_name: &str) -> bool {
314 match self {
315 Self::Bold => matches!(tag_name, "b" | "strong"),
316 Self::Italic => matches!(tag_name, "i" | "em"),
317 Self::Underline => matches!(tag_name, "u" | "ins"),
318 Self::Strike => matches!(tag_name, "s" | "del" | "strike"),
319 Self::Spoiler => tag_name == "tg-spoiler",
320 Self::SpanSpoiler => tag_name == "span",
321 Self::Code => tag_name == "code",
322 Self::CodeInPre => false,
323 Self::Pre => tag_name == "pre",
324 Self::Link(_) => tag_name == "a",
325 Self::CustomEmoji(_) => tag_name == "tg-emoji",
326 Self::Blockquote { .. } => tag_name == "blockquote",
327 Self::TgTime { .. } => tag_name == "tg-time",
328 Self::Unknown => false,
329 }
330 }
331}
332
333#[cfg(feature = "html5ever")]
340#[cfg_attr(docsrs, doc(cfg(feature = "html5ever")))]
341pub fn parse_html(html: &str) -> (String, Vec<tl::enums::MessageEntity>) {
342 use html5ever::tendril::StrTendril;
343 use html5ever::tokenizer::{
344 BufferQueue, Tag, TagKind, Token, TokenSink, TokenSinkResult, Tokenizer,
345 };
346 use std::cell::Cell;
347
348 struct Sink {
349 text: Cell<String>,
350 entities: Cell<Vec<tl::enums::MessageEntity>>,
351 offset: Cell<i32>,
352 }
353
354 impl TokenSink for Sink {
355 type Handle = ();
356
357 fn process_token(&self, token: Token, _line: u64) -> TokenSinkResult<()> {
358 let mut text = self.text.take();
359 let mut entities = self.entities.take();
360 let mut offset = self.offset.get();
361
362 macro_rules! close_ent {
363 ($kind:ident) => {{
364 if let Some(idx) = entities
365 .iter()
366 .rposition(|e| matches!(e, tl::enums::MessageEntity::$kind(_)))
367 {
368 let closed_len = {
369 if let tl::enums::MessageEntity::$kind(ref mut inner) = entities[idx] {
370 inner.length = offset - inner.offset;
371 inner.length
372 } else {
373 unreachable!()
374 }
375 };
376 if closed_len == 0 {
377 entities.remove(idx);
378 }
379 }
380 }};
381 }
382
383 match token {
384 Token::TagToken(Tag {
385 kind: TagKind::StartTag,
386 name,
387 attrs,
388 ..
389 }) => {
390 let len0 = 0i32;
391 match name.as_ref() {
392 "b" | "strong" => entities.push(tl::enums::MessageEntity::Bold(
393 tl::types::MessageEntityBold {
394 offset,
395 length: len0,
396 },
397 )),
398 "i" | "em" => entities.push(tl::enums::MessageEntity::Italic(
399 tl::types::MessageEntityItalic {
400 offset,
401 length: len0,
402 },
403 )),
404 "u" | "ins" => entities.push(tl::enums::MessageEntity::Underline(
405 tl::types::MessageEntityUnderline {
406 offset,
407 length: len0,
408 },
409 )),
410 "s" | "del" | "strike" => entities.push(tl::enums::MessageEntity::Strike(
411 tl::types::MessageEntityStrike {
412 offset,
413 length: len0,
414 },
415 )),
416 "tg-spoiler" => entities.push(tl::enums::MessageEntity::Spoiler(
417 tl::types::MessageEntitySpoiler {
418 offset,
419 length: len0,
420 },
421 )),
422 "span" => {
423 let is_spoiler = attrs.iter().any(|a| {
424 a.name.local.as_ref() == "class" && a.value.as_ref() == "tg-spoiler"
425 });
426 if is_spoiler {
427 entities.push(tl::enums::MessageEntity::Spoiler(
428 tl::types::MessageEntitySpoiler {
429 offset,
430 length: len0,
431 },
432 ));
433 }
434 }
435 "blockquote" => {
436 let collapsed =
437 attrs.iter().any(|a| a.name.local.as_ref() == "expandable");
438 entities.push(tl::enums::MessageEntity::Blockquote(
439 tl::types::MessageEntityBlockquote {
440 collapsed,
441 offset,
442 length: len0,
443 },
444 ));
445 }
446 "tg-time" => {
447 let unix: i32 = attrs
448 .iter()
449 .find(|a| a.name.local.as_ref() == "unix")
450 .and_then(|a| a.value.as_ref().parse::<i32>().ok())
451 .unwrap_or(0);
452 let fmt = attrs
453 .iter()
454 .find(|a| a.name.local.as_ref() == "format")
455 .map(|a| a.value.as_ref().to_string())
456 .unwrap_or_default();
457 let (
458 relative,
459 short_time,
460 long_time,
461 short_date,
462 long_date,
463 day_of_week,
464 ) = parse_tg_time_format(&fmt);
465 entities.push(tl::enums::MessageEntity::FormattedDate(
466 tl::types::MessageEntityFormattedDate {
467 relative,
468 short_time,
469 long_time,
470 short_date,
471 long_date,
472 day_of_week,
473 offset,
474 length: len0,
475 date: unix,
476 },
477 ));
478 }
479 "code" => {
480 let in_pre = entities.last().map_or(
481 false,
482 |e| matches!(e, tl::enums::MessageEntity::Pre(p) if p.length == 0),
483 );
484 if in_pre {
485 let lang = attrs
486 .iter()
487 .find(|a| a.name.local.as_ref() == "class")
488 .and_then(|a| {
489 let v: &str = a.value.as_ref();
490 v.strip_prefix("language-")
491 })
492 .map(|s| s.to_string())
493 .unwrap_or_default();
494 if let Some(tl::enums::MessageEntity::Pre(p)) = entities.last_mut()
495 {
496 p.language = lang;
497 }
498 } else {
499 entities.push(tl::enums::MessageEntity::Code(
500 tl::types::MessageEntityCode {
501 offset,
502 length: len0,
503 },
504 ));
505 }
506 }
507 "pre" => entities.push(tl::enums::MessageEntity::Pre(
508 tl::types::MessageEntityPre {
509 offset,
510 length: len0,
511 language: String::new(),
512 },
513 )),
514 "a" => {
515 let href = attrs
516 .iter()
517 .find(|a| a.name.local.as_ref() == "href")
518 .map(|a| {
519 let v: &str = a.value.as_ref();
520 v.to_string()
521 })
522 .unwrap_or_default();
523 const MENTION_PFX: &str = "tg://user?id=";
524 if href.starts_with(MENTION_PFX) {
525 if let Ok(uid) = href[MENTION_PFX.len()..].parse::<i64>() {
526 entities.push(tl::enums::MessageEntity::MentionName(
527 tl::types::MessageEntityMentionName {
528 offset,
529 length: len0,
530 user_id: uid,
531 },
532 ));
533 }
534 } else {
535 entities.push(tl::enums::MessageEntity::TextUrl(
536 tl::types::MessageEntityTextUrl {
537 offset,
538 length: len0,
539 url: href,
540 },
541 ));
542 }
543 }
544 "tg-emoji" => {
545 let doc_id = attrs
546 .iter()
547 .find(|a| a.name.local.as_ref() == "emoji-id")
548 .and_then(|a| {
549 let v: &str = a.value.as_ref();
550 v.parse::<i64>().ok()
551 })
552 .unwrap_or(0);
553 entities.push(tl::enums::MessageEntity::CustomEmoji(
554 tl::types::MessageEntityCustomEmoji {
555 offset,
556 length: len0,
557 document_id: doc_id,
558 },
559 ));
560 }
561 "br" => {
562 text.push('\n');
563 offset += 1;
564 }
565 _ => {}
566 }
567 }
568 Token::TagToken(Tag {
569 kind: TagKind::EndTag,
570 name,
571 ..
572 }) => match name.as_ref() {
573 "b" | "strong" => close_ent!(Bold),
574 "i" | "em" => close_ent!(Italic),
575 "u" | "ins" => close_ent!(Underline),
576 "s" | "del" | "strike" => close_ent!(Strike),
577 "tg-spoiler" | "span" => close_ent!(Spoiler),
578 "blockquote" => close_ent!(Blockquote),
579 "tg-time" => close_ent!(FormattedDate),
580 "code" => {
581 let in_pre = entities.last().map_or(
582 false,
583 |e| matches!(e, tl::enums::MessageEntity::Pre(p) if p.length == 0),
584 );
585 if !in_pre {
586 close_ent!(Code);
587 }
588 }
589 "pre" => close_ent!(Pre),
590 "a" => match entities.last() {
591 Some(tl::enums::MessageEntity::MentionName(_)) => close_ent!(MentionName),
592 _ => close_ent!(TextUrl),
593 },
594 "tg-emoji" => close_ent!(CustomEmoji),
595 _ => {}
596 },
597 Token::CharacterTokens(s) => {
598 let s_str: &str = s.as_ref();
599 offset += s_str.encode_utf16().count() as i32;
600 text.push_str(s_str);
601 }
602 _ => {}
603 }
604
605 self.text.replace(text);
606 self.entities.replace(entities);
607 self.offset.replace(offset);
608 TokenSinkResult::Continue
609 }
610 }
611
612 let mut input = BufferQueue::default();
613 input.push_back(StrTendril::from_slice(html).try_reinterpret().unwrap());
614 let tok = Tokenizer::new(
615 Sink {
616 text: Cell::new(String::with_capacity(html.len())),
617 entities: Cell::new(Vec::new()),
618 offset: Cell::new(0),
619 },
620 Default::default(),
621 );
622 let _ = tok.feed(&mut input);
623 tok.end();
624 let Sink { text, entities, .. } = tok.sink;
625 (text.take(), entities.take())
626}
627
628pub fn generate_html(text: &str, entities: &[tl::enums::MessageEntity]) -> String {
635 use tl::enums::MessageEntity as ME;
636
637 let mut markers: Vec<(i32, bool, String)> = Vec::new();
638
639 for ent in entities {
640 let (off, len, open, close) = match ent {
641 ME::Bold(e) => (e.offset, e.length, "<b>".into(), "</b>".into()),
642 ME::Italic(e) => (e.offset, e.length, "<i>".into(), "</i>".into()),
643 ME::Underline(e) => (e.offset, e.length, "<u>".into(), "</u>".into()),
644 ME::Strike(e) => (e.offset, e.length, "<s>".into(), "</s>".into()),
645 ME::Spoiler(e) => (
646 e.offset,
647 e.length,
648 "<tg-spoiler>".into(),
649 "</tg-spoiler>".into(),
650 ),
651 ME::Code(e) => (e.offset, e.length, "<code>".into(), "</code>".into()),
652 ME::Pre(e) => {
653 let lang = if e.language.is_empty() {
654 String::new()
655 } else {
656 format!(" class=\"language-{}\"", e.language)
657 };
658 (
659 e.offset,
660 e.length,
661 format!("<pre><code{lang}>"),
662 "</code></pre>".into(),
663 )
664 }
665 ME::TextUrl(e) => (
666 e.offset,
667 e.length,
668 format!("<a href=\"{}\">", escape_html(&e.url)),
669 "</a>".into(),
670 ),
671 ME::MentionName(e) => (
672 e.offset,
673 e.length,
674 format!("<a href=\"tg://user?id={}\">", e.user_id),
675 "</a>".into(),
676 ),
677 ME::CustomEmoji(e) => (
678 e.offset,
679 e.length,
680 format!("<tg-emoji emoji-id=\"{}\">", e.document_id),
681 "</tg-emoji>".into(),
682 ),
683 ME::Blockquote(e) => {
684 let open = if e.collapsed {
685 "<blockquote expandable>".to_string()
686 } else {
687 "<blockquote>".to_string()
688 };
689 (e.offset, e.length, open, "</blockquote>".into())
690 }
691 ME::FormattedDate(e) => {
692 let fmt = tg_time_flags_to_format(e);
693 (
694 e.offset,
695 e.length,
696 format!("<tg-time unix=\"{}\" format=\"{}\">", e.date, fmt),
697 "</tg-time>".into(),
698 )
699 }
700 _ => continue,
701 };
702 markers.push((off, true, open));
703 markers.push((off + len, false, close));
704 }
705
706 markers.sort_by(|(a_pos, a_open, _), (b_pos, b_open, _)| {
707 a_pos.cmp(b_pos).then_with(|| b_open.cmp(a_open))
708 });
709
710 let mut result =
711 String::with_capacity(text.len() + markers.iter().map(|(_, _, s)| s.len()).sum::<usize>());
712 let mut marker_idx = 0;
713 let mut utf16_pos: i32 = 0;
714
715 for ch in text.chars() {
716 while marker_idx < markers.len() && markers[marker_idx].0 <= utf16_pos {
717 result.push_str(&markers[marker_idx].2);
718 marker_idx += 1;
719 }
720 match ch {
721 '&' => result.push_str("&"),
722 '<' => result.push_str("<"),
723 '>' => result.push_str(">"),
724 '"' => result.push_str("""),
725 c => result.push(c),
726 }
727 utf16_pos += ch.len_utf16() as i32;
728 }
729 while marker_idx < markers.len() {
730 result.push_str(&markers[marker_idx].2);
731 marker_idx += 1;
732 }
733
734 result
735}
736
737fn escape_html(s: &str) -> String {
738 s.replace('&', "&")
739 .replace('<', "<")
740 .replace('>', ">")
741 .replace('"', """)
742}