Skip to main content

ebook_rs/
section.rs

1use crate::archive::{EpubArchive, resolve_relative_path};
2use crate::layout::AssetDeliveryStrategy;
3use base64::Engine;
4use serde::{Deserialize, Serialize};
5
6/// Loaded and processed EPUB section ready for reading.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Section {
9    pub index: usize,
10    pub idref: String,
11    pub href: String,
12    pub full_path: String,
13    pub raw_html: String,
14    pub processed_html: String,
15    pub plain_text: String,
16    pub plain_text_lower: String, // P4: Pre-computed lowercased text for zero-alloc search
17    pub char_count: usize,
18    pub viewport_width: Option<f64>,  // FXL viewport target width
19    pub viewport_height: Option<f64>, // FXL viewport target height
20}
21
22/// Tokenized word entry with character range offsets for SpeechSynthesis TTS word synchronization.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct TtsWordToken {
25    pub index: usize,
26    pub word: String,
27    pub char_start: usize,
28    pub char_end: usize,
29}
30
31impl Section {
32    /// Create and process a section from archive content using default Base64 inlining.
33    pub fn new(
34        index: usize,
35        idref: String,
36        href: String,
37        full_path: String,
38        archive: &EpubArchive,
39    ) -> Result<Self, String> {
40        Self::new_with_strategy(
41            index,
42            idref,
43            href,
44            full_path,
45            archive,
46            &AssetDeliveryStrategy::InlinedBase64,
47        )
48    }
49
50    /// Create and process a section using a specific asset delivery strategy (Base64 vs Resource Stream).
51    pub fn new_with_strategy(
52        index: usize,
53        idref: String,
54        href: String,
55        full_path: String,
56        archive: &EpubArchive,
57        _strategy: &AssetDeliveryStrategy,
58    ) -> Result<Self, String> {
59        let raw_html = archive.read_string(&full_path)?;
60        let plain_text = extract_plain_text(&raw_html);
61        let plain_text_lower = plain_text.to_lowercase();
62        let char_count = plain_text.chars().count();
63        let (viewport_width, viewport_height) = parse_viewport_meta(&raw_html);
64
65        Ok(Self {
66            index,
67            idref,
68            href,
69            full_path,
70            processed_html: raw_html.clone(),
71            raw_html,
72            plain_text,
73            plain_text_lower,
74            char_count,
75            viewport_width,
76            viewport_height,
77        })
78    }
79
80    /// Detects the primary language of this section's plain text content using `whatlang`.
81    pub fn detect_language(&self) -> Option<String> {
82        if self.plain_text.len() >= 30 {
83            whatlang::detect(&self.plain_text).map(|info| info.lang().code().to_string())
84        } else {
85            None
86        }
87    }
88
89    /// Tokenizes plain text into word tokens with exact character start/end offsets for SpeechSynthesis TTS synchronization.
90    pub fn tokenize_tts_words(&self) -> Vec<TtsWordToken> {
91        let mut tokens = Vec::new();
92        let mut word_index = 0;
93        let mut char_offset = 0;
94        let plain_chars: Vec<char> = self.plain_text.chars().collect();
95
96        let mut i = 0;
97        while i < plain_chars.len() {
98            if plain_chars[i].is_whitespace() {
99                i += 1;
100                char_offset += 1;
101                continue;
102            }
103
104            let start_char = char_offset;
105            let mut word = String::new();
106            while i < plain_chars.len() && !plain_chars[i].is_whitespace() {
107                word.push(plain_chars[i]);
108                i += 1;
109                char_offset += 1;
110            }
111
112            tokens.push(TtsWordToken {
113                index: word_index,
114                word,
115                char_start: start_char,
116                char_end: char_offset,
117            });
118            word_index += 1;
119        }
120
121        tokens
122    }
123
124    /// Wraps plain text words in the processed HTML with `<span id="tts-w-{index}" class="tts-word">` for live SpeechSynthesis word-by-word visual highlighting.
125    pub fn to_tts_annotated_html(&self) -> String {
126        let tokens = self.tokenize_tts_words();
127        if tokens.is_empty() {
128            return self.processed_html.clone();
129        }
130
131        let mut annotated = String::with_capacity(self.processed_html.len() + tokens.len() * 50);
132        let mut token_idx = 0;
133        let mut in_tag = false;
134        let mut in_quote: Option<char> = None;
135
136        let html_chars: Vec<char> = self.processed_html.chars().collect();
137        let mut i = 0;
138
139        let token_char_vecs: Vec<Vec<char>> =
140            tokens.iter().map(|t| t.word.chars().collect()).collect();
141
142        while i < html_chars.len() {
143            let ch = html_chars[i];
144            if !in_tag && ch == '<' {
145                in_tag = true;
146                in_quote = None;
147                annotated.push(ch);
148                i += 1;
149                continue;
150            }
151
152            if in_tag {
153                annotated.push(ch);
154                if let Some(q) = in_quote {
155                    if ch == q {
156                        in_quote = None;
157                    }
158                } else if ch == '"' || ch == '\'' {
159                    in_quote = Some(ch);
160                } else if ch == '>' {
161                    in_tag = false;
162                }
163                i += 1;
164                continue;
165            }
166
167            if token_idx < tokens.len() {
168                let token = &tokens[token_idx];
169                let token_chars = &token_char_vecs[token_idx];
170                let t_len = token_chars.len();
171
172                if i + t_len <= html_chars.len() && html_chars[i..i + t_len] == token_chars[..] {
173                    annotated.push_str(&format!(
174                        "<span id=\"tts-w-{}\" class=\"tts-word\" data-start=\"{}\" data-end=\"{}\">{}</span>",
175                        token.index, token.char_start, token.char_end, token.word
176                    ));
177                    i += t_len;
178                    token_idx += 1;
179                    continue;
180                }
181            }
182
183            annotated.push(ch);
184            i += 1;
185        }
186
187        annotated
188    }
189
190    /// Strip embedded <script> tags, inline event attributes (on*="..."), and javascript: URIs.
191    pub fn strip_script_content(&mut self) {
192        self.processed_html = sanitize_html_scripts(&self.processed_html);
193    }
194
195    /// Extract footnotes and endnotes for popup previewing.
196    pub fn extract_footnotes(&self) -> Vec<crate::footnote::Footnote> {
197        crate::footnote::parse_footnotes_from_html(&self.raw_html)
198    }
199
200    /// Calculate structural NLP Reading Analytics (word count, WPM reading time, difficulty score, keywords).
201    pub fn analytics(&self) -> crate::analytics::ReadingAnalytics {
202        crate::analytics::ReadingAnalytics::analyze_text(&self.plain_text)
203    }
204
205    /// Calculate virtual reflow page breaks for this section using default or custom paginator bounds.
206    pub fn paginate(
207        &self,
208        paginator: Option<&crate::paginator::ReflowPaginator>,
209    ) -> crate::paginator::SectionPageMap {
210        let default_paginator = crate::paginator::ReflowPaginator::default();
211        let active_paginator = paginator.unwrap_or(&default_paginator);
212        active_paginator.paginate_section(self)
213    }
214}
215
216/// Extract clean plain text from HTML content by stripping tags, styles, and scripts.
217/// Quote-aware HTML tag boundary finder that ignores `>` characters inside attribute quotes.
218pub fn find_tag_end(html: &str, start_idx: usize) -> Option<usize> {
219    let bytes = html.as_bytes();
220    let mut in_quote: Option<u8> = None;
221    let mut i = start_idx;
222    while i < bytes.len() {
223        let b = bytes[i];
224        if let Some(q) = in_quote {
225            if b == q {
226                in_quote = None;
227            }
228        } else if b == b'"' || b == b'\'' {
229            in_quote = Some(b);
230        } else if b == b'>' {
231            return Some(i);
232        }
233        i += 1;
234    }
235    None
236}
237
238fn starts_with_ignore_case(s: &str, prefix: &str) -> bool {
239    if let Some(sub) = s.get(..prefix.len()) {
240        sub.eq_ignore_ascii_case(prefix)
241    } else {
242        false
243    }
244}
245
246/// Extract clean plain text from HTML content by stripping tags, styles, and scripts.
247/// UTF-8 char boundary safe and quote-aware extraction.
248pub fn extract_plain_text(html: &str) -> String {
249    let mut in_tag = false;
250    let mut in_quote: Option<u8> = None;
251    let mut text = String::with_capacity(html.len());
252    let mut skipping_tag: Option<&'static str> = None;
253
254    let len = html.len();
255    let mut i = 0;
256
257    while i < len {
258        if !in_tag && html.as_bytes()[i] == b'<' {
259            in_tag = true;
260            in_quote = None;
261            let slice = &html[i..];
262            if skipping_tag.is_none() {
263                if starts_with_ignore_case(slice, "<style") {
264                    skipping_tag = Some("style");
265                } else if starts_with_ignore_case(slice, "<script") {
266                    skipping_tag = Some("script");
267                }
268            } else if let Some(tag) = skipping_tag {
269                let close_tag = if tag == "style" {
270                    "</style"
271                } else {
272                    "</script"
273                };
274                if starts_with_ignore_case(slice, close_tag) {
275                    skipping_tag = None;
276                } else if find_ignore_case(slice, close_tag).is_none() {
277                    // Unclosed style/script recovery: if no closing tag exists anywhere in document,
278                    // recover when encountering structural HTML block tags (<p, <div, <body, <h, <section)
279                    if starts_with_ignore_case(slice, "<p")
280                        || starts_with_ignore_case(slice, "<div")
281                        || starts_with_ignore_case(slice, "<body")
282                        || starts_with_ignore_case(slice, "<h")
283                        || starts_with_ignore_case(slice, "<section")
284                    {
285                        skipping_tag = None;
286                    }
287                }
288            }
289            text.push(' ');
290            i += 1;
291            continue;
292        }
293
294        if in_tag {
295            let b = html.as_bytes()[i];
296            if let Some(q) = in_quote {
297                if b == q {
298                    in_quote = None;
299                }
300            } else if b == b'"' || b == b'\'' {
301                in_quote = Some(b);
302            } else if b == b'>' {
303                in_tag = false;
304                in_quote = None;
305            }
306            i += 1;
307            continue;
308        }
309
310        if skipping_tag.is_none() {
311            if let Some(ch) = html[i..].chars().next() {
312                text.push(ch);
313                i += ch.len_utf8();
314            } else {
315                i += 1;
316            }
317        } else {
318            i += 1;
319        }
320    }
321
322    let mut result = String::with_capacity(text.len());
323    let mut prev_space = false;
324    for ch in text.chars() {
325        if ch.is_whitespace() {
326            if !prev_space {
327                result.push(' ');
328                prev_space = true;
329            }
330        } else {
331            result.push(ch);
332            prev_space = false;
333        }
334    }
335
336    result.trim().to_string()
337}
338
339/// Process XHTML/HTML resources: Inline images, styles, and fonts as base64 Data URIs.
340pub fn process_section_resources(html: &str, section_path: &str, archive: &EpubArchive) -> String {
341    process_section_resources_with_strategy(
342        html,
343        section_path,
344        archive,
345        &AssetDeliveryStrategy::InlinedBase64,
346    )
347}
348
349/// Process XHTML/HTML resources according to specified delivery strategy (Base64 vs ResourceStream).
350pub fn process_section_resources_with_strategy(
351    html: &str,
352    section_path: &str,
353    archive: &EpubArchive,
354    strategy: &AssetDeliveryStrategy,
355) -> String {
356    let section_dir = if let Some(idx) = section_path.rfind('/') {
357        &section_path[..idx]
358    } else {
359        ""
360    };
361
362    let mut output = html.to_string();
363
364    // Replace image src attributes
365    let img_src_regex = regex_find_attr(html, "src");
366    for (orig_attr, src_val) in img_src_regex {
367        if src_val.starts_with("data:")
368            || src_val.starts_with("http://")
369            || src_val.starts_with("https://")
370        {
371            continue;
372        }
373        let res_path = resolve_relative_path(section_dir, &src_val);
374
375        match strategy {
376            AssetDeliveryStrategy::InlinedBase64 => {
377                if let Ok(bytes) = archive.read_bytes(&res_path) {
378                    let mime = EpubArchive::get_mime_type(&res_path);
379                    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
380                    let data_uri = format!("data:{};base64,{}", mime, b64);
381                    output = output.replace(&orig_attr, &format!("src=\"{}\"", data_uri));
382                }
383            }
384            AssetDeliveryStrategy::ResourceStream => {
385                let stream_url = format!("resource/{}", res_path);
386                output = output.replace(&orig_attr, &format!("src=\"{}\"", stream_url));
387            }
388        }
389    }
390
391    // Replace ONLY <link href="*.css"> stylesheet links
392    let css_href_regex = regex_find_link_css(html);
393    for (orig_attr, href_val) in css_href_regex {
394        let res_path = resolve_relative_path(section_dir, &href_val);
395        match strategy {
396            AssetDeliveryStrategy::InlinedBase64 => {
397                if let Ok(css_text) = archive.read_string(&res_path) {
398                    let processed_css = process_css_resources(&css_text, &res_path, archive);
399                    let b64 =
400                        base64::engine::general_purpose::STANDARD.encode(processed_css.as_bytes());
401                    let data_uri = format!("data:text/css;base64,{}", b64);
402                    output = output.replace(&orig_attr, &format!("href=\"{}\"", data_uri));
403                }
404            }
405            AssetDeliveryStrategy::ResourceStream => {
406                let stream_url = format!("resource/{}", res_path);
407                output = output.replace(&orig_attr, &format!("href=\"{}\"", stream_url));
408            }
409        }
410    }
411
412    output
413}
414
415fn find_ignore_case(s: &str, pat: &str) -> Option<usize> {
416    if pat.is_empty() || s.len() < pat.len() {
417        return None;
418    }
419    for (i, _) in s.char_indices() {
420        let end = i + pat.len();
421        if end <= s.len() && s.is_char_boundary(end) {
422            if s[i..end].eq_ignore_ascii_case(pat) {
423                return Some(i);
424            }
425        }
426    }
427    None
428}
429
430/// Helper to find ONLY `<link ... href="...">` tags for CSS inlining (E5 Fix).
431fn regex_find_link_css(html: &str) -> Vec<(String, String)> {
432    let mut list = Vec::new();
433    let mut search_idx = 0;
434
435    while search_idx < html.len() {
436        if !html.is_char_boundary(search_idx) {
437            search_idx += 1;
438            continue;
439        }
440        if let Some(link_idx) = find_ignore_case(&html[search_idx..], "<link") {
441            let abs_link = search_idx + link_idx;
442            if let Some(abs_close) = find_tag_end(html, abs_link) {
443                let tag_str = &html[abs_link..=abs_close];
444                if let Some((orig_href, val)) = extract_attr(tag_str, "href") {
445                    if find_ignore_case(&val, ".css").is_some()
446                        || find_ignore_case(tag_str, "rel=\"stylesheet\"").is_some()
447                    {
448                        list.push((orig_href, val));
449                    }
450                }
451                search_idx = abs_close + 1;
452            } else {
453                break;
454            }
455        } else {
456            break;
457        }
458    }
459
460    list
461}
462
463fn extract_attr(tag_str: &str, attr: &str) -> Option<(String, String)> {
464    let attr_lower = attr.to_lowercase();
465    let pat1 = format!(" {}=\"", attr_lower);
466    let pat2 = format!("<{}=\"", attr_lower);
467    let pat3 = format!(" {}='", attr_lower);
468    let pat4 = format!("<{}='", attr_lower);
469
470    for pat in &[pat1, pat2, pat3, pat4] {
471        if let Some(pos) = find_ignore_case(tag_str, pat) {
472            let quote = pat.chars().last().unwrap();
473            let attr_start = pos + 1;
474            let val_start = pos + pat.len();
475
476            if tag_str.is_char_boundary(val_start) {
477                if let Some(quote_idx) =
478                    memchr::memchr(quote as u8, &tag_str.as_bytes()[val_start..])
479                {
480                    let val_end = val_start + quote_idx;
481                    if tag_str.is_char_boundary(attr_start)
482                        && tag_str.is_char_boundary(val_end)
483                        && val_end < tag_str.len()
484                    {
485                        let val = &tag_str[val_start..val_end];
486                        let orig = &tag_str[attr_start..=val_end];
487                        return Some((orig.to_string(), val.to_string()));
488                    }
489                }
490            }
491        }
492    }
493    None
494}
495
496/// Helper to find attributes like `src="..."` or `href="..."` with word boundary checks (E4 Fix).
497fn regex_find_attr(html: &str, attr: &str) -> Vec<(String, String)> {
498    let mut list = Vec::new();
499    let pattern1 = format!(" {}=\"", attr);
500    let pattern2 = format!("<{}=\"", attr);
501    let mut search_idx = 0;
502
503    while search_idx < html.len() {
504        if !html.is_char_boundary(search_idx) {
505            search_idx += 1;
506            continue;
507        }
508        let slice = &html[search_idx..];
509        let p1_match = find_ignore_case(slice, &pattern1).map(|s| search_idx + s + 1);
510        let p2_match = find_ignore_case(slice, &pattern2).map(|s| search_idx + s + 1);
511
512        let abs_start = match (p1_match, p2_match) {
513            (Some(m1), Some(m2)) => m1.min(m2),
514            (Some(m1), None) => m1,
515            (None, Some(m2)) => m2,
516            (None, None) => break,
517        };
518
519        // Skip if inside <pre>, <code>, <script>, or <style> blocks
520        let before_slice = &html[..abs_start];
521        let inside_code = ["<pre", "<code", "<script", "<style"].iter().any(|open| {
522            let close = format!("</{}", &open[1..]);
523            let last_o = before_slice.rfind(open);
524            let last_c = before_slice.rfind(&close);
525            match (last_o, last_c) {
526                (Some(_o), None) => true,
527                (Some(o), Some(c)) => o > c,
528                _ => false,
529            }
530        });
531
532        if inside_code {
533            search_idx = abs_start + 1;
534            continue;
535        }
536
537        let val_start = abs_start + attr.len() + 2;
538        if html.is_char_boundary(val_start) {
539            if let Some(end) = html[val_start..].find('"') {
540                let abs_end = val_start + end;
541                if html.is_char_boundary(abs_end) {
542                    let val = &html[val_start..abs_end];
543                    let orig = &html[abs_start..=abs_end];
544                    list.push((orig.to_string(), val.to_string()));
545                    search_idx = abs_end + 1;
546                    continue;
547                }
548            }
549        }
550        search_idx = abs_start + 1;
551    }
552
553    list
554}
555
556/// Inline fonts and images inside CSS stylesheet content (P7 Fix: Single-pass streaming builder).
557fn process_css_resources(css: &str, css_path: &str, archive: &EpubArchive) -> String {
558    let css_dir = if let Some(idx) = css_path.rfind('/') {
559        &css_path[..idx]
560    } else {
561        ""
562    };
563
564    let mut output = String::with_capacity(css.len());
565    let mut search_idx = 0;
566
567    while let Some(url_idx) = css[search_idx..].find("url(") {
568        let abs_url = search_idx + url_idx;
569        output.push_str(&css[search_idx..abs_url]);
570
571        let val_start = abs_url + 4;
572        if let Some(close_idx) = css[val_start..].find(')') {
573            let abs_close = val_start + close_idx;
574            let raw_url = css[val_start..abs_close]
575                .trim()
576                .trim_matches('\'')
577                .trim_matches('"');
578
579            if !raw_url.starts_with("data:") && !raw_url.starts_with("http") {
580                let res_path = resolve_relative_path(css_dir, raw_url);
581                if let Ok(mut bytes) = archive.read_bytes(&res_path) {
582                    if let Ok(enc_xml) = archive.read_string("META-INF/encryption.xml") {
583                        let deobf =
584                            crate::deobfuscate::FontDeobfuscator::parse_encryption_xml(&enc_xml);
585                        if deobf.is_encrypted(&res_path) {
586                            let opf_path = archive
587                                .get_opf_path()
588                                .unwrap_or_else(|_| "OEBPS/content.opf".to_string());
589                            let identifier = archive
590                                .read_string(&opf_path)
591                                .or_else(|_| archive.read_string("content.opf"))
592                                .ok()
593                                .and_then(|xml| crate::opf::parse_opf(&xml, &opf_path).ok())
594                                .map(|pkg| pkg.metadata.identifier.clone().unwrap_or_default())
595                                .unwrap_or_default();
596                            deobf.deobfuscate(&res_path, &mut bytes, &identifier);
597                        }
598                    }
599                    let mime = EpubArchive::get_mime_type(&res_path);
600                    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
601                    output.push_str(&format!("url(\"data:{};base64,{}\")", mime, b64));
602                    search_idx = abs_close + 1;
603                    continue;
604                }
605            }
606            output.push_str(&css[abs_url..=abs_close]);
607            search_idx = abs_close + 1;
608        } else {
609            output.push_str(&css[abs_url..]);
610            search_idx = css.len();
611            break;
612        }
613    }
614
615    if search_idx < css.len() {
616        output.push_str(&css[search_idx..]);
617    }
618
619    output
620}
621
622/// Parse `<meta name="viewport" content="width=..., height=...">` from section HTML.
623pub fn parse_viewport_meta(html: &str) -> (Option<f64>, Option<f64>) {
624    let mut search_idx = 0;
625
626    while search_idx < html.len() {
627        if !html.is_char_boundary(search_idx) {
628            search_idx += 1;
629            continue;
630        }
631        if let Some(idx) = find_ignore_case(&html[search_idx..], "<meta") {
632            let abs_idx = search_idx + idx;
633            if let Some(abs_close) = find_tag_end(html, abs_idx) {
634                let tag = &html[abs_idx..=abs_close];
635                if find_ignore_case(tag, "viewport").is_some() {
636                    if let Some((_, content)) = extract_attr(tag, "content") {
637                        let mut width = None;
638                        let mut height = None;
639
640                        for pair in content.split(',') {
641                            if let Some((k, v)) = pair.split_once('=') {
642                                let k = k.trim();
643                                let v = v.trim();
644                                if k.eq_ignore_ascii_case("width") {
645                                    width = v.parse::<f64>().ok();
646                                } else if k.eq_ignore_ascii_case("height") {
647                                    height = v.parse::<f64>().ok();
648                                }
649                            }
650                        }
651                        if width.is_some() || height.is_some() {
652                            return (width, height);
653                        }
654                    }
655                }
656                search_idx = abs_close + 1;
657            } else {
658                break;
659            }
660        } else {
661            break;
662        }
663    }
664
665    (None, None)
666}
667
668/// Sanitize HTML content by stripping <script> blocks, inline event handlers, and javascript: links.
669pub fn sanitize_html_scripts(html: &str) -> String {
670    let mut output = String::with_capacity(html.len());
671    let len = html.len();
672    let mut i = 0;
673
674    // Phase 1: Strip <script>...</script>, <iframe>, <object>, <embed> with fast memchr tag scanning
675    let bytes = html.as_bytes();
676    while i < len {
677        if let Some(tag_offset) = memchr::memchr(b'<', &bytes[i..]) {
678            let abs_tag = i + tag_offset;
679            if abs_tag > i {
680                output.push_str(&html[i..abs_tag]);
681            }
682            i = abs_tag;
683
684            let slice = &html[i..];
685            if starts_with_ignore_case(slice, "<script")
686                || starts_with_ignore_case(slice, "<iframe")
687                || starts_with_ignore_case(slice, "<object")
688                || starts_with_ignore_case(slice, "<embed")
689                || starts_with_ignore_case(slice, "<applet")
690                || starts_with_ignore_case(slice, "<base")
691                || starts_with_ignore_case(slice, "<form")
692                || (starts_with_ignore_case(slice, "<meta")
693                    && (find_ignore_case(slice.get(..60).unwrap_or(slice), "http-equiv").is_some()
694                        || find_ignore_case(slice.get(..60).unwrap_or(slice), "refresh").is_some()))
695            {
696                if let Some(close_tag_pos) = find_tag_end(html, i) {
697                    let tag_str = &html[i..=close_tag_pos];
698                    let is_self_closing = tag_str.ends_with("/>")
699                        || starts_with_ignore_case(slice, "<base")
700                        || starts_with_ignore_case(slice, "<meta");
701                    if is_self_closing {
702                        i = close_tag_pos + 1;
703                        continue;
704                    }
705
706                    let tag_name = if starts_with_ignore_case(slice, "<script") {
707                        "</script>"
708                    } else if starts_with_ignore_case(slice, "<iframe") {
709                        "</iframe>"
710                    } else if starts_with_ignore_case(slice, "<object") {
711                        "</object>"
712                    } else if starts_with_ignore_case(slice, "<applet") {
713                        "</applet>"
714                    } else if starts_with_ignore_case(slice, "<form") {
715                        "</form>"
716                    } else {
717                        "</embed>"
718                    };
719
720                    if let Some(end_idx) = find_ignore_case(&html[close_tag_pos + 1..], tag_name) {
721                        let end_pos = close_tag_pos + 1 + end_idx + tag_name.len();
722                        i = end_pos;
723                        continue;
724                    } else {
725                        // Unclosed tag: strip just opening tag element
726                        i = close_tag_pos + 1;
727                        continue;
728                    }
729                } else {
730                    i += 1;
731                    continue;
732                }
733            }
734
735            if let Some(ch) = slice.chars().next() {
736                output.push(ch);
737                i += ch.len_utf8();
738            } else {
739                i += 1;
740            }
741        } else {
742            output.push_str(&html[i..]);
743            break;
744        }
745    }
746
747    // Phase 2 (B8 Fix): Strip inline event attributes like onload=, onclick= (only inside HTML tags)
748    let mut sanitized = String::with_capacity(output.len());
749    let mut idx = 0;
750    let mut in_tag = false;
751    let mut in_quote: Option<char> = None;
752
753    while idx < output.len() {
754        if !output.is_char_boundary(idx) {
755            idx += 1;
756            continue;
757        }
758        let slice = &output[idx..];
759        if let Some(ch) = slice.chars().next() {
760            if !in_tag && ch == '<' {
761                in_tag = true;
762                in_quote = None;
763            } else if in_tag {
764                if let Some(q) = in_quote {
765                    if ch == q {
766                        in_quote = None;
767                    }
768                } else if ch == '"' || ch == '\'' {
769                    in_quote = Some(ch);
770                } else if ch == '>' {
771                    in_tag = false;
772                    in_quote = None;
773                }
774            }
775
776            if in_tag && (ch.is_whitespace() || ch == '<' || ch == '/') {
777                let rest = &slice[ch.len_utf8()..];
778                let trimmed_rest = rest.trim_start();
779                let ws_bytes = &rest[..rest.len() - trimmed_rest.len()];
780
781                if starts_with_ignore_case(trimmed_rest, "on") {
782                    let mut attr_len = 2;
783                    while attr_len < trimmed_rest.len()
784                        && trimmed_rest.as_bytes()[attr_len].is_ascii_alphanumeric()
785                    {
786                        attr_len += 1;
787                    }
788                    let after_attr = trimmed_rest[attr_len..].trim_start();
789                    if let Some(_stripped) = after_attr.strip_prefix('=') {
790                        sanitized.push(ch);
791                        sanitized.push_str(ws_bytes);
792                        idx += ch.len_utf8()
793                            + ws_bytes.len()
794                            + (trimmed_rest.len() - after_attr.len())
795                            + 1;
796
797                        if idx < output.len() {
798                            let val_slice = &output[idx..];
799                            let trimmed_val = val_slice.trim_start();
800                            idx += val_slice.len() - trimmed_val.len();
801
802                            if let Some(quote_ch) = trimmed_val.chars().next() {
803                                if quote_ch == '"' || quote_ch == '\'' {
804                                    idx += quote_ch.len_utf8();
805                                    if let Some(end_q) = output[idx..].find(quote_ch) {
806                                        idx += end_q + quote_ch.len_utf8();
807                                    } else {
808                                        idx = output.len();
809                                    }
810                                } else {
811                                    while idx < output.len() {
812                                        let c = output[idx..].chars().next().unwrap_or(' ');
813                                        if c.is_whitespace() || c == '>' || c == '/' {
814                                            if c == '>' {
815                                                in_tag = false;
816                                            }
817                                            break;
818                                        }
819                                        idx += c.len_utf8();
820                                    }
821                                }
822                            }
823                        }
824                        continue;
825                    }
826                }
827            }
828
829            sanitized.push(ch);
830            idx += ch.len_utf8();
831        } else {
832            break;
833        }
834    }
835
836    // Phase 3 (B9 Fix): Neutralize javascript:, vbscript:, data:text/html URIs (decoding all HTML entities first)
837    let mut final_sanitized = String::with_capacity(sanitized.len());
838    let mut cur_idx = 0;
839    while cur_idx < sanitized.len() {
840        if !sanitized.is_char_boundary(cur_idx) {
841            cur_idx += 1;
842            continue;
843        }
844        let slice = &sanitized[cur_idx..];
845        let first_byte = slice.as_bytes().first().copied().unwrap_or(0);
846        let might_be_scheme = matches!(first_byte, b'j' | b'J' | b'v' | b'V' | b'd' | b'D' | b'&');
847
848        let match_info = if might_be_scheme {
849            let mut window_len = slice.len().min(64);
850            while !slice.is_char_boundary(window_len) {
851                window_len -= 1;
852            }
853            let window = &slice[..window_len];
854            let decoded_window = decode_html_entities_for_uri(window);
855
856            if starts_with_ignore_case(window, "javascript:") {
857                Some("javascript:".len())
858            } else if starts_with_ignore_case(window, "vbscript:") {
859                Some("vbscript:".len())
860            } else if starts_with_ignore_case(window, "data:text/html") {
861                Some("data:text/html".len())
862            } else if starts_with_ignore_case(&decoded_window, "javascript:")
863                || starts_with_ignore_case(&decoded_window, "vbscript:")
864                || starts_with_ignore_case(&decoded_window, "data:text/html")
865            {
866                if let Some(colon_pos) = window.find(':') {
867                    Some(colon_pos + 1)
868                } else {
869                    Some(window.len().min(15))
870                }
871            } else {
872                None
873            }
874        } else {
875            None
876        };
877
878        if let Some(advance_len) = match_info {
879            final_sanitized.push_str("#disabled_uri:");
880            cur_idx += advance_len.min(slice.len());
881        } else if let Some(ch) = slice.chars().next() {
882            final_sanitized.push(ch);
883            cur_idx += ch.len_utf8();
884        } else {
885            break;
886        }
887    }
888
889    final_sanitized
890}
891
892fn decode_html_entities_for_uri(input: &str) -> String {
893    let mut out = String::with_capacity(input.len());
894    let mut i = 0;
895    let bytes = input.as_bytes();
896
897    while i < bytes.len() {
898        if bytes[i] == b'&' && i + 2 < bytes.len() {
899            if bytes[i + 1] == b'#' {
900                let is_hex = bytes[i + 2] == b'x' || bytes[i + 2] == b'X';
901                let start = if is_hex { i + 3 } else { i + 2 };
902                let mut end = start;
903                while end < bytes.len() && bytes[end] != b';' && (end - start) < 8 {
904                    end += 1;
905                }
906                if end < bytes.len() && bytes[end] == b';' {
907                    let num_str = &input[start..end];
908                    let parsed = if is_hex {
909                        u32::from_str_radix(num_str, 16).ok()
910                    } else {
911                        num_str.parse::<u32>().ok()
912                    };
913                    if let Some(code) = parsed {
914                        if let Some(ch) = char::from_u32(code) {
915                            if !ch.is_control() {
916                                out.push(ch);
917                            }
918                            i = end + 1;
919                            continue;
920                        }
921                    }
922                }
923            }
924        }
925        if let Some(ch) = input[i..].chars().next() {
926            out.push(ch);
927            i += ch.len_utf8();
928        } else {
929            i += 1;
930        }
931    }
932    out
933}