1use super::style::{self, BookStyle, IndentMap, MAX_INDENT_EM};
6use scraper::{Html, Selector};
7use std::sync::LazyLock;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14pub enum ListKind {
15 Unordered,
16 Ordered(u32),
17 Unmarked,
21}
22
23pub const LIST_INDENT_EM: f32 = 1.5;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
29pub enum BlockquoteKind {
30 #[default]
31 None,
32 Leaf,
33 Children,
34}
35
36#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
37pub struct TextSegment {
38 pub start: usize,
40 pub end: usize,
42 pub tag: String,
44 pub id: Option<String>,
46 pub src: Option<String>,
48 pub caption: Option<String>,
50 #[serde(default)]
55 pub indent: f32,
56 #[serde(default)]
58 pub styles: Vec<StyleRun>,
59 #[serde(default)]
61 pub list: Option<ListKind>,
62 #[serde(default)]
64 pub list_depth: u32,
65 #[serde(default)]
67 pub blockquote: BlockquoteKind,
68 #[serde(default)]
70 pub links: Vec<LinkRun>,
71 #[serde(default)]
77 pub svg: Option<String>,
78 #[serde(default)]
85 pub code_indent: bool,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
94pub struct LinkRun {
95 pub start: usize,
96 pub end: usize,
97 pub href: String,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
109pub struct StyleRun {
110 pub start: usize,
111 pub end: usize,
112 pub bold: bool,
113 pub italic: bool,
114 #[serde(default)]
115 pub link: bool,
116}
117
118impl TextSegment {
119 pub fn contains(&self, i: usize) -> bool {
120 i >= self.start && i < self.end
121 }
122
123 pub fn list_marker(&self) -> Option<String> {
130 match self.list.as_ref()? {
131 ListKind::Unordered => Some("\u{2022} ".to_string()),
132 ListKind::Ordered(n) => Some(format!("{n}. ")),
133 ListKind::Unmarked => None,
134 }
135 }
136}
137
138const BLOCK_SELECTOR: &str =
141 "p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, div, figure, img, image, svg, table";
142const CONTAINER_TAGS: &[&str] = &["div", "blockquote"];
143
144static BLOCK_SELECTOR_OBJ: LazyLock<Selector> =
145 LazyLock::new(|| Selector::parse(BLOCK_SELECTOR).expect("valid selector"));
146static IMG_SELECTOR_OBJ: LazyLock<Selector> =
147 LazyLock::new(|| Selector::parse("img").expect("selector"));
148static CAP_SELECTOR_OBJ: LazyLock<Selector> =
149 LazyLock::new(|| Selector::parse("figcaption").expect("selector"));
150static SVG_IMAGE_SELECTOR_OBJ: LazyLock<Selector> =
151 LazyLock::new(|| Selector::parse("image").expect("selector"));
152static SVG_SELECTOR_OBJ: LazyLock<Selector> =
153 LazyLock::new(|| Selector::parse("svg").expect("selector"));
154
155pub fn extract(xhtml: &str) -> (String, Vec<TextSegment>) {
159 extract_with_style(xhtml, &BookStyle::new())
160}
161
162pub fn extract_with_indents(xhtml: &str, indents: &IndentMap) -> (String, Vec<TextSegment>) {
169 extract_with_style(xhtml, &BookStyle::from_indents(indents.clone()))
170}
171
172pub fn extract_with_style(xhtml: &str, style: &BookStyle) -> (String, Vec<TextSegment>) {
175 let html = Html::parse_fragment(xhtml);
176 let sel = BLOCK_SELECTOR_OBJ.clone();
177 let child_sel = BLOCK_SELECTOR_OBJ.clone();
178 let img_sel = IMG_SELECTOR_OBJ.clone();
179 let cap_sel = CAP_SELECTOR_OBJ.clone();
180
181 let mut text = String::new();
182 let mut segs: Vec<TextSegment> = Vec::new();
183 let mut figure_srcs: std::collections::HashSet<String> = std::collections::HashSet::new();
184 let mut inline_svg_seq = 0usize;
186
187 for elem in html.select(&sel) {
188 let tag = elem.value().name().to_string();
189 let id = elem.value().attr("id").map(|s| s.to_string());
190
191 if tag != "figure" && has_ancestor(elem, &["figcaption"]) {
195 continue;
196 }
197 if tag != "table" && has_ancestor(elem, &["table"]) {
198 continue;
199 }
200
201 let pos = text.len();
202 let produced: Vec<TextSegment> = match tag.as_str() {
203 "figure" => extract_figure_segment(
204 elem,
205 &img_sel,
206 &cap_sel,
207 pos,
208 id,
209 &mut figure_srcs,
210 &mut inline_svg_seq,
211 )
212 .into_iter()
213 .collect(),
214 "img" => {
215 if figure_srcs.contains(elem.value().attr("src").unwrap_or("")) {
216 Vec::new()
217 } else {
218 vec![TextSegment {
219 start: pos,
220 end: pos,
221 tag,
222 id,
223 src: elem.value().attr("src").map(|s| s.to_string()),
224 caption: None,
225 indent: 0.0,
226 styles: Vec::new(),
227 list: None,
228 list_depth: 0,
229 blockquote: BlockquoteKind::None,
230 svg: None,
231 code_indent: false,
232 links: Vec::new(),
233 }]
234 }
235 }
236 "image" => extract_svg_image_segment(elem, pos, id)
237 .into_iter()
238 .collect(),
239 "svg" => extract_inline_svg_segment(elem, pos, id, &mut inline_svg_seq)
240 .into_iter()
241 .collect(),
242 "table" => extract_table_segments(elem, &mut text, id),
243 _ => extract_text_segment(elem, &child_sel, &mut text, pos, tag, id, &segs, style)
244 .into_iter()
245 .collect(),
246 };
247 segs.extend(produced);
248 }
249
250 resolve_blockquote_context(&mut segs, &html);
251
252 let extra = scan_raw_svg_images(xhtml, &segs, &mut text);
253 segs.extend(extra);
254
255 collect_orphan_text(&html, &mut text, &mut segs);
256
257 (text, segs)
258}
259
260static BODY_SELECTOR_OBJ: LazyLock<Selector> =
261 LazyLock::new(|| Selector::parse("body, html").expect("selector"));
262
263static BLOCK_SELECTOR_TAGS: std::sync::LazyLock<std::collections::HashSet<&'static str>> =
266 std::sync::LazyLock::new(|| {
267 [
268 "p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "pre", "div",
269 "figure", "img", "image", "svg", "table",
270 ]
271 .into_iter()
272 .collect()
273 });
274
275static NON_CONTENT_TAGS: std::sync::LazyLock<std::collections::HashSet<&'static str>> =
281 std::sync::LazyLock::new(|| {
282 [
283 "script", "style", "link", "meta", "head", "title", "noscript", "template",
284 "base",
285 ]
286 .into_iter()
287 .collect()
288 });
289
290fn collect_orphan_text(html: &Html, text: &mut String, segs: &mut Vec<TextSegment>) {
299 let body_sel = BODY_SELECTOR_OBJ.clone();
300 let Some(root) = html.select(&body_sel).next() else {
301 return;
302 };
303
304 let root_ref = scraper::ElementRef::wrap(*root).unwrap_or(root);
305
306 fn walk_orphans(node: ego_tree::NodeRef<scraper::Node>) -> Vec<String> {
307 let mut parts: Vec<String> = Vec::new();
308 for child in node.children() {
309 match child.value() {
310 scraper::Node::Text(t) => {
311 let trimmed = t.trim();
312 if !trimmed.is_empty() {
313 parts.push(trimmed.to_string());
314 }
315 }
316 scraper::Node::Element(e) => {
317 if BLOCK_SELECTOR_TAGS.contains(e.name())
318 || NON_CONTENT_TAGS.contains(e.name())
319 {
320 continue;
321 }
322 let owned = own_text_inner(child);
323 let trimmed = owned.trim();
324 if !trimmed.is_empty() {
325 parts.push(trimmed.to_string());
326 }
327 }
328 _ => {}
329 }
330 }
331 parts
332 }
333
334 let gap_texts = walk_orphans(*root_ref);
335 if gap_texts.is_empty() {
336 return;
337 }
338
339 let total_gap: usize = gap_texts.iter().map(|s| s.len()).sum();
340 log::warn!(
341 "extract: orphan text collected (captured {} chars, injected {} chars in {} gaps)",
342 text.len(),
343 total_gap,
344 gap_texts.len(),
345 );
346
347 for gap in gap_texts {
348 let pos = text.len();
349 text.push_str(&gap);
350 segs.push(TextSegment {
351 start: pos,
352 end: pos + gap.len(),
353 tag: "div".to_string(),
354 id: None,
355 src: None,
356 caption: None,
357 indent: 0.0,
358 styles: Vec::new(),
359 list: None,
360 list_depth: 0,
361 blockquote: BlockquoteKind::None,
362 svg: None,
363 code_indent: false,
364 links: Vec::new(),
365 });
366 }
367}
368
369fn own_text_inner(node: ego_tree::NodeRef<scraper::Node>) -> String {
370 let mut out = String::new();
371 for child in node.children() {
372 match child.value() {
373 scraper::Node::Text(t) => out.push_str(t),
374 scraper::Node::Element(e)
375 if !is_block_tag(e.name()) && !NON_CONTENT_TAGS.contains(e.name()) =>
376 {
377 out.push_str(&own_text_inner(child));
378 }
379 _ => {}
380 }
381 }
382 out
383}
384
385fn has_ancestor(elem: scraper::ElementRef, names: &[&str]) -> bool {
387 elem.ancestors()
388 .any(|a| matches!(a.value(), scraper::Node::Element(e) if names.contains(&e.name())))
389}
390
391fn extract_figure_segment(
392 elem: scraper::ElementRef,
393 img_sel: &Selector,
394 cap_sel: &Selector,
395 pos: usize,
396 id: Option<String>,
397 figure_srcs: &mut std::collections::HashSet<String>,
398 seq: &mut usize,
399) -> Option<TextSegment> {
400 let mut src = elem
404 .select(img_sel)
405 .next()
406 .and_then(|i| i.value().attr("src"))
407 .or_else(|| {
408 elem.select(&SVG_IMAGE_SELECTOR_OBJ)
409 .next()
410 .and_then(image_href)
411 })
412 .map(|s| {
413 figure_srcs.insert(s.to_string());
414 s.to_string()
415 });
416 let mut svg = None;
419 if src.is_none() {
420 if let Some(markup) = elem
421 .select(&SVG_SELECTOR_OBJ)
422 .next()
423 .and_then(inline_svg_markup)
424 {
425 src = Some(inline_svg_key(seq));
426 svg = Some(markup);
427 }
428 }
429 let cap = elem
430 .select(cap_sel)
431 .next()
432 .map(|c| {
433 c.text()
434 .collect::<String>()
435 .split_whitespace()
436 .collect::<Vec<_>>()
437 .join(" ")
438 })
439 .filter(|c| !c.is_empty());
440 if src.is_none() && cap.is_none() {
442 return None;
443 }
444 Some(TextSegment {
445 start: pos,
446 end: pos,
447 tag: "figure".to_string(),
448 id,
449 src,
450 caption: cap,
451 indent: 0.0,
452 styles: Vec::new(),
453 list: None,
454 list_depth: 0,
455 blockquote: BlockquoteKind::None,
456 svg,
457 code_indent: false,
458 links: Vec::new(),
459 })
460}
461
462fn image_href<'a>(elem: scraper::ElementRef<'a>) -> Option<&'a str> {
469 elem.value()
470 .attrs()
471 .find(|(name, value)| *name == "href" && !value.trim().is_empty())
472 .map(|(_, value)| value)
473}
474
475fn inline_svg_markup(elem: scraper::ElementRef) -> Option<String> {
487 if elem.select(&SVG_IMAGE_SELECTOR_OBJ).next().is_some() {
491 return None;
492 }
493 if !elem.children().any(|c| c.value().is_element()) {
495 return None;
496 }
497 Some(with_svg_namespace(&elem.html()))
498}
499
500fn with_svg_namespace(block: &str) -> String {
505 if block.contains("xmlns") {
506 return block.to_string();
507 }
508 match block.find(|c: char| c.is_ascii_whitespace() || c == '>') {
509 Some(i) => format!(
510 "{} xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"{}",
511 &block[..i],
512 &block[i..]
513 ),
514 None => block.to_string(),
515 }
516}
517
518fn inline_svg_key(seq: &mut usize) -> String {
522 let key = format!("#inline-svg-{seq}");
523 *seq += 1;
524 key
525}
526
527fn extract_inline_svg_segment(
528 elem: scraper::ElementRef,
529 pos: usize,
530 id: Option<String>,
531 seq: &mut usize,
532) -> Option<TextSegment> {
533 if has_ancestor(elem, &["figure"]) {
536 return None;
537 }
538 let markup = inline_svg_markup(elem)?;
539 Some(TextSegment {
540 start: pos,
541 end: pos,
542 tag: "image".to_string(),
543 id,
544 src: Some(inline_svg_key(seq)),
545 caption: None,
546 indent: 0.0,
547 styles: Vec::new(),
548 list: None,
549 list_depth: 0,
550 blockquote: BlockquoteKind::None,
551 svg: Some(markup),
552 code_indent: false,
553 links: Vec::new(),
554 })
555}
556
557fn extract_svg_image_segment(
558 elem: scraper::ElementRef,
559 pos: usize,
560 id: Option<String>,
561) -> Option<TextSegment> {
562 let src = image_href(elem).unwrap_or("");
563 if src.is_empty() {
564 return None;
565 }
566 Some(TextSegment {
567 start: pos,
568 end: pos,
569 tag: "image".to_string(),
570 id,
571 src: Some(src.to_string()),
572 caption: None,
573 indent: 0.0,
574 styles: Vec::new(),
575 list: None,
576 list_depth: 0,
577 blockquote: BlockquoteKind::None,
578 svg: None,
579 code_indent: false,
580 links: Vec::new(),
581 })
582}
583
584fn extract_text_segment(
585 elem: scraper::ElementRef,
586 child_sel: &Selector,
587 text: &mut String,
588 pos: usize,
589 tag: String,
590 id: Option<String>,
591 segs: &[TextSegment],
592 style_sheet: &BookStyle,
593) -> Option<TextSegment> {
594 if CONTAINER_TAGS.contains(&tag.as_str()) && elem.select(child_sel).next().is_some() {
595 return None;
596 }
597 let indents = &style_sheet.indents;
598 let raw: String = if tag == "li" {
602 own_text(elem)
603 } else {
604 elem.text().collect()
605 };
606 let indent_from_block = if tag == "pre" {
609 0.0
610 } else {
611 block_indent_em(elem, &raw, indents)
612 };
613 let code_indent = tag != "pre" && tag != "li" && indent_from_block > 0.0;
619 let indent = indent_from_block;
620 let t: &str = if tag == "pre" {
624 raw.trim_matches('\n').trim_end()
625 } else {
626 raw.trim()
627 };
628 if t.is_empty() {
629 return None;
630 }
631 if !text.is_empty() {
632 text.push('\n');
633 }
634 let content_start = text.len();
635 text.push_str(t);
636 let end = text.len();
637 let dup = segs
638 .last()
639 .map(|s| text[s.start..s.end] == text[content_start..end])
640 .unwrap_or(false);
641 if dup {
642 text.truncate(pos);
643 return None;
644 }
645 let lead = raw.len() - raw.trim_start().len();
648 let rebase = |a: usize, b: usize| -> Option<(usize, usize)> {
649 let a = content_start + a.saturating_sub(lead);
650 let b = content_start + b.saturating_sub(lead);
651 let (a, b) = (a.max(content_start).min(end), b.max(content_start).min(end));
652 (a < b).then_some((a, b))
653 };
654 let (raw_styles, raw_links) = if tag == "pre" {
655 (Vec::new(), Vec::new())
656 } else {
657 collect_style_runs(elem)
658 };
659 let styles: Vec<StyleRun> = raw_styles
660 .into_iter()
661 .filter_map(|(a, b, bold, italic, link)| {
662 rebase(a, b).map(|(start, end)| StyleRun {
663 start,
664 end,
665 bold,
666 italic,
667 link,
668 })
669 })
670 .collect();
671 let links: Vec<LinkRun> = raw_links
672 .into_iter()
673 .filter_map(|(a, b, href)| rebase(a, b).map(|(start, end)| LinkRun { start, end, href }))
674 .collect();
675 let (list, list_depth) = list_context(elem, &tag, style_sheet);
676 let indent = if list.is_some() {
679 (indent + list_depth as f32 * LIST_INDENT_EM).min(MAX_INDENT_EM)
680 } else {
681 indent
682 };
683 Some(TextSegment {
684 start: content_start,
685 end,
686 tag,
687 id,
688 src: None,
689 caption: None,
690 indent,
691 styles,
692 list,
693 list_depth,
694 blockquote: BlockquoteKind::None,
695 svg: None,
696 code_indent,
697 links,
698 })
699}
700
701fn own_text(elem: scraper::ElementRef) -> String {
704 fn walk_own(node: ego_tree::NodeRef<scraper::Node>, out: &mut String) {
705 for child in node.children() {
706 match child.value() {
707 scraper::Node::Text(t) => out.push_str(t),
708 scraper::Node::Element(e) if !is_block_tag(e.name()) => walk_own(child, out),
709 _ => {}
710 }
711 }
712 }
713 let mut out = String::new();
714 walk_own(*elem, &mut out);
715 out
716}
717
718fn is_block_tag(name: &str) -> bool {
720 matches!(
721 name,
722 "p" | "h1"
723 | "h2"
724 | "h3"
725 | "h4"
726 | "h5"
727 | "h6"
728 | "li"
729 | "ul"
730 | "ol"
731 | "blockquote"
732 | "pre"
733 | "div"
734 | "figure"
735 | "img"
736 | "image"
737 | "table"
738 )
739}
740
741fn list_context(
748 elem: scraper::ElementRef,
749 tag: &str,
750 style_sheet: &BookStyle,
751) -> (Option<ListKind>, u32) {
752 if tag != "li" {
753 return (None, 0);
754 }
755 let mut depth = 0u32;
756 let mut owner: Option<scraper::ElementRef> = None;
757 for anc in elem.ancestors() {
758 let Some(e) = scraper::ElementRef::wrap(anc) else {
759 continue;
760 };
761 if matches!(e.value().name(), "ul" | "ol") {
762 if owner.is_none() {
763 owner = Some(e);
764 } else {
765 depth += 1;
766 }
767 }
768 }
769 let Some(list_elem) = owner else {
770 return (None, 0);
771 };
772 if list_marker_suppressed(list_elem, style_sheet)
773 && has_ancestor(list_elem, &["nav"])
774 {
775 return (Some(ListKind::Unmarked), depth);
776 }
777 if list_elem.value().name() == "ol" {
778 let start = list_elem
779 .value()
780 .attr("start")
781 .and_then(|s| s.parse::<u32>().ok())
782 .unwrap_or(1);
783 let preceding = elem
786 .prev_siblings()
787 .filter(|n| matches!(n.value(), scraper::Node::Element(e) if e.name() == "li"))
788 .count() as u32;
789 (Some(ListKind::Ordered(start + preceding)), depth)
790 } else {
791 (Some(ListKind::Unordered), depth)
792 }
793}
794
795fn list_marker_suppressed(list_elem: scraper::ElementRef, style_sheet: &BookStyle) -> bool {
807 let by_class = list_elem
808 .value()
809 .attr("class")
810 .map(|classes| {
811 classes
812 .split_whitespace()
813 .any(|c| style_sheet.no_marker.contains(c))
814 })
815 .unwrap_or(false);
816 let by_inline = list_elem
817 .value()
818 .attr("style")
819 .map(style::inline_list_style_none)
820 .unwrap_or(false);
821 by_class || by_inline
822}
823
824fn is_bold_tag(name: &str) -> bool {
825 matches!(name, "b" | "strong")
826}
827
828fn is_italic_tag(name: &str) -> bool {
829 matches!(name, "i" | "em" | "cite" | "var")
830}
831
832fn is_link_tag(name: &str) -> bool {
833 name == "a"
834}
835
836struct StyleWalk {
837 runs: Vec<(usize, usize, bool, bool, bool)>,
838 links: Vec<(usize, usize, String)>,
839}
840
841fn walk(
842 node: ego_tree::NodeRef<scraper::Node>,
843 bold: bool,
844 italic: bool,
845 href: Option<&str>,
846 pos: &mut usize,
847 out: &mut StyleWalk,
848) {
849 for child in node.children() {
850 match child.value() {
851 scraper::Node::Text(t) => {
852 let start = *pos;
853 *pos += t.len();
854 let link = href.is_some();
855 if (bold || italic || link) && *pos > start {
856 out.runs.push((start, *pos, bold, italic, link));
857 }
858 if let Some(h) = href {
859 if *pos > start {
860 out.links.push((start, *pos, h.to_string()));
861 }
862 }
863 }
864 scraper::Node::Element(e) => {
865 let name = e.name();
866 let child_href = if is_link_tag(name) {
869 e.attr("href").filter(|h| !h.trim().is_empty()).or(href)
870 } else {
871 href
872 };
873 walk(
874 child,
875 bold || is_bold_tag(name),
876 italic || is_italic_tag(name),
877 child_href,
878 pos,
879 out,
880 );
881 }
882 _ => {}
883 }
884 }
885}
886
887#[allow(clippy::type_complexity)]
893fn collect_style_runs(
894 elem: scraper::ElementRef,
895) -> (
896 Vec<(usize, usize, bool, bool, bool)>,
897 Vec<(usize, usize, String)>,
898) {
899 let mut out = StyleWalk {
900 runs: Vec::new(),
901 links: Vec::new(),
902 };
903 let mut pos = 0usize;
904 walk(*elem, false, false, None, &mut pos, &mut out);
905 out.runs.dedup_by(|b, a| {
906 if a.1 == b.0 && a.2 == b.2 && a.3 == b.3 && a.4 == b.4 {
907 a.1 = b.1;
908 true
909 } else {
910 false
911 }
912 });
913 out.links.dedup_by(|b, a| {
914 if a.1 == b.0 && a.2 == b.2 {
915 a.1 = b.1;
916 true
917 } else {
918 false
919 }
920 });
921 (out.runs, out.links)
922}
923
924const EM_PER_LEADING_SPACE: f32 = 0.5;
932
933fn block_indent_em(elem: scraper::ElementRef, raw: &str, indents: &IndentMap) -> f32 {
939 let from_class = elem
940 .value()
941 .attr("class")
942 .map(|classes| {
943 classes
944 .split_whitespace()
945 .filter_map(|c| indents.get(c).copied())
946 .fold(0.0f32, f32::max)
947 })
948 .unwrap_or(0.0);
949 let from_style = elem
950 .value()
951 .attr("style")
952 .and_then(style::inline_indent_em)
953 .unwrap_or(0.0);
954 let leading = raw
955 .chars()
956 .take_while(|c| *c == ' ' || *c == '\u{00A0}' || *c == '\t')
957 .count() as f32;
958 (from_class.max(from_style) + leading * EM_PER_LEADING_SPACE).min(MAX_INDENT_EM)
959}
960
961static BQ_SELECTOR_OBJ: LazyLock<Selector> =
962 LazyLock::new(|| Selector::parse("blockquote").expect("selector"));
963static TR_SELECTOR_OBJ: LazyLock<Selector> =
964 LazyLock::new(|| Selector::parse("tr").expect("selector"));
965static TD_SELECTOR_OBJ: LazyLock<Selector> =
966 LazyLock::new(|| Selector::parse("td, th").expect("selector"));
967static TH_SELECTOR_OBJ: LazyLock<Selector> =
968 LazyLock::new(|| Selector::parse("th").expect("selector"));
969
970fn resolve_blockquote_context(segs: &mut [TextSegment], html: &Html) {
971 let bq_sel = BQ_SELECTOR_OBJ.clone();
972 let block_sel = BLOCK_SELECTOR_OBJ.clone();
973 for bq_elem in html.select(&bq_sel) {
974 let bq_children: Vec<_> = bq_elem.select(&block_sel).collect();
975 if bq_children.is_empty() {
976 continue;
977 }
978 let bq_text: String = bq_elem.text().collect();
979 let bq_trimmed = bq_text.trim();
980 if bq_trimmed.is_empty() {
981 continue;
982 }
983 let is_leaf = bq_children.len() == 1
984 && bq_children[0].value().name() != "div"
985 && bq_children[0].value().name() != "blockquote";
986 for seg in segs.iter_mut() {
987 if seg.tag == "blockquote" || seg.tag == "div" || seg.src.is_some() {
988 continue;
989 }
990 if seg.start >= bq_trimmed.len() {
991 continue;
992 }
993 let seg_text = if seg.end <= bq_trimmed.len() {
994 &bq_trimmed[seg.start..seg.end]
995 } else {
996 continue;
997 };
998 if seg_text.is_empty() {
999 continue;
1000 }
1001 if !bq_trimmed.contains(seg_text) {
1002 continue;
1003 }
1004 seg.blockquote = if is_leaf {
1005 BlockquoteKind::Leaf
1006 } else {
1007 BlockquoteKind::Children
1008 };
1009 }
1010 }
1011}
1012
1013fn extract_table_segments(
1026 table: scraper::ElementRef,
1027 text: &mut String,
1028 id: Option<String>,
1029) -> Vec<TextSegment> {
1030 let tr_sel = TR_SELECTOR_OBJ.clone();
1031 let td_sel = TD_SELECTOR_OBJ.clone();
1032 let rows: Vec<Vec<String>> = table
1033 .select(&tr_sel)
1034 .map(|tr| {
1035 tr.select(&td_sel)
1036 .map(|td| {
1037 td.text()
1038 .collect::<String>()
1039 .split_whitespace()
1040 .collect::<Vec<_>>()
1041 .join(" ")
1042 })
1043 .collect()
1044 })
1045 .filter(|cells: &Vec<String>| !cells.is_empty())
1046 .collect();
1047 if rows.is_empty() {
1048 return Vec::new();
1049 }
1050
1051 let has_th = table
1055 .select(&tr_sel)
1056 .next()
1057 .map(|tr| tr.select(&TH_SELECTOR_OBJ).next().is_some())
1058 .unwrap_or(false);
1059 let uniform = rows.len() > 1 && rows[1..].iter().all(|r| r.len() == rows[0].len());
1060 let (headers, body_rows) = if (has_th || uniform) && rows.len() > 1 {
1061 (Some(&rows[0]), &rows[1..])
1062 } else {
1063 (None, &rows[..])
1064 };
1065
1066 let mut out = Vec::new();
1067 let mut push =
1068 |content: &str, indent: f32, bold: bool, text: &mut String, id: &mut Option<String>| {
1069 if content.is_empty() {
1070 return;
1071 }
1072 if !text.is_empty() {
1073 text.push('\n');
1074 }
1075 let start = text.len();
1076 text.push_str(content);
1077 let end = text.len();
1078 let styles = if bold {
1079 vec![StyleRun {
1080 start,
1081 end,
1082 bold: true,
1083 italic: false,
1084 link: false,
1085 }]
1086 } else {
1087 Vec::new()
1088 };
1089 out.push(TextSegment {
1090 start,
1091 end,
1092 tag: "p".to_string(),
1093 id: id.take(),
1096 src: None,
1097 caption: None,
1098 indent,
1099 styles,
1100 list: None,
1101 list_depth: 0,
1102 blockquote: BlockquoteKind::None,
1103 svg: None,
1104 code_indent: false,
1105 links: Vec::new(),
1106 });
1107 };
1108
1109 let mut pending_id = id;
1110 for row in body_rows {
1111 let mut cells = row.iter().enumerate();
1112 if let Some((_, title)) = cells.next() {
1114 let title = if title.is_empty() { "\u{2014}" } else { title };
1115 push(title, 0.0, true, text, &mut pending_id);
1116 }
1117 for (ci, cell) in cells {
1118 if cell.is_empty() {
1119 continue;
1120 }
1121 let line = match headers.and_then(|h| h.get(ci)) {
1122 Some(label) if !label.is_empty() => format!("{label}: {cell}"),
1123 _ => cell.clone(),
1124 };
1125 push(&line, TABLE_CELL_INDENT_EM, false, text, &mut pending_id);
1126 }
1127 }
1128 out
1129}
1130
1131const TABLE_CELL_INDENT_EM: f32 = 1.5;
1134
1135fn scan_raw_svg_images(xhtml: &str, segs: &[TextSegment], text: &mut String) -> Vec<TextSegment> {
1136 let captured_srcs: std::collections::HashSet<String> =
1137 segs.iter().filter_map(|s| s.src.clone()).collect();
1138 let mut extra: Vec<TextSegment> = Vec::new();
1139 let mut search = 0;
1140 while let Some(pos) = xhtml[search..].find("<image") {
1141 let abs = search + pos;
1142 let tag_end = xhtml[abs..]
1143 .find('>')
1144 .map(|p| abs + p)
1145 .unwrap_or(xhtml.len());
1146 let tag = &xhtml[abs..tag_end];
1147 for attr in &["xlink:href", "href"] {
1148 if let Some(eq) = tag.find(attr) {
1149 let rest = &tag[eq + attr.len()..];
1150 let rest = rest.trim_start();
1151 if let Some(after_eq) = rest.strip_prefix('=') {
1152 let v = after_eq.trim_start();
1153 let q = v.chars().next().unwrap_or('"');
1154 if (q == '"' || q == '\'') && v.len() > 1 {
1155 if let Some(end) = v[1..].find(q) {
1156 let src = &v[1..1 + end];
1157 if !captured_srcs.contains(src) && !src.is_empty() {
1158 extra.push(TextSegment {
1159 start: text.len(),
1160 end: text.len(),
1161 tag: "image".to_string(),
1162 id: None,
1163 src: Some(src.to_string()),
1164 caption: None,
1165 indent: 0.0,
1166 styles: Vec::new(),
1167 list: None,
1168 list_depth: 0,
1169 blockquote: BlockquoteKind::None,
1170 svg: None,
1171 code_indent: false,
1172 links: Vec::new(),
1173 });
1174 }
1175 break;
1176 }
1177 }
1178 }
1179 }
1180 }
1181 search = tag_end;
1182 }
1183 extra
1184}
1185
1186pub fn segment_at(segs: &[TextSegment], i: usize) -> Option<&TextSegment> {
1188 segs.iter().find(|s| s.contains(i))
1189}
1190#[cfg(test)]
1191mod tests;