Skip to main content

ferogram_parsers/
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 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                // </code> inside <pre>: just pop the CodeInPre marker
49                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                // Open tag
179                let htag: HtmlTag = match tag_name {
180                    "b" | "strong" => HtmlTag::Bold,
181                    "i" | "em" => HtmlTag::Italic,
182                    "u" | "ins" => HtmlTag::Underline, // <ins> is the underline alias
183                    "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                        // Inside open <pre>: annotate language, push CodeInPre sentinel
221                        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/// Parse HTML attributes including boolean attributes (e.g. `expandable`).
279#[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    /// Returns true if this open tag is closed by the given close-tag name.
312    /// Handles all Telegram HTML aliases: strong/b, em/i, ins/u, del|strike/s.
313    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// HTML parser: html5ever backend
334
335/// Parse a Telegram-compatible HTML string into `(plain_text, entities)`.
336///
337/// Uses the [`html5ever`] spec-compliant tokenizer.
338/// Enable the `html5ever` Cargo feature to activate this implementation.
339#[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
628// HTML generator (always available, no html5ever dependency)
629
630/// Generate Telegram-compatible HTML from plain text + entities.
631///
632/// All entity types are handled including `Blockquote`, `FormattedDate`
633/// (`<tg-time>`), and `CustomEmoji` (`<tg-emoji>`).
634pub 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("&amp;"),
722            '<' => result.push_str("&lt;"),
723            '>' => result.push_str("&gt;"),
724            '"' => result.push_str("&quot;"),
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('&', "&amp;")
739        .replace('<', "&lt;")
740        .replace('>', "&gt;")
741        .replace('"', "&quot;")
742}