use super::style::{self, IndentMap, MAX_INDENT_EM};
use scraper::{Html, Selector};
use std::sync::LazyLock;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TextSegment {
pub start: usize,
pub end: usize,
pub tag: String,
pub id: Option<String>,
pub src: Option<String>,
pub caption: Option<String>,
#[serde(default)]
pub indent: f32,
#[serde(default)]
pub styles: Vec<StyleRun>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StyleRun {
pub start: usize,
pub end: usize,
pub bold: bool,
pub italic: bool,
}
impl TextSegment {
pub fn contains(&self, i: usize) -> bool {
i >= self.start && i < self.end
}
}
const BLOCK_SELECTOR: &str =
"p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, div, figure, img, image";
const CONTAINER_TAGS: &[&str] = &["div", "blockquote"];
static BLOCK_SELECTOR_OBJ: LazyLock<Selector> =
LazyLock::new(|| Selector::parse(BLOCK_SELECTOR).expect("valid selector"));
static IMG_SELECTOR_OBJ: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("img").expect("selector"));
static CAP_SELECTOR_OBJ: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("figcaption").expect("selector"));
pub fn extract(xhtml: &str) -> (String, Vec<TextSegment>) {
extract_with_indents(xhtml, &IndentMap::new())
}
pub fn extract_with_indents(xhtml: &str, indents: &IndentMap) -> (String, Vec<TextSegment>) {
let html = Html::parse_fragment(xhtml);
let sel = BLOCK_SELECTOR_OBJ.clone();
let child_sel = BLOCK_SELECTOR_OBJ.clone();
let img_sel = IMG_SELECTOR_OBJ.clone();
let cap_sel = CAP_SELECTOR_OBJ.clone();
let mut text = String::new();
let mut segs: Vec<TextSegment> = Vec::new();
let mut figure_srcs: std::collections::HashSet<String> = std::collections::HashSet::new();
for elem in html.select(&sel) {
let tag = elem.value().name().to_string();
let pos = text.len();
let id = elem.value().attr("id").map(|s| s.to_string());
let seg = match tag.as_str() {
"figure" => extract_figure_segment(elem, &img_sel, &cap_sel, pos, id, &mut figure_srcs),
"img" => {
if figure_srcs.contains(elem.value().attr("src").unwrap_or("")) {
None
} else {
Some(TextSegment {
start: pos,
end: pos,
tag,
id,
src: elem.value().attr("src").map(|s| s.to_string()),
caption: None,
indent: 0.0,
styles: Vec::new(),
})
}
}
"image" => extract_svg_image_segment(elem, pos, id),
_ => extract_text_segment(elem, &child_sel, &mut text, pos, tag, id, &segs, indents),
};
if let Some(s) = seg {
segs.push(s);
}
}
let extra = scan_raw_svg_images(xhtml, &segs, &mut text);
segs.extend(extra);
(text, segs)
}
fn extract_figure_segment(
elem: scraper::ElementRef,
img_sel: &Selector,
cap_sel: &Selector,
pos: usize,
id: Option<String>,
figure_srcs: &mut std::collections::HashSet<String>,
) -> Option<TextSegment> {
let src = elem
.select(img_sel)
.next()
.and_then(|i| i.value().attr("src"))
.map(|s| {
figure_srcs.insert(s.to_string());
s.to_string()
});
let cap = elem
.select(cap_sel)
.next()
.map(|c| c.text().collect::<String>().trim().to_string());
Some(TextSegment {
start: pos,
end: pos,
tag: "figure".to_string(),
id,
src,
caption: cap,
indent: 0.0,
styles: Vec::new(),
})
}
fn extract_svg_image_segment(
elem: scraper::ElementRef,
pos: usize,
id: Option<String>,
) -> Option<TextSegment> {
let src = elem
.value()
.attr("xlink:href")
.or_else(|| elem.value().attr("href"))
.unwrap_or("");
if src.is_empty() {
return None;
}
Some(TextSegment {
start: pos,
end: pos,
tag: "image".to_string(),
id,
src: Some(src.to_string()),
caption: None,
indent: 0.0,
styles: Vec::new(),
})
}
fn extract_text_segment(
elem: scraper::ElementRef,
child_sel: &Selector,
text: &mut String,
pos: usize,
tag: String,
id: Option<String>,
segs: &[TextSegment],
indents: &IndentMap,
) -> Option<TextSegment> {
if CONTAINER_TAGS.contains(&tag.as_str()) && elem.select(child_sel).next().is_some() {
return None;
}
let raw: String = elem.text().collect();
let indent = if tag == "pre" {
0.0
} else {
block_indent_em(elem, &raw, indents)
};
let t: &str = if tag == "pre" {
raw.trim_matches('\n').trim_end()
} else {
raw.trim()
};
if t.is_empty() {
return None;
}
if !text.is_empty() {
text.push('\n');
}
let content_start = text.len();
text.push_str(t);
let end = text.len();
let dup = segs
.last()
.map(|s| text[s.start..s.end] == text[content_start..end])
.unwrap_or(false);
if dup {
text.truncate(pos);
return None;
}
let lead = raw.len() - raw.trim_start().len();
let styles = if tag == "pre" {
Vec::new()
} else {
collect_style_runs(elem)
.into_iter()
.filter_map(|(a, b, bold, italic)| {
let a = content_start + a.saturating_sub(lead);
let b = content_start + b.saturating_sub(lead);
let (a, b) = (a.max(content_start).min(end), b.max(content_start).min(end));
(a < b).then_some(StyleRun {
start: a,
end: b,
bold,
italic,
})
})
.collect()
};
Some(TextSegment {
start: content_start,
end,
tag,
id,
src: None,
caption: None,
indent,
styles,
})
}
fn is_bold_tag(name: &str) -> bool {
matches!(name, "b" | "strong")
}
fn is_italic_tag(name: &str) -> bool {
matches!(name, "i" | "em" | "cite" | "var")
}
fn collect_style_runs(elem: scraper::ElementRef) -> Vec<(usize, usize, bool, bool)> {
fn walk(
node: ego_tree::NodeRef<scraper::Node>,
bold: bool,
italic: bool,
pos: &mut usize,
out: &mut Vec<(usize, usize, bool, bool)>,
) {
for child in node.children() {
match child.value() {
scraper::Node::Text(t) => {
let start = *pos;
*pos += t.len();
if (bold || italic) && *pos > start {
out.push((start, *pos, bold, italic));
}
}
scraper::Node::Element(e) => {
let name = e.name();
walk(
child,
bold || is_bold_tag(name),
italic || is_italic_tag(name),
pos,
out,
);
}
_ => {}
}
}
}
let mut out = Vec::new();
let mut pos = 0usize;
walk(*elem, false, false, &mut pos, &mut out);
out.dedup_by(|b, a| {
if a.1 == b.0 && a.2 == b.2 && a.3 == b.3 {
a.1 = b.1;
true
} else {
false
}
});
out
}
const EM_PER_LEADING_SPACE: f32 = 0.5;
fn block_indent_em(elem: scraper::ElementRef, raw: &str, indents: &IndentMap) -> f32 {
let from_class = elem
.value()
.attr("class")
.map(|classes| {
classes
.split_whitespace()
.filter_map(|c| indents.get(c).copied())
.fold(0.0f32, f32::max)
})
.unwrap_or(0.0);
let from_style = elem
.value()
.attr("style")
.and_then(style::inline_indent_em)
.unwrap_or(0.0);
let leading = raw
.chars()
.take_while(|c| *c == ' ' || *c == '\u{00A0}' || *c == '\t')
.count() as f32;
(from_class.max(from_style) + leading * EM_PER_LEADING_SPACE).min(MAX_INDENT_EM)
}
fn scan_raw_svg_images(xhtml: &str, segs: &[TextSegment], text: &mut String) -> Vec<TextSegment> {
let captured_srcs: std::collections::HashSet<String> =
segs.iter().filter_map(|s| s.src.clone()).collect();
let mut extra: Vec<TextSegment> = Vec::new();
let mut search = 0;
while let Some(pos) = xhtml[search..].find("<image") {
let abs = search + pos;
let tag_end = xhtml[abs..]
.find('>')
.map(|p| abs + p)
.unwrap_or(xhtml.len());
let tag = &xhtml[abs..tag_end];
for attr in &["xlink:href", "href"] {
if let Some(eq) = tag.find(attr) {
let rest = &tag[eq + attr.len()..];
let rest = rest.trim_start();
if let Some(after_eq) = rest.strip_prefix('=') {
let v = after_eq.trim_start();
let q = v.chars().next().unwrap_or('"');
if (q == '"' || q == '\'') && v.len() > 1 {
if let Some(end) = v[1..].find(q) {
let src = &v[1..1 + end];
if !captured_srcs.contains(src) && !src.is_empty() {
extra.push(TextSegment {
start: text.len(),
end: text.len(),
tag: "image".to_string(),
id: None,
src: Some(src.to_string()),
caption: None,
indent: 0.0,
styles: Vec::new(),
});
}
break;
}
}
}
}
}
search = tag_end;
}
extra
}
pub fn segment_at(segs: &[TextSegment], i: usize) -> Option<&TextSegment> {
segs.iter().find(|s| s.contains(i))
}
#[cfg(test)]
mod tests;