Skip to main content

ferogram_parsers/
rich_html.rs

1// Copyright (c) Ankit Chaubey <ankitchaubey.dev@gmail.com>
2//
3// ferogram: async Telegram MTProto client in Rust
4// https://github.com/ankit-chaubey/ferogram
5//
6// Licensed under either the MIT License or the Apache License 2.0.
7// See the LICENSE-MIT or LICENSE-APACHE file in this repository:
8// https://github.com/ankit-chaubey/ferogram
9//
10// Feel free to use, modify, and share this code.
11// Please keep this notice when redistributing.
12
13use ferogram_tl_types as tl;
14
15use crate::rich_common::*;
16
17pub fn parse_rich_html(html: &str) -> Vec<tl::enums::PageBlock> {
18    RichHtmlParser::new(html).parse()
19}
20
21struct RichHtmlParser {
22    html: String,
23    pos: usize,
24}
25
26impl RichHtmlParser {
27    fn new(html: &str) -> Self {
28        Self {
29            html: html.to_string(),
30            pos: 0,
31        }
32    }
33
34    fn remaining(&self) -> &str {
35        &self.html[self.pos..]
36    }
37
38    fn skip_whitespace(&mut self) {
39        while self.pos < self.html.len() {
40            let c = self.html.as_bytes()[self.pos];
41            if c.is_ascii_whitespace() {
42                self.pos += 1;
43            } else {
44                break;
45            }
46        }
47    }
48
49    fn parse(mut self) -> Vec<tl::enums::PageBlock> {
50        let mut blocks = Vec::new();
51        loop {
52            self.skip_whitespace();
53            if self.pos >= self.html.len() {
54                break;
55            }
56            if self.remaining().starts_with('<')
57                && let Some(block) = self.try_parse_block_tag()
58            {
59                blocks.extend(block);
60                continue;
61            }
62            // Text content at top level → paragraph
63            if let Some(para) = self.parse_text_paragraph() {
64                blocks.push(para);
65            }
66        }
67        blocks
68    }
69
70    fn try_parse_block_tag(&mut self) -> Option<Vec<tl::enums::PageBlock>> {
71        let rem = self.remaining();
72        let lower = rem.to_ascii_lowercase();
73
74        macro_rules! heading {
75            ($tag:literal, $level:expr) => {
76                if lower.starts_with(concat!("<", $tag, ">"))
77                    || lower.starts_with(concat!("<", $tag, " "))
78                {
79                    let body = self.consume_tag($tag)?;
80                    return Some(vec![heading_block($level, parse_rich_html_inline(&body))]);
81                }
82            };
83        }
84
85        heading!("h1", 1);
86        heading!("h2", 2);
87        heading!("h3", 3);
88        heading!("h4", 4);
89        heading!("h5", 5);
90        heading!("h6", 6);
91
92        if lower.starts_with("<p>") || lower.starts_with("<p ") {
93            let body = self.consume_tag("p")?;
94            return Some(vec![tl::enums::PageBlock::Paragraph(
95                tl::types::PageBlockParagraph {
96                    text: parse_rich_html_inline(&body),
97                },
98            )]);
99        }
100
101        if lower.starts_with("<pre>") || lower.starts_with("<pre>") || lower.starts_with("<pre ") {
102            let body = self.consume_tag("pre")?;
103            let (lang, code) = extract_pre_content_from_body(&body);
104            return Some(vec![tl::enums::PageBlock::Preformatted(
105                tl::types::PageBlockPreformatted {
106                    text: rt_plain(code),
107                    language: lang,
108                },
109            )]);
110        }
111
112        if lower.starts_with("<footer>") || lower.starts_with("<footer ") {
113            let body = self.consume_tag("footer")?;
114            return Some(vec![tl::enums::PageBlock::Footer(
115                tl::types::PageBlockFooter {
116                    text: parse_rich_html_inline(&body),
117                },
118            )]);
119        }
120
121        if lower.starts_with("<hr") {
122            self.consume_until('>');
123            self.pos += 1;
124            return Some(vec![tl::enums::PageBlock::Divider]);
125        }
126
127        if lower.starts_with("<blockquote") {
128            let body = self.consume_tag("blockquote")?;
129            let (text, credit) = split_cite(&body);
130            return Some(vec![tl::enums::PageBlock::Blockquote(
131                tl::types::PageBlockBlockquote {
132                    text: parse_rich_html_inline(&text),
133                    caption: parse_rich_html_inline(&credit),
134                },
135            )]);
136        }
137
138        if lower.starts_with("<aside") {
139            let body = self.consume_tag("aside")?;
140            let (text, credit) = split_cite(&body);
141            return Some(vec![tl::enums::PageBlock::Pullquote(
142                tl::types::PageBlockPullquote {
143                    text: parse_rich_html_inline(&text),
144                    caption: parse_rich_html_inline(&credit),
145                },
146            )]);
147        }
148
149        if lower.starts_with("<ul") {
150            let body = self.consume_tag("ul")?;
151            let items = parse_html_list_items(&body, false);
152            return Some(vec![tl::enums::PageBlock::List(tl::types::PageBlockList {
153                items,
154            })]);
155        }
156
157        if lower.starts_with("<ol") {
158            let tag_open = rem.split('>').next().unwrap_or("").to_string();
159            let (_, attrs) = parse_tag(tag_open.trim_start_matches('<'));
160            let start: Option<i32> = attrs
161                .iter()
162                .find(|(k, _)| k == "start")
163                .and_then(|(_, v)| v.parse().ok());
164            let reversed = attrs.iter().any(|(k, _)| k == "reversed");
165            let ol_type: Option<String> = attrs
166                .iter()
167                .find(|(k, _)| k == "type")
168                .map(|(_, v)| v.clone());
169            let body = self.consume_tag("ol")?;
170            let items = parse_html_ordered_list_items(&body, ol_type.as_deref());
171            return Some(vec![tl::enums::PageBlock::OrderedList(
172                tl::types::PageBlockOrderedList {
173                    reversed,
174                    items,
175                    start,
176                    r#type: ol_type,
177                },
178            )]);
179        }
180
181        if lower.starts_with("<table") {
182            let tag_open = rem.split('>').next().unwrap_or("").to_string();
183            let (_, attrs) = parse_tag(tag_open.trim_start_matches('<'));
184            let bordered = attrs.iter().any(|(k, _)| k == "bordered");
185            let striped = attrs.iter().any(|(k, _)| k == "striped");
186            let body = self.consume_tag("table")?;
187            let (title, rows) = parse_html_table(&body);
188            return Some(vec![tl::enums::PageBlock::Table(
189                tl::types::PageBlockTable {
190                    bordered,
191                    striped,
192                    title,
193                    rows,
194                },
195            )]);
196        }
197
198        if lower.starts_with("<details") {
199            let is_open_hint = rem.to_ascii_lowercase().starts_with("<details open");
200            let full = self.consume_tag("details")?;
201            let is_open = is_open_hint || full.starts_with("open");
202            let summary = extract_between(&full, "<summary>", "</summary>").unwrap_or_default();
203            let body_start = full
204                .find("</summary>")
205                .map(|i| i + "</summary>".len())
206                .unwrap_or(full.len());
207            let inner = parse_rich_html(full[body_start..].trim());
208            return Some(vec![tl::enums::PageBlock::Details(
209                tl::types::PageBlockDetails {
210                    open: is_open,
211                    blocks: inner,
212                    title: parse_rich_html_inline(&summary),
213                },
214            )]);
215        }
216
217        if lower.starts_with("<img ") {
218            let tag_raw = self.consume_self_closing_tag();
219            let (_, attrs) = parse_tag(&tag_raw);
220            let src = attrs
221                .iter()
222                .find(|(k, _)| k == "src")
223                .map(|(_, v)| v.clone())
224                .unwrap_or_default();
225            let spoiler = attrs.iter().any(|(k, _)| k == "tg-spoiler");
226            if !src.is_empty() {
227                return Some(vec![media_block(&src, empty_caption(), spoiler)]);
228            }
229            return Some(vec![]);
230        }
231
232        if lower.starts_with("<video ") {
233            let tag_raw = self.consume_self_closing_or_pair("video");
234            let (_, attrs) = parse_tag(&tag_raw);
235            let src = attrs
236                .iter()
237                .find(|(k, _)| k == "src")
238                .map(|(_, v)| v.clone())
239                .unwrap_or_default();
240            let spoiler = attrs.iter().any(|(k, _)| k == "tg-spoiler");
241            if !src.is_empty() {
242                return Some(vec![media_block(&src, empty_caption(), spoiler)]);
243            }
244            return Some(vec![]);
245        }
246
247        if lower.starts_with("<audio ") {
248            let tag_raw = self.consume_self_closing_or_pair("audio");
249            let (_, attrs) = parse_tag(&tag_raw);
250            let src = attrs
251                .iter()
252                .find(|(k, _)| k == "src")
253                .map(|(_, v)| v.clone())
254                .unwrap_or_default();
255            if !src.is_empty() {
256                return Some(vec![media_block(&src, empty_caption(), false)]);
257            }
258            return Some(vec![]);
259        }
260
261        if lower.starts_with("<figure") {
262            let body = self.consume_tag("figure")?;
263            let caption_raw =
264                extract_between(&body, "<figcaption>", "</figcaption>").unwrap_or_default();
265            let (cap_t, cap_cr) = split_cite(&caption_raw);
266            let cap = if cap_t.is_empty() {
267                empty_caption()
268            } else {
269                caption_text_credit(
270                    parse_rich_html_inline(&cap_t),
271                    parse_rich_html_inline(&cap_cr),
272                )
273            };
274            let spoiler = body.contains("tg-spoiler");
275
276            if body.to_ascii_lowercase().contains("<tg-map") {
277                let map_inner = extract_between(&body, "<tg-map", "/>").unwrap_or_default();
278                let (_, attrs) = parse_tag(&format!("tg-map {map_inner}"));
279                let lat: f64 = attrs
280                    .iter()
281                    .find(|(k, _)| k == "lat")
282                    .and_then(|(_, v)| v.parse().ok())
283                    .unwrap_or(0.0);
284                let long: f64 = attrs
285                    .iter()
286                    .find(|(k, _)| k == "long")
287                    .and_then(|(_, v)| v.parse().ok())
288                    .unwrap_or(0.0);
289                let zoom: i32 = attrs
290                    .iter()
291                    .find(|(k, _)| k == "zoom")
292                    .and_then(|(_, v)| v.parse().ok())
293                    .unwrap_or(15);
294                return Some(vec![tl::enums::PageBlock::Map(tl::types::PageBlockMap {
295                    geo: tl::enums::GeoPoint::GeoPoint(tl::types::GeoPoint {
296                        lat,
297                        long,
298                        access_hash: 0,
299                        accuracy_radius: None,
300                    }),
301                    zoom,
302                    w: 400,
303                    h: 300,
304                    caption: cap,
305                })]);
306            }
307
308            let src = extract_src_from_figure(&body);
309            if let Some(url) = src {
310                return Some(vec![media_block(&url, cap, spoiler)]);
311            }
312            return Some(vec![]);
313        }
314
315        if lower.starts_with("<tg-collage") {
316            let body = self.consume_tag("tg-collage")?;
317            let (items, cap) = extract_collage_items(&body);
318            return Some(vec![tl::enums::PageBlock::Collage(
319                tl::types::PageBlockCollage {
320                    items,
321                    caption: cap.unwrap_or_else(empty_caption),
322                },
323            )]);
324        }
325
326        if lower.starts_with("<tg-slideshow") {
327            let body = self.consume_tag("tg-slideshow")?;
328            let (items, cap) = extract_collage_items(&body);
329            return Some(vec![tl::enums::PageBlock::Slideshow(
330                tl::types::PageBlockSlideshow {
331                    items,
332                    caption: cap.unwrap_or_else(empty_caption),
333                },
334            )]);
335        }
336
337        if lower.starts_with("<tg-map") {
338            let tag_raw = self.consume_self_closing_tag();
339            let (_, attrs) = parse_tag(&tag_raw);
340            let lat: f64 = attrs
341                .iter()
342                .find(|(k, _)| k == "lat")
343                .and_then(|(_, v)| v.parse().ok())
344                .unwrap_or(0.0);
345            let long: f64 = attrs
346                .iter()
347                .find(|(k, _)| k == "long")
348                .and_then(|(_, v)| v.parse().ok())
349                .unwrap_or(0.0);
350            let zoom: i32 = attrs
351                .iter()
352                .find(|(k, _)| k == "zoom")
353                .and_then(|(_, v)| v.parse().ok())
354                .unwrap_or(15);
355            return Some(vec![tl::enums::PageBlock::Map(tl::types::PageBlockMap {
356                geo: tl::enums::GeoPoint::GeoPoint(tl::types::GeoPoint {
357                    lat,
358                    long,
359                    access_hash: 0,
360                    accuracy_radius: None,
361                }),
362                zoom,
363                w: 400,
364                h: 300,
365                caption: empty_caption(),
366            })]);
367        }
368
369        if lower.starts_with("<tg-math-block") {
370            let body = self.consume_tag("tg-math-block")?;
371            return Some(vec![tl::enums::PageBlock::Math(tl::types::PageBlockMath {
372                source: body,
373            })]);
374        }
375
376        if lower.starts_with("<a ") && lower.contains("name=") {
377            // Standalone anchor: <a name="id"></a>
378            let tag_raw = self.consume_self_closing_or_pair("a");
379            let (_, attrs) = parse_tag(&tag_raw);
380            let name = attrs
381                .iter()
382                .find(|(k, _)| k == "name")
383                .map(|(_, v)| v.clone())
384                .unwrap_or_default();
385            if !name.is_empty() {
386                return Some(vec![tl::enums::PageBlock::Anchor(
387                    tl::types::PageBlockAnchor { name },
388                )]);
389            }
390            return Some(vec![]);
391        }
392
393        // Skip unknown/comment/doctype tags
394        if lower.starts_with("<!--") || lower.starts_with("<!") {
395            self.consume_until('>');
396            self.pos = (self.pos + 1).min(self.html.len());
397            return Some(vec![]);
398        }
399
400        None
401    }
402
403    fn consume_tag(&mut self, tag: &str) -> Option<String> {
404        // Move past the opening tag
405        let open_end = self.remaining().find('>')?;
406        self.pos += open_end + 1;
407        let close_tag = format!("</{tag}>");
408        let close_pos = self.remaining().to_ascii_lowercase().find(&close_tag)?;
409        let body = self.remaining()[..close_pos].to_string();
410        self.pos += close_pos + close_tag.len();
411        Some(body)
412    }
413
414    fn consume_self_closing_tag(&mut self) -> String {
415        let end = self.remaining().find('>').unwrap_or(self.remaining().len());
416        let tag_raw = self.remaining()[1..end]
417            .trim_end_matches('/')
418            .trim()
419            .to_string();
420        self.pos += end + 1;
421        tag_raw
422    }
423
424    fn consume_self_closing_or_pair(&mut self, tag: &str) -> String {
425        let rem = self.remaining();
426        // Check if it's self-closing or has a close tag in the same stretch
427        let open_end = rem.find('>').unwrap_or(rem.len());
428        let is_self = rem[..open_end].ends_with('/');
429        let tag_raw = rem[1..open_end].trim_end_matches('/').trim().to_string();
430        self.pos += open_end + 1;
431        if !is_self {
432            let close_tag = format!("</{tag}>");
433            if let Some(end) = self.remaining().to_ascii_lowercase().find(&close_tag) {
434                self.pos += end + close_tag.len();
435            }
436        }
437        tag_raw
438    }
439
440    fn consume_until(&mut self, ch: char) {
441        while self.pos < self.html.len() {
442            if self.html.as_bytes()[self.pos] == ch as u8 {
443                break;
444            }
445            self.pos += 1;
446        }
447    }
448
449    fn parse_text_paragraph(&mut self) -> Option<tl::enums::PageBlock> {
450        let start = self.pos;
451        while self.pos < self.html.len() {
452            let rem = self.remaining();
453            if rem.starts_with('<') {
454                // Peek at the tag: if it's a block tag, stop
455                let lower = rem.to_ascii_lowercase();
456                let is_block = is_block_html_tag(&lower);
457                if is_block {
458                    break;
459                }
460                // Inline tag: include it as-is and continue
461                let end = rem.find('>').unwrap_or(rem.len());
462                self.pos += end + 1;
463            } else {
464                self.pos += 1;
465            }
466        }
467        if self.pos == start {
468            return None;
469        }
470        let text_raw = &self.html[start..self.pos];
471        let decoded = decode_html_entities(text_raw);
472        if decoded.trim().is_empty() {
473            return None;
474        }
475        Some(tl::enums::PageBlock::Paragraph(
476            tl::types::PageBlockParagraph {
477                text: parse_rich_html_inline(&decoded),
478            },
479        ))
480    }
481}
482
483/// Parse an HTML inline string into a `RichText` tree.
484/// Handles all inline tags: b, strong, i, em, u, ins, s, del, strike,
485/// code, mark, tg-spoiler, sub, sup, a, tg-emoji, tg-time, tg-math, tg-reference.
486pub fn parse_rich_html_inline(html: &str) -> tl::enums::RichText {
487    let chars: Vec<char> = html.chars().collect();
488    let mut parts = Vec::new();
489    let mut buf = String::new();
490    let mut i = 0;
491    let n = chars.len();
492
493    macro_rules! flush {
494        () => {
495            if !buf.is_empty() {
496                parts.push(rt_plain(decode_html_entities(&std::mem::take(&mut buf))));
497            }
498        };
499    }
500
501    while i < n {
502        if chars[i] == '&' {
503            // HTML entity: collect until `;`
504            let mut j = i + 1;
505            while j < n && chars[j] != ';' && chars[j] != ' ' {
506                j += 1;
507            }
508            if j < n && chars[j] == ';' {
509                let entity: String = chars[i..=j].iter().collect();
510                buf.push_str(&decode_html_entities(&entity));
511                i = j + 1;
512                continue;
513            }
514        }
515
516        if chars[i] != '<' {
517            buf.push(chars[i]);
518            i += 1;
519            continue;
520        }
521
522        // Try to parse as inline HTML tag
523        let remaining: String = chars[i..].iter().collect();
524        if let Some((consumed, rt)) = try_parse_html_inline_tag(&chars, i, n) {
525            flush!();
526            parts.push(rt);
527            i = consumed;
528            continue;
529        }
530
531        // Not recognised - emit as text
532        buf.push(chars[i]);
533        i += 1;
534        let _ = remaining;
535    }
536    flush!();
537    rt_concat(parts)
538}
539
540fn extract_pre_content_from_body(body: &str) -> (String, String) {
541    // <code class="language-X">…</code>
542    let lo = body.to_ascii_lowercase();
543    if lo.contains("<code") {
544        let lang = extract_between(body, "class=\"language-", "\"").unwrap_or_default();
545        let code_start = lo.find('>').map(|i| i + 1).unwrap_or(0);
546        let code = extract_between(body, ">", "</code>")
547            .or_else(|| {
548                extract_between(body, "<code", "</code>").map(|c| {
549                    let ci = c.find('>').map(|i| i + 1).unwrap_or(0);
550                    c[ci..].to_string()
551                })
552            })
553            .unwrap_or_else(|| body[code_start..].to_string());
554        return (lang, decode_html_entities(&code));
555    }
556    (String::new(), decode_html_entities(body))
557}