1use scraper::{Html, ElementRef};
7use url::Url;
8
9use crate::selector::try_parse_selector;
10use crate::types::{
11 Heading, Image, ImageLoading, ListContent, ListType, ListItem,
12 TableContent, TableRow, TableCell, CodeBlock, Quote,
13 ParserConfig,
14};
15
16#[derive(Debug, Clone)]
22pub struct OutlineItem {
23 pub heading: Heading,
25 pub children: Vec<OutlineItem>,
27 pub depth: usize,
29}
30
31impl OutlineItem {
32 pub fn new(heading: Heading) -> Self {
33 Self {
34 heading,
35 children: Vec::new(),
36 depth: 0,
37 }
38 }
39}
40
41pub fn extract_headings(document: &Html) -> Vec<Heading> {
49 let mut headings = Vec::new();
50
51 for level in 1..=6u8 {
52 let selector_str = format!("h{level}");
53 let selector = match try_parse_selector(&selector_str) {
54 Some(s) => s,
55 None => continue,
56 };
57 for element in document.select(&selector) {
58 let text: String = element
59 .text()
60 .collect::<Vec<_>>()
61 .join("")
62 .trim()
63 .to_string();
64
65 if text.is_empty() {
66 continue;
67 }
68
69 let id = element.value().attr("id").map(ToString::to_string);
70 let classes: Vec<String> = element
71 .value()
72 .classes()
73 .map(std::string::ToString::to_string)
74 .collect();
75
76 headings.push(Heading {
77 level,
78 text,
79 id,
80 classes,
81 });
82 }
83 }
84
85 headings
86}
87
88pub fn get_main_heading(document: &Html) -> Option<Heading> {
90 if let Some(sel) = try_parse_selector("h1")
92 && let Some(el) = document.select(&sel).next() {
93 let text: String = el
94 .text()
95 .collect::<Vec<_>>()
96 .join("")
97 .trim()
98 .to_string();
99 if !text.is_empty() {
100 let id = el.value().attr("id").map(ToString::to_string);
101 let classes: Vec<String> = el
102 .value()
103 .classes()
104 .map(std::string::ToString::to_string)
105 .collect();
106 return Some(Heading {
107 level: 1,
108 text,
109 id,
110 classes,
111 });
112 }
113 }
114
115 if let Some(sel) = try_parse_selector("h2")
117 && let Some(el) = document.select(&sel).next() {
118 let text: String = el
119 .text()
120 .collect::<Vec<_>>()
121 .join("")
122 .trim()
123 .to_string();
124 if !text.is_empty() {
125 let id = el.value().attr("id").map(ToString::to_string);
126 let classes: Vec<String> = el
127 .value()
128 .classes()
129 .map(std::string::ToString::to_string)
130 .collect();
131 return Some(Heading {
132 level: 2,
133 text,
134 id,
135 classes,
136 });
137 }
138 }
139
140 None
141}
142
143pub fn build_outline(headings: &[Heading]) -> Vec<OutlineItem> {
147 fn build(items: &[Heading], min_level: u8, depth: usize) -> (Vec<OutlineItem>, usize) {
148 let mut result = Vec::new();
149 let mut i = 0;
150 while i < items.len() {
151 let h = &items[i];
152 if h.level < min_level {
153 break;
154 }
155 let mut item = OutlineItem::new(h.clone());
156 item.depth = depth;
157 let (children, consumed) = build(&items[i + 1..], h.level + 1, depth + 1);
158 item.children = children;
159 i += 1 + consumed;
160 result.push(item);
161 }
162 (result, i)
163 }
164
165 let (outline, _) = build(headings, 1, 0);
166 outline
167}
168
169pub fn extract_paragraphs(document: &Html, min_length: usize) -> Vec<String> {
177 let selector = match try_parse_selector("p") {
178 Some(s) => s,
179 None => return Vec::new(),
180 };
181
182 let mut paragraphs = Vec::new();
183
184 for element in document.select(&selector) {
185 let text: String = element
186 .text()
187 .collect::<Vec<_>>()
188 .join("")
189 .trim()
190 .to_string();
191
192 if text.len() >= min_length {
193 paragraphs.push(text);
194 }
195 }
196
197 paragraphs
198}
199
200pub fn extract_lists(document: &Html, config: &ParserConfig) -> Vec<ListContent> {
208 let mut lists = Vec::new();
209
210 for list_tag in &["ul", "ol"] {
212 let selector = match try_parse_selector(list_tag) {
213 Some(s) => s,
214 None => continue,
215 };
216 for element in document.select(&selector) {
217 let parent = element.parent();
219 if let Some(parent_id) = parent
220 && let Some(parent_ref) = ElementRef::wrap(parent_id)
221 && parent_ref.value().name() == "li" {
222 continue;
223 }
224 let list = extract_list(&element, config);
225 if !list.is_empty() {
226 lists.push(list);
227 }
228 }
229 }
230
231 if let Some(sel) = try_parse_selector("dl") {
233 for element in document.select(&sel) {
234 let list = extract_definition_list(&element, config);
235 if !list.is_empty() {
236 lists.push(list);
237 }
238 }
239 }
240
241 lists
242}
243
244pub fn extract_list(element: &ElementRef, _config: &ParserConfig) -> ListContent {
246 let tag_name = element.value().name();
247 let list_type = match tag_name {
248 "ol" => ListType::Ordered,
249 _ => ListType::Unordered,
250 };
251
252 let mut list = ListContent::new(list_type);
253
254 for child in element.children() {
255 let child_ref = match ElementRef::wrap(child) {
256 Some(r) => r,
257 None => continue,
258 };
259 if child_ref.value().name() != "li" {
260 continue;
261 }
262 if let Some(item) = extract_list_item(&child_ref) {
263 list.add_item(item);
264 }
265 }
266
267 list
268}
269
270pub fn extract_list_item(element: &ElementRef) -> Option<ListItem> {
272 let mut text_parts = Vec::new();
273 let mut nested_list: Option<ListContent> = None;
274 let mut has_text = false;
275
276 for child in element.children() {
277 let child_ref = match ElementRef::wrap(child) {
278 Some(r) => r,
279 None => {
280 if let scraper::Node::Text(t) = child.value() {
282 let t = t.text.trim();
283 if !t.is_empty() {
284 text_parts.push(t.to_string());
285 has_text = true;
286 }
287 }
288 continue;
289 }
290 };
291
292 let name = child_ref.value().name();
293 match name {
294 "ul" | "ol" => {
295 let lt = if name == "ol" {
296 ListType::Ordered
297 } else {
298 ListType::Unordered
299 };
300 let mut nl = ListContent::new(lt);
301 for subchild in child_ref.children() {
302 if let Some(sub_ref) = ElementRef::wrap(subchild)
303 && sub_ref.value().name() == "li"
304 && let Some(item) = extract_list_item(&sub_ref) {
305 nl.add_item(item);
306 }
307 }
308 if !nl.is_empty() {
309 nested_list = Some(nl);
310 }
311 }
312 _ => {
313 let t: String = child_ref
314 .text()
315 .collect::<Vec<_>>()
316 .join("")
317 .trim()
318 .to_string();
319 if !t.is_empty() {
320 text_parts.push(t);
321 has_text = true;
322 }
323 }
324 }
325 }
326
327 if !has_text {
328 return None;
329 }
330
331 let text = text_parts.join(" ");
332
333 if let Some(nl) = nested_list {
334 Some(ListItem::with_nested(text, nl))
335 } else {
336 Some(ListItem::new(text))
337 }
338}
339
340pub fn extract_definition_list(element: &ElementRef, _config: &ParserConfig) -> ListContent {
342 let mut list = ListContent::new(ListType::Definition);
343
344 let mut current_term: Option<String> = None;
345
346 for child in element.children() {
347 let child_ref = match ElementRef::wrap(child) {
348 Some(r) => r,
349 None => continue,
350 };
351
352 let name = child_ref.value().name();
353 match name {
354 "dt" => {
355 let text: String = child_ref
356 .text()
357 .collect::<Vec<_>>()
358 .join("")
359 .trim()
360 .to_string();
361 if !text.is_empty() {
362 current_term = Some(text);
363 }
364 }
365 "dd" => {
366 let text: String = child_ref
367 .text()
368 .collect::<Vec<_>>()
369 .join("")
370 .trim()
371 .to_string();
372 if !text.is_empty() {
373 let item_text = if let Some(ref term) = current_term {
374 format!("{term}: {text}")
375 } else {
376 text
377 };
378 list.add_item(ListItem::new(item_text));
379 }
380 }
381 _ => {}
382 }
383 }
384
385 list
386}
387
388pub fn extract_tables(document: &Html, config: &ParserConfig) -> Vec<TableContent> {
394 let selector = match try_parse_selector("table") {
395 Some(s) => s,
396 None => return Vec::new(),
397 };
398
399 let mut tables = Vec::new();
400
401 for element in document.select(&selector) {
402 let table = extract_table(&element, config);
403 if !table.is_empty() {
404 tables.push(table);
405 }
406 }
407
408 tables
409}
410
411pub fn extract_table(element: &ElementRef, _config: &ParserConfig) -> TableContent {
413 let mut table = TableContent::new();
414
415 if let Some(caption_sel) = try_parse_selector("caption")
417 && let Some(caption_el) = element.select(&caption_sel).next() {
418 let caption: String = caption_el
419 .text()
420 .collect::<Vec<_>>()
421 .join("")
422 .trim()
423 .to_string();
424 if !caption.is_empty() {
425 table.caption = Some(caption);
426 }
427 }
428
429 if let Some(thead_sel) = try_parse_selector("thead") {
431 let mut header_rows = Vec::new();
432 if let Some(thead_el) = element.select(&thead_sel).next() {
433 for row_el in thead_el.child_elements() {
434 let row = extract_table_row(&row_el);
435 if !row.cells.is_empty() {
436 header_rows.push(row);
437 }
438 }
439 }
440 table.headers = header_rows;
441 }
442
443 let mut rows = Vec::new();
445 if let Some(tbody_sel) = try_parse_selector("tbody") {
446 if let Some(tbody_el) = element.select(&tbody_sel).next() {
447 for row_el in tbody_el.child_elements() {
448 if row_el.value().name() == "tr" {
449 let row = extract_table_row(&row_el);
450 if !row.cells.is_empty() {
451 rows.push(row);
452 }
453 }
454 }
455 } else {
456 for child in element.children() {
458 if let Some(row_ref) = ElementRef::wrap(child)
459 && row_ref.value().name() == "tr" {
460 let row = extract_table_row(&row_ref);
461 if !row.cells.is_empty() {
462 rows.push(row);
463 }
464 }
465 }
466 }
467 }
468
469 table.rows = rows;
470
471 let max_cols = table
473 .headers
474 .iter()
475 .chain(table.rows.iter())
476 .map(|r| r.cells.len())
477 .max()
478 .unwrap_or(0);
479 table.column_count = max_cols;
480
481 table
482}
483
484pub fn extract_table_row(element: &ElementRef) -> TableRow {
486 let mut cells = Vec::new();
487
488 for child in element.children() {
489 let child_ref = match ElementRef::wrap(child) {
490 Some(r) => r,
491 None => continue,
492 };
493
494 let name = child_ref.value().name();
495 if name != "td" && name != "th" {
496 continue;
497 }
498
499 let cell = extract_table_cell(&child_ref);
500 cells.push(cell);
501 }
502
503 TableRow::new(cells)
504}
505
506pub fn extract_table_cell(element: &ElementRef) -> TableCell {
508 let is_header = element.value().name() == "th";
509
510 let content: String = element
511 .text()
512 .collect::<Vec<_>>()
513 .join("")
514 .trim()
515 .to_string();
516
517 let colspan = element
518 .value()
519 .attr("colspan")
520 .and_then(|v| v.parse::<u32>().ok())
521 .unwrap_or(1);
522
523 let rowspan = element
524 .value()
525 .attr("rowspan")
526 .and_then(|v| v.parse::<u32>().ok())
527 .unwrap_or(1);
528
529 TableCell {
530 content,
531 is_header,
532 colspan,
533 rowspan,
534 }
535}
536
537const KNOWN_LANGUAGES: &[&str] = &[
543 "abap", "actionscript", "ada", "apache", "applescript", "arduino",
544 "asm", "asp", "assembly", "awk", "bash", "basic", "batch", "bison",
545 "c", "c++", "c#", "csharp", "caml", "clojure", "cmake", "coffeescript",
546 "cpp", "crystal", "cql", "css", "cuda", "cypher", "d", "dart",
547 "delphi", "diff", "django", "docker", "dot", "elixir", "elm",
548 "emacs", "erlang", "excel", "f#", "fsharp", "fasta", "fish",
549 "flow", "fortran", "fsharp", "gams", "gcode", "gdscript", "genie",
550 "gherkin", "git", "glsl", "glyph", "gnuplot", "go", "golo",
551 "gradle", "graphql", "groovy", "haskell", "haxe", "hcl", "hlsl",
552 "html", "http", "hy", "idl", "ini", "io", "java", "javascript",
553 "jinja", "jq", "json", "julia", "kotlin", "latex", "less", "lilypond",
554 "lisp", "livescript", "llvm", "lua", "m4", "makefile", "markdown",
555 "mathematica", "matlab", "mcfunction", "meson", "minizinc", "mips",
556 "modelica", "mojo", "mql4", "mql5", "msl", "mysql", "nasm", "nginx",
557 "nim", "nix", "node", "numpy", "ocaml", "octave", "odin", "opencl",
558 "opengl", "pascal", "perl", "php", "pkl", "plsql", "postgresql",
559 "powershell", "processing", "prolog", "promql", "protobuf", "pug",
560 "puppet", "purebasic", "pyramid", "python", "q", "qml", "r",
561 "racket", "ragel", "rasql", "reason", "rebol", "red", "restructuredtext",
562 "riscv", "robot", "ruby", "rust", "sas", "sass", "scala", "scheme",
563 "scilab", "scss", "shell", "smalltalk", "smarty", "snakemake", "solidity",
564 "sparql", "sql", "stan", "stata", "stylus", "svelte", "swift",
565 "systemverilog", "tcl", "terraform", "tex", "text", "thrift", "toml",
566 "turing", "twig", "typescript", "typoscript", "v", "vala", "vbnet",
567 "verilog", "vhdl", "vim", "visualbasic", "vue", "wasm", "wdl",
568 "webassembly", "wolfram", "x86asm", "xaml", "xml", "xsl", "yaml",
569 "yang", "yara", "zephir", "zig",
570];
571
572pub fn is_known_language(lang: &str) -> bool {
574 let lang = lang.trim().to_lowercase();
575 KNOWN_LANGUAGES.contains(&lang.as_str())
576}
577
578pub fn extract_code_blocks(document: &Html, _config: &ParserConfig) -> Vec<CodeBlock> {
580 let mut blocks = Vec::new();
581
582 let pre_selector = match try_parse_selector("pre") {
584 Some(s) => s,
585 None => return blocks,
586 };
587
588 for pre_element in document.select(&pre_selector) {
589 if let Some(code_el) = pre_element.child_elements().next()
590 && code_el.value().name() == "code" {
591 let block = extract_code_block(&code_el, false);
592 blocks.push(block);
593 }
594 }
595
596 if let Some(code_sel) = try_parse_selector("code") {
598 for element in document.select(&code_sel) {
599 let parent = element.parent();
601 if let Some(parent_id) = parent
602 && let Some(parent_ref) = ElementRef::wrap(parent_id)
603 && parent_ref.value().name() == "pre" {
604 continue;
605 }
606
607 let block = extract_code_block(&element, true);
608 blocks.push(block);
609 }
610 }
611
612 blocks
613}
614
615pub fn extract_code_block(element: &ElementRef, is_inline: bool) -> CodeBlock {
617 let code: String = element
618 .text()
619 .collect::<Vec<_>>()
620 .join("")
621 .trim()
622 .to_string();
623
624 let language = element
626 .value()
627 .attr("class")
628 .and_then(|cls| {
629 for part in cls.split_whitespace() {
631 if let Some(lang) = part.strip_prefix("language-")
632 && is_known_language(lang) {
633 return Some(lang.to_string());
634 }
635 if let Some(lang) = part.strip_prefix("lang-")
636 && is_known_language(lang) {
637 return Some(lang.to_string());
638 }
639 if is_known_language(part) {
640 return Some(part.to_string());
641 }
642 }
643 None
644 })
645 .or_else(|| {
646 element
647 .value()
648 .attr("data-lang")
649 .filter(|l| is_known_language(l))
650 .map(ToString::to_string)
651 });
652
653 let filename = element
654 .value()
655 .attr("data-filename")
656 .or_else(|| element.value().attr("filename"))
657 .map(ToString::to_string);
658
659 let line_count = code.lines().count();
660
661 CodeBlock {
662 code,
663 language,
664 line_count,
665 is_inline,
666 filename,
667 }
668}
669
670pub fn extract_quotes(document: &Html, _config: &ParserConfig) -> Vec<Quote> {
676 let selector = match try_parse_selector("blockquote") {
677 Some(s) => s,
678 None => return Vec::new(),
679 };
680
681 let mut quotes = Vec::new();
682
683 for element in document.select(&selector) {
684 let text: String = element
685 .text()
686 .collect::<Vec<_>>()
687 .join("")
688 .trim()
689 .to_string();
690
691 if text.is_empty() || text.len() < 10 {
692 continue;
693 }
694
695 let cite = element
696 .value()
697 .attr("cite")
698 .map(ToString::to_string);
699
700 let cite_url = if let Some(cite_sel) = try_parse_selector("cite") {
702 element
703 .select(&cite_sel)
704 .next()
705 .and_then(|el| {
706 let cite_text: String = el
707 .text()
708 .collect::<Vec<_>>()
709 .join("")
710 .trim()
711 .to_string();
712 if cite_text.is_empty() { None } else { Some(cite_text) }
713 })
714 } else {
715 None
716 };
717
718 let mut quote = Quote::new(text);
719 if let Some(c) = cite {
720 quote.cite = Some(c);
721 }
722 if let Some(cu) = cite_url {
723 quote.cite_url = Some(cu);
724 }
725
726 quotes.push(quote);
727 }
728
729 quotes
730}
731
732pub fn extract_images(document: &Html, base_url: Option<&Url>) -> Vec<Image> {
738 let selector = match try_parse_selector("img") {
739 Some(s) => s,
740 None => return Vec::new(),
741 };
742
743 let mut images = Vec::new();
744
745 for element in document.select(&selector) {
746 if let Some(image) = extract_image(&element, base_url) {
747 images.push(image);
748 }
749 }
750
751 images
752}
753
754pub fn extract_image(element: &ElementRef, base_url: Option<&Url>) -> Option<Image> {
756 let src = element.value().attr("src")?;
757 let src = src.trim();
758
759 if src.is_empty() {
760 return None;
761 }
762
763 if src.starts_with("data:") {
765 return None;
766 }
767
768 let resolved_src = base_url.and_then(|base| resolve_image_url(base, src));
769
770 let alt = element
771 .value()
772 .attr("alt")
773 .unwrap_or("")
774 .to_string();
775
776 let title = element
777 .value()
778 .attr("title")
779 .map(ToString::to_string);
780
781 let width = element
782 .value()
783 .attr("width")
784 .and_then(|w| w.parse::<u32>().ok());
785
786 let height = element
787 .value()
788 .attr("height")
789 .and_then(|h| h.parse::<u32>().ok());
790
791 let srcset = element
792 .value()
793 .attr("srcset")
794 .map(ToString::to_string);
795
796 let sizes = element
797 .value()
798 .attr("sizes")
799 .map(ToString::to_string);
800
801 let loading = element
802 .value()
803 .attr("loading")
804 .map(|v| match v {
805 "lazy" => ImageLoading::Lazy,
806 _ => ImageLoading::Eager,
807 })
808 .unwrap_or_default();
809
810 let is_decorative = alt.is_empty();
811
812 let mut image = Image {
813 src: src.to_string(),
814 url: resolved_src,
815 alt,
816 title,
817 width,
818 height,
819 srcset,
820 sizes,
821 loading,
822 is_decorative,
823 };
824
825 if image.srcset.is_some() && image.url.is_none() && image.src.starts_with('/') {
826 if let Some(base) = base_url {
828 image.url = resolve_image_url(base, &image.src);
829 }
830 }
831
832 Some(image)
833}
834
835pub fn resolve_image_url(base: &Url, src: &str) -> Option<String> {
837 if src.starts_with("http://") || src.starts_with("https://") {
838 return Some(src.to_string());
839 }
840
841 if src.starts_with("//") {
842 let scheme = base.scheme();
844 return Some(format!("{scheme}:{src}"));
845 }
846
847 base.join(src).ok().map(|u| u.to_string())
848}
849
850#[cfg(test)]
855mod tests {
856 use super::*;
857
858 fn create_document(html: &str) -> Html {
859 Html::parse_document(html)
860 }
861
862 fn default_config() -> ParserConfig {
863 ParserConfig::default()
864 }
865
866 #[test]
869 fn test_extract_headings_basic() {
870 let html = r#"<html><body>
871 <h1 id="main">主标题</h1>
872 <h2 class="section">章节一</h2>
873 <h3>子章节</h3>
874 <h2>章节二</h2>
875 </body></html>"#;
876 let doc = create_document(html);
877 let headings = extract_headings(&doc);
878
879 assert_eq!(headings.len(), 4);
880 assert_eq!(headings[0].level, 1);
881 assert_eq!(headings[0].text, "主标题");
882 assert_eq!(headings[0].id.as_deref(), Some("main"));
883 assert_eq!(headings[1].level, 2);
884 assert_eq!(headings[1].text, "章节一");
885 assert!(headings[1].classes.contains(&"section".to_string()));
886 }
887
888 #[test]
889 fn test_extract_headings_empty() {
890 let html = r"<html><body><p>没有标题</p></body></html>";
891 let doc = create_document(html);
892 let headings = extract_headings(&doc);
893 assert!(headings.is_empty());
894 }
895
896 #[test]
897 fn test_extract_headings_ignores_empty() {
898 let html = r"<html><body><h1> </h1><h2>内容</h2></body></html>";
899 let doc = create_document(html);
900 let headings = extract_headings(&doc);
901 assert_eq!(headings.len(), 1);
902 assert_eq!(headings[0].text, "内容");
903 }
904
905 #[test]
906 fn test_get_main_heading_from_h1() {
907 let html = r#"<html><body>
908 <h1 id="title">页面标题</h1>
909 <h2>次要标题</h2>
910 </body></html>"#;
911 let doc = create_document(html);
912 let main = get_main_heading(&doc);
913 assert!(main.is_some());
914 assert_eq!(main.unwrap().text, "页面标题");
915 }
916
917 #[test]
918 fn test_get_main_heading_fallback_to_h2() {
919 let html = r"<html><body><h2>次级标题</h2></body></html>";
920 let doc = create_document(html);
921 let main = get_main_heading(&doc);
922 assert!(main.is_some());
923 assert_eq!(main.unwrap().level, 2);
924 }
925
926 #[test]
927 fn test_get_main_heading_none() {
928 let html = r"<html><body><p>无标题</p></body></html>";
929 let doc = create_document(html);
930 let main = get_main_heading(&doc);
931 assert!(main.is_none());
932 }
933
934 #[test]
935 fn test_build_outline_simple() {
936 let headings = vec![
937 Heading::new(1, "H1").with_id("a"),
938 Heading::new(2, "H2-1").with_id("b"),
939 Heading::new(2, "H2-2").with_id("c"),
940 Heading::new(3, "H3").with_id("d"),
941 ];
942 let outline = build_outline(&headings);
943 assert_eq!(outline.len(), 1);
944 assert_eq!(outline[0].heading.text, "H1");
945 assert_eq!(outline[0].children.len(), 2);
946 assert_eq!(outline[0].children[0].children.len(), 0);
947 assert_eq!(outline[0].children[1].children.len(), 1);
948 assert_eq!(outline[0].children[1].children[0].heading.text, "H3");
949 }
950
951 #[test]
952 fn test_build_outline_multiple_h1() {
953 let headings = vec![
954 Heading::new(1, "第一部分"),
955 Heading::new(2, "细节"),
956 Heading::new(1, "第二部分"),
957 ];
958 let outline = build_outline(&headings);
959 assert_eq!(outline.len(), 2);
960 assert_eq!(outline[0].heading.text, "第一部分");
961 assert_eq!(outline[1].heading.text, "第二部分");
962 }
963
964 #[test]
967 fn test_extract_paragraphs_basic() {
968 let html = r"<html><body>
969 <p>第一段文字内容。</p>
970 <p>第二段文字内容,长度足够长。</p>
971 <p>短</p>
972 </body></html>";
973 let doc = create_document(html);
974 let paragraphs = extract_paragraphs(&doc, 5);
975 assert_eq!(paragraphs.len(), 2);
976 assert!(paragraphs[0].contains("第一段"));
977 }
978
979 #[test]
980 fn test_extract_paragraphs_empty() {
981 let html = r"<html><body></body></html>";
982 let doc = create_document(html);
983 let paragraphs = extract_paragraphs(&doc, 1);
984 assert!(paragraphs.is_empty());
985 }
986
987 #[test]
990 fn test_extract_lists_unordered() {
991 let html = r"<html><body>
992 <ul>
993 <li>苹果</li>
994 <li>香蕉</li>
995 <li>樱桃</li>
996 </ul>
997 </body></html>";
998 let doc = create_document(html);
999 let lists = extract_lists(&doc, &default_config());
1000 assert_eq!(lists.len(), 1);
1001 assert_eq!(lists[0].list_type, ListType::Unordered);
1002 assert_eq!(lists[0].items.len(), 3);
1003 assert_eq!(lists[0].items[0].text, "苹果");
1004 }
1005
1006 #[test]
1007 fn test_extract_lists_ordered() {
1008 let html = r"<html><body>
1009 <ol>
1010 <li>第一</li>
1011 <li>第二</li>
1012 </ol>
1013 </body></html>";
1014 let doc = create_document(html);
1015 let lists = extract_lists(&doc, &default_config());
1016 assert_eq!(lists.len(), 1);
1017 assert_eq!(lists[0].list_type, ListType::Ordered);
1018 }
1019
1020 #[test]
1021 fn test_extract_list_with_nested() {
1022 let html = r"<html><body>
1023 <ul>
1024 <li>
1025 水果
1026 <ul>
1027 <li>苹果</li>
1028 <li>香蕉</li>
1029 </ul>
1030 </li>
1031 <li>蔬菜</li>
1032 </ul>
1033 </body></html>";
1034 let doc = create_document(html);
1035 let lists = extract_lists(&doc, &default_config());
1036 assert_eq!(lists.len(), 1);
1037 assert_eq!(lists[0].items.len(), 2);
1038 let nested = &lists[0].items[0];
1039 assert!(nested.nested.is_some());
1040 let inner = nested.nested.as_ref().unwrap();
1041 assert_eq!(inner.items.len(), 2);
1042 assert_eq!(inner.items[0].text, "苹果");
1043 }
1044
1045 #[test]
1046 fn test_extract_list_skips_empty_items() {
1047 let html = r"<html><body>
1048 <ul>
1049 <li></li>
1050 <li>有效项</li>
1051 </ul>
1052 </body></html>";
1053 let doc = create_document(html);
1054 let lists = extract_lists(&doc, &default_config());
1055 assert_eq!(lists[0].items.len(), 1);
1056 }
1057
1058 #[test]
1059 fn test_extract_definition_list_basic() {
1060 let html = r"<html><body>
1061 <dl>
1062 <dt>HTML</dt>
1063 <dd>超文本标记语言</dd>
1064 <dt>CSS</dt>
1065 <dd>层叠样式表</dd>
1066 </dl>
1067 </body></html>";
1068 let doc = create_document(html);
1069 let lists = extract_lists(&doc, &default_config());
1070 assert_eq!(lists.len(), 1);
1071 assert_eq!(lists[0].list_type, ListType::Definition);
1072 assert_eq!(lists[0].items.len(), 2);
1073 assert!(lists[0].items[0].text.contains("HTML"));
1074 assert!(lists[0].items[0].text.contains("超文本标记语言"));
1075 }
1076
1077 #[test]
1078 fn test_extract_definition_list_dt_only() {
1079 let html = r"<html><body>
1080 <dl>
1081 <dt>术语一</dt>
1082 <dt>术语二</dt>
1083 </dl>
1084 </body></html>";
1085 let doc = create_document(html);
1086 let lists = extract_lists(&doc, &default_config());
1087 assert!(lists.is_empty() || lists[0].items.is_empty());
1088 }
1089
1090 #[test]
1093 fn test_extract_tables_basic() {
1094 let html = r"<html><body>
1095 <table>
1096 <thead>
1097 <tr><th>姓名</th><th>年龄</th></tr>
1098 </thead>
1099 <tbody>
1100 <tr><td>张三</td><td>25</td></tr>
1101 <tr><td>李四</td><td>30</td></tr>
1102 </tbody>
1103 </table>
1104 </body></html>";
1105 let doc = create_document(html);
1106 let tables = extract_tables(&doc, &default_config());
1107 assert_eq!(tables.len(), 1);
1108 assert_eq!(tables[0].headers.len(), 1);
1109 assert_eq!(tables[0].rows.len(), 2);
1110 assert_eq!(tables[0].column_count, 2);
1111 assert_eq!(tables[0].rows[0].cells[0].content, "张三");
1112 }
1113
1114 #[test]
1115 fn test_extract_tables_no_thead() {
1116 let html = r"<html><body>
1117 <table>
1118 <tr><td>A</td><td>B</td></tr>
1119 <tr><td>C</td><td>D</td></tr>
1120 </table>
1121 </body></html>";
1122 let doc = create_document(html);
1123 let tables = extract_tables(&doc, &default_config());
1124 assert_eq!(tables.len(), 1);
1125 assert_eq!(tables[0].rows.len(), 2);
1126 }
1127
1128 #[test]
1129 fn test_extract_table_cell_with_colspan() {
1130 let html = r#"<html><body><table>
1131 <tr><td colspan="2">合并单元格</td></tr>
1132 </table></body></html>"#;
1133 let doc = create_document(html);
1134 let tables = extract_tables(&doc, &default_config());
1135 assert_eq!(tables[0].rows[0].cells[0].colspan, 2);
1136 }
1137
1138 #[test]
1139 fn test_extract_tables_empty() {
1140 let doc = create_document("<html></html>");
1141 let tables = extract_tables(&doc, &default_config());
1142 assert!(tables.is_empty());
1143 }
1144
1145 #[test]
1146 fn test_extract_table_with_caption() {
1147 let html = r"<html><body><table>
1148 <caption>用户信息表</caption>
1149 <tr><th>ID</th><th>名称</th></tr>
1150 </table></body></html>";
1151 let doc = create_document(html);
1152 let tables = extract_tables(&doc, &default_config());
1153 assert_eq!(tables[0].caption.as_deref(), Some("用户信息表"));
1154 }
1155
1156 #[test]
1159 fn test_extract_code_blocks_pre_code() {
1160 let html = r#"<html><body>
1161 <pre><code class="language-rust">fn main() { println!("hello"); }</code></pre>
1162 </body></html>"#;
1163 let doc = create_document(html);
1164 let blocks = extract_code_blocks(&doc, &default_config());
1165 assert_eq!(blocks.len(), 1);
1166 assert!(!blocks[0].is_inline);
1167 assert_eq!(blocks[0].language.as_deref(), Some("rust"));
1168 assert!(blocks[0].code.contains("fn main"));
1169 }
1170
1171 #[test]
1172 fn test_extract_code_blocks_inline() {
1173 let html = r"<html><body>
1174 <p>使用 <code>println!</code> 宏输出</p>
1175 </body></html>";
1176 let doc = create_document(html);
1177 let blocks = extract_code_blocks(&doc, &default_config());
1178 let inline_blocks: Vec<_> = blocks.iter().filter(|b| b.is_inline).collect();
1179 assert!(!inline_blocks.is_empty());
1180 }
1181
1182 #[test]
1183 fn test_extract_code_blocks_skips_inline_inside_pre() {
1184 let html = r"<html><body>
1185 <pre><code>块级代码</code></pre>
1186 <p><code>内联代码</code></p>
1187 </body></html>";
1188 let doc = create_document(html);
1189 let blocks = extract_code_blocks(&doc, &default_config());
1190 let inline_blocks: Vec<_> = blocks.iter().filter(|b| b.is_inline).collect();
1191 let pre_blocks: Vec<_> = blocks.iter().filter(|b| !b.is_inline).collect();
1192 assert_eq!(pre_blocks.len(), 1);
1193 assert_eq!(inline_blocks.len(), 1);
1194 }
1195
1196 #[test]
1197 fn test_extract_code_block_with_data_lang() {
1198 let html = r#"<pre><code data-lang="python">print("hello")</code></pre>"#;
1199 let doc = create_document(html);
1200 let blocks = extract_code_blocks(&doc, &default_config());
1201 assert_eq!(blocks[0].language.as_deref(), Some("python"));
1202 }
1203
1204 #[test]
1205 fn test_extract_code_block_with_filename() {
1206 let html = r#"<pre><code data-filename="main.rs">fn main() {}</code></pre>"#;
1207 let doc = create_document(html);
1208 let blocks = extract_code_blocks(&doc, &default_config());
1209 assert_eq!(blocks[0].filename.as_deref(), Some("main.rs"));
1210 }
1211
1212 #[test]
1213 fn test_is_known_language_positive() {
1214 assert!(is_known_language("rust"));
1215 assert!(is_known_language("python"));
1216 assert!(is_known_language("javascript"));
1217 assert!(is_known_language("Rust"));
1218 assert!(is_known_language(" python "));
1219 }
1220
1221 #[test]
1222 fn test_is_known_language_negative() {
1223 assert!(!is_known_language("foobarbaz"));
1224 assert!(!is_known_language(""));
1225 assert!(!is_known_language(" "));
1226 }
1227
1228 #[test]
1231 fn test_extract_quotes_basic() {
1232 let html = r#"<html><body>
1233 <blockquote cite="https://example.com">
1234 这是一段引用文字。
1235 </blockquote>
1236 </body></html>"#;
1237 let doc = create_document(html);
1238 let quotes = extract_quotes(&doc, &default_config());
1239 assert_eq!(quotes.len(), 1);
1240 assert_eq!(quotes[0].text.trim(), "这是一段引用文字。");
1241 assert_eq!(quotes[0].cite.as_deref(), Some("https://example.com"));
1242 }
1243
1244 #[test]
1245 fn test_extract_quotes_with_inner_cite() {
1246 let html = r"<html><body>
1247 <blockquote>
1248 知识就是力量。
1249 <cite>弗朗西斯·培根</cite>
1250 </blockquote>
1251 </body></html>";
1252 let doc = create_document(html);
1253 let quotes = extract_quotes(&doc, &default_config());
1254 assert!(quotes[0].cite_url.is_some());
1255 }
1256
1257 #[test]
1258 fn test_extract_quotes_filters_short() {
1259 let html = r"<html><body>
1260 <blockquote>短</blockquote>
1261 <blockquote>这是一段足够长的引用文字,长度超过十个字符。</blockquote>
1262 </body></html>";
1263 let doc = create_document(html);
1264 let quotes = extract_quotes(&doc, &default_config());
1265 assert_eq!(quotes.len(), 1);
1266 }
1267
1268 #[test]
1269 fn test_extract_quotes_empty() {
1270 let doc = create_document("<html></html>");
1271 let quotes = extract_quotes(&doc, &default_config());
1272 assert!(quotes.is_empty());
1273 }
1274
1275 #[test]
1278 fn test_extract_images_basic() {
1279 let html = r#"<html><body>
1280 <img src="/images/photo.jpg" alt="风景照" width="800" height="600">
1281 </body></html>"#;
1282 let base = Url::parse("https://example.com").ok();
1283 let doc = create_document(html);
1284 let images = extract_images(&doc, base.as_ref());
1285 assert_eq!(images.len(), 1);
1286 assert_eq!(images[0].src, "/images/photo.jpg");
1287 assert_eq!(images[0].alt, "风景照");
1288 assert_eq!(images[0].width, Some(800));
1289 assert_eq!(images[0].height, Some(600));
1290 }
1291
1292 #[test]
1293 fn test_extract_images_resolves_url() {
1294 let html = r#"<html><body><img src="/images/pic.jpg" alt="图片"></body></html>"#;
1295 let base = Url::parse("https://example.com/blog/").ok();
1296 let doc = create_document(html);
1297 let images = extract_images(&doc, base.as_ref());
1298 assert_eq!(
1299 images[0].url.as_deref(),
1300 Some("https://example.com/images/pic.jpg")
1301 );
1302 }
1303
1304 #[test]
1305 fn test_extract_images_absolute_url() {
1306 let html = r#"<html><body>
1307 <img src="https://cdn.example.com/logo.png" alt="Logo">
1308 </body></html>"#;
1309 let base = Url::parse("https://example.com").ok();
1310 let doc = create_document(html);
1311 let images = extract_images(&doc, base.as_ref());
1312 assert_eq!(
1313 images[0].url.as_deref(),
1314 Some("https://cdn.example.com/logo.png")
1315 );
1316 }
1317
1318 #[test]
1319 fn test_extract_images_skips_data_uri() {
1320 let html = r#"<html><body>
1321 <img src="data:image/png;base64,abc123" alt="内嵌">
1322 </body></html>"#;
1323 let doc = create_document(html);
1324 let images = extract_images(&doc, None);
1325 assert!(images.is_empty());
1326 }
1327
1328 #[test]
1329 fn test_extract_images_no_src() {
1330 let html = r#"<html><body><img alt="无src"></body></html>"#;
1331 let doc = create_document(html);
1332 let images = extract_images(&doc, None);
1333 assert!(images.is_empty());
1334 }
1335
1336 #[test]
1337 fn test_extract_image_with_srcset() {
1338 let html = r#"<html><body>
1339 <img src="/img/photo.jpg" alt="响应式图片"
1340 srcset="/img/photo-400.jpg 400w, /img/photo-800.jpg 800w"
1341 sizes="(max-width: 600px) 400px, 800px"
1342 loading="lazy">
1343 </body></html>"#;
1344 let base = Url::parse("https://example.com").ok();
1345 let doc = create_document(html);
1346 let images = extract_images(&doc, base.as_ref());
1347 assert_eq!(images.len(), 1);
1348 assert!(images[0].srcset.is_some());
1349 assert!(images[0].sizes.is_some());
1350 assert_eq!(images[0].loading, ImageLoading::Lazy);
1351 }
1352
1353 #[test]
1354 fn test_extract_image_decorative() {
1355 let html = r#"<html><body><img src="/spacer.gif" alt=""></body></html>"#;
1356 let doc = create_document(html);
1357 let images = extract_images(&doc, None);
1358 assert!(images[0].is_decorative);
1359 }
1360
1361 #[test]
1362 fn test_resolve_image_url_absolute() {
1363 let base = Url::parse("https://example.com").unwrap();
1364 assert_eq!(
1365 resolve_image_url(&base, "https://other.com/img.jpg"),
1366 Some("https://other.com/img.jpg".to_string())
1367 );
1368 }
1369
1370 #[test]
1371 fn test_resolve_image_url_relative() {
1372 let base = Url::parse("https://example.com/blog/").unwrap();
1373 assert_eq!(
1374 resolve_image_url(&base, "img.jpg"),
1375 Some("https://example.com/blog/img.jpg".to_string())
1376 );
1377 }
1378
1379 #[test]
1380 fn test_resolve_image_url_root_relative() {
1381 let base = Url::parse("https://example.com/blog/").unwrap();
1382 assert_eq!(
1383 resolve_image_url(&base, "/images/img.jpg"),
1384 Some("https://example.com/images/img.jpg".to_string())
1385 );
1386 }
1387
1388 #[test]
1389 fn test_resolve_image_url_protocol_relative() {
1390 let base = Url::parse("https://example.com").unwrap();
1391 assert_eq!(
1392 resolve_image_url(&base, "//cdn.example.com/img.jpg"),
1393 Some("https://cdn.example.com/img.jpg".to_string())
1394 );
1395 }
1396}