1use 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 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 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 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 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 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 let lower = rem.to_ascii_lowercase();
456 let is_block = is_block_html_tag(&lower);
457 if is_block {
458 break;
459 }
460 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
483pub 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 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 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 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 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}