1use crate::archive::{EpubArchive, resolve_relative_path};
2use crate::layout::AssetDeliveryStrategy;
3use base64::Engine;
4use serde::{Deserialize, Serialize};
5
6#[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, pub char_count: usize,
18 pub viewport_width: Option<f64>, pub viewport_height: Option<f64>, }
21
22#[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 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 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 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 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 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 pub fn strip_script_content(&mut self) {
192 self.processed_html = sanitize_html_scripts(&self.processed_html);
193 }
194
195 pub fn extract_footnotes(&self) -> Vec<crate::footnote::Footnote> {
197 crate::footnote::parse_footnotes_from_html(&self.raw_html)
198 }
199
200 pub fn analytics(&self) -> crate::analytics::ReadingAnalytics {
202 crate::analytics::ReadingAnalytics::analyze_text(&self.plain_text)
203 }
204
205 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
216pub 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
246pub 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 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
339pub 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
349pub 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 §ion_path[..idx]
358 } else {
359 ""
360 };
361
362 let mut output = html.to_string();
363
364 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 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
430fn 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
496fn 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 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
556fn 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
622pub 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
668pub 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 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 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 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 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}