use super::style::{self, BookStyle, IndentMap, MAX_INDENT_EM};
use scraper::{Html, Selector};
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ListKind {
Unordered,
Ordered(u32),
Unmarked,
}
pub const LIST_INDENT_EM: f32 = 1.5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum BlockquoteKind {
#[default]
None,
Leaf,
Children,
}
#[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>,
#[serde(default)]
pub list: Option<ListKind>,
#[serde(default)]
pub list_depth: u32,
#[serde(default)]
pub blockquote: BlockquoteKind,
#[serde(default)]
pub links: Vec<LinkRun>,
#[serde(default)]
pub svg: Option<String>,
#[serde(default)]
pub code_indent: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LinkRun {
pub start: usize,
pub end: usize,
pub href: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StyleRun {
pub start: usize,
pub end: usize,
pub bold: bool,
pub italic: bool,
#[serde(default)]
pub link: bool,
}
impl TextSegment {
pub fn contains(&self, i: usize) -> bool {
i >= self.start && i < self.end
}
pub fn list_marker(&self) -> Option<String> {
match self.list.as_ref()? {
ListKind::Unordered => Some("\u{2022} ".to_string()),
ListKind::Ordered(n) => Some(format!("{n}. ")),
ListKind::Unmarked => None,
}
}
}
const BLOCK_SELECTOR: &str =
"p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, div, figure, img, image, svg, table";
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"));
static SVG_IMAGE_SELECTOR_OBJ: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("image").expect("selector"));
static SVG_SELECTOR_OBJ: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("svg").expect("selector"));
pub fn extract(xhtml: &str) -> (String, Vec<TextSegment>) {
extract_with_style(xhtml, &BookStyle::new())
}
pub fn extract_with_indents(xhtml: &str, indents: &IndentMap) -> (String, Vec<TextSegment>) {
extract_with_style(xhtml, &BookStyle::from_indents(indents.clone()))
}
pub fn extract_with_style(xhtml: &str, style: &BookStyle) -> (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();
let mut inline_svg_seq = 0usize;
for elem in html.select(&sel) {
let tag = elem.value().name().to_string();
let id = elem.value().attr("id").map(|s| s.to_string());
if tag != "figure" && has_ancestor(elem, &["figcaption"]) {
continue;
}
if tag != "table" && has_ancestor(elem, &["table"]) {
continue;
}
let pos = text.len();
let produced: Vec<TextSegment> = match tag.as_str() {
"figure" => extract_figure_segment(
elem,
&img_sel,
&cap_sel,
pos,
id,
&mut figure_srcs,
&mut inline_svg_seq,
)
.into_iter()
.collect(),
"img" => {
if figure_srcs.contains(elem.value().attr("src").unwrap_or("")) {
Vec::new()
} else {
vec![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(),
list: None,
list_depth: 0,
blockquote: BlockquoteKind::None,
svg: None,
code_indent: false,
links: Vec::new(),
}]
}
}
"image" => extract_svg_image_segment(elem, pos, id)
.into_iter()
.collect(),
"svg" => extract_inline_svg_segment(elem, pos, id, &mut inline_svg_seq)
.into_iter()
.collect(),
"table" => extract_table_segments(elem, &mut text, id),
_ => extract_text_segment(elem, &child_sel, &mut text, pos, tag, id, &segs, style)
.into_iter()
.collect(),
};
segs.extend(produced);
}
resolve_blockquote_context(&mut segs, &html);
let extra = scan_raw_svg_images(xhtml, &segs, &mut text);
segs.extend(extra);
collect_orphan_text(&html, &mut text, &mut segs);
(text, segs)
}
static BODY_SELECTOR_OBJ: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("body, html").expect("selector"));
static BLOCK_SELECTOR_TAGS: std::sync::LazyLock<std::collections::HashSet<&'static str>> =
std::sync::LazyLock::new(|| {
[
"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "pre", "div",
"figure", "img", "image", "svg", "table",
]
.into_iter()
.collect()
});
static NON_CONTENT_TAGS: std::sync::LazyLock<std::collections::HashSet<&'static str>> =
std::sync::LazyLock::new(|| {
[
"script", "style", "link", "meta", "head", "title", "noscript", "template",
"base",
]
.into_iter()
.collect()
});
fn collect_orphan_text(html: &Html, text: &mut String, segs: &mut Vec<TextSegment>) {
let body_sel = BODY_SELECTOR_OBJ.clone();
let Some(root) = html.select(&body_sel).next() else {
return;
};
let root_ref = scraper::ElementRef::wrap(*root).unwrap_or(root);
fn walk_orphans(node: ego_tree::NodeRef<scraper::Node>) -> Vec<String> {
let mut parts: Vec<String> = Vec::new();
for child in node.children() {
match child.value() {
scraper::Node::Text(t) => {
let trimmed = t.trim();
if !trimmed.is_empty() {
parts.push(trimmed.to_string());
}
}
scraper::Node::Element(e) => {
if BLOCK_SELECTOR_TAGS.contains(e.name())
|| NON_CONTENT_TAGS.contains(e.name())
{
continue;
}
let owned = own_text_inner(child);
let trimmed = owned.trim();
if !trimmed.is_empty() {
parts.push(trimmed.to_string());
}
}
_ => {}
}
}
parts
}
let gap_texts = walk_orphans(*root_ref);
if gap_texts.is_empty() {
return;
}
let total_gap: usize = gap_texts.iter().map(|s| s.len()).sum();
log::warn!(
"extract: orphan text collected (captured {} chars, injected {} chars in {} gaps)",
text.len(),
total_gap,
gap_texts.len(),
);
for gap in gap_texts {
let pos = text.len();
text.push_str(&gap);
segs.push(TextSegment {
start: pos,
end: pos + gap.len(),
tag: "div".to_string(),
id: None,
src: None,
caption: None,
indent: 0.0,
styles: Vec::new(),
list: None,
list_depth: 0,
blockquote: BlockquoteKind::None,
svg: None,
code_indent: false,
links: Vec::new(),
});
}
}
fn own_text_inner(node: ego_tree::NodeRef<scraper::Node>) -> String {
let mut out = String::new();
for child in node.children() {
match child.value() {
scraper::Node::Text(t) => out.push_str(t),
scraper::Node::Element(e)
if !is_block_tag(e.name()) && !NON_CONTENT_TAGS.contains(e.name()) =>
{
out.push_str(&own_text_inner(child));
}
_ => {}
}
}
out
}
fn has_ancestor(elem: scraper::ElementRef, names: &[&str]) -> bool {
elem.ancestors()
.any(|a| matches!(a.value(), scraper::Node::Element(e) if names.contains(&e.name())))
}
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>,
seq: &mut usize,
) -> Option<TextSegment> {
let mut src = elem
.select(img_sel)
.next()
.and_then(|i| i.value().attr("src"))
.or_else(|| {
elem.select(&SVG_IMAGE_SELECTOR_OBJ)
.next()
.and_then(image_href)
})
.map(|s| {
figure_srcs.insert(s.to_string());
s.to_string()
});
let mut svg = None;
if src.is_none() {
if let Some(markup) = elem
.select(&SVG_SELECTOR_OBJ)
.next()
.and_then(inline_svg_markup)
{
src = Some(inline_svg_key(seq));
svg = Some(markup);
}
}
let cap = elem
.select(cap_sel)
.next()
.map(|c| {
c.text()
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
})
.filter(|c| !c.is_empty());
if src.is_none() && cap.is_none() {
return None;
}
Some(TextSegment {
start: pos,
end: pos,
tag: "figure".to_string(),
id,
src,
caption: cap,
indent: 0.0,
styles: Vec::new(),
list: None,
list_depth: 0,
blockquote: BlockquoteKind::None,
svg,
code_indent: false,
links: Vec::new(),
})
}
fn image_href<'a>(elem: scraper::ElementRef<'a>) -> Option<&'a str> {
elem.value()
.attrs()
.find(|(name, value)| *name == "href" && !value.trim().is_empty())
.map(|(_, value)| value)
}
fn inline_svg_markup(elem: scraper::ElementRef) -> Option<String> {
if elem.select(&SVG_IMAGE_SELECTOR_OBJ).next().is_some() {
return None;
}
if !elem.children().any(|c| c.value().is_element()) {
return None;
}
Some(with_svg_namespace(&elem.html()))
}
fn with_svg_namespace(block: &str) -> String {
if block.contains("xmlns") {
return block.to_string();
}
match block.find(|c: char| c.is_ascii_whitespace() || c == '>') {
Some(i) => format!(
"{} xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"{}",
&block[..i],
&block[i..]
),
None => block.to_string(),
}
}
fn inline_svg_key(seq: &mut usize) -> String {
let key = format!("#inline-svg-{seq}");
*seq += 1;
key
}
fn extract_inline_svg_segment(
elem: scraper::ElementRef,
pos: usize,
id: Option<String>,
seq: &mut usize,
) -> Option<TextSegment> {
if has_ancestor(elem, &["figure"]) {
return None;
}
let markup = inline_svg_markup(elem)?;
Some(TextSegment {
start: pos,
end: pos,
tag: "image".to_string(),
id,
src: Some(inline_svg_key(seq)),
caption: None,
indent: 0.0,
styles: Vec::new(),
list: None,
list_depth: 0,
blockquote: BlockquoteKind::None,
svg: Some(markup),
code_indent: false,
links: Vec::new(),
})
}
fn extract_svg_image_segment(
elem: scraper::ElementRef,
pos: usize,
id: Option<String>,
) -> Option<TextSegment> {
let src = image_href(elem).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(),
list: None,
list_depth: 0,
blockquote: BlockquoteKind::None,
svg: None,
code_indent: false,
links: Vec::new(),
})
}
fn extract_text_segment(
elem: scraper::ElementRef,
child_sel: &Selector,
text: &mut String,
pos: usize,
tag: String,
id: Option<String>,
segs: &[TextSegment],
style_sheet: &BookStyle,
) -> Option<TextSegment> {
if CONTAINER_TAGS.contains(&tag.as_str()) && elem.select(child_sel).next().is_some() {
return None;
}
let indents = &style_sheet.indents;
let raw: String = if tag == "li" {
own_text(elem)
} else {
elem.text().collect()
};
let indent_from_block = if tag == "pre" {
0.0
} else {
block_indent_em(elem, &raw, indents)
};
let code_indent = tag != "pre" && tag != "li" && indent_from_block > 0.0;
let indent = indent_from_block;
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 rebase = |a: usize, b: usize| -> Option<(usize, usize)> {
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((a, b))
};
let (raw_styles, raw_links) = if tag == "pre" {
(Vec::new(), Vec::new())
} else {
collect_style_runs(elem)
};
let styles: Vec<StyleRun> = raw_styles
.into_iter()
.filter_map(|(a, b, bold, italic, link)| {
rebase(a, b).map(|(start, end)| StyleRun {
start,
end,
bold,
italic,
link,
})
})
.collect();
let links: Vec<LinkRun> = raw_links
.into_iter()
.filter_map(|(a, b, href)| rebase(a, b).map(|(start, end)| LinkRun { start, end, href }))
.collect();
let (list, list_depth) = list_context(elem, &tag, style_sheet);
let indent = if list.is_some() {
(indent + list_depth as f32 * LIST_INDENT_EM).min(MAX_INDENT_EM)
} else {
indent
};
Some(TextSegment {
start: content_start,
end,
tag,
id,
src: None,
caption: None,
indent,
styles,
list,
list_depth,
blockquote: BlockquoteKind::None,
svg: None,
code_indent,
links,
})
}
fn own_text(elem: scraper::ElementRef) -> String {
fn walk_own(node: ego_tree::NodeRef<scraper::Node>, out: &mut String) {
for child in node.children() {
match child.value() {
scraper::Node::Text(t) => out.push_str(t),
scraper::Node::Element(e) if !is_block_tag(e.name()) => walk_own(child, out),
_ => {}
}
}
}
let mut out = String::new();
walk_own(*elem, &mut out);
out
}
fn is_block_tag(name: &str) -> bool {
matches!(
name,
"p" | "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "li"
| "ul"
| "ol"
| "blockquote"
| "pre"
| "div"
| "figure"
| "img"
| "image"
| "table"
)
}
fn list_context(
elem: scraper::ElementRef,
tag: &str,
style_sheet: &BookStyle,
) -> (Option<ListKind>, u32) {
if tag != "li" {
return (None, 0);
}
let mut depth = 0u32;
let mut owner: Option<scraper::ElementRef> = None;
for anc in elem.ancestors() {
let Some(e) = scraper::ElementRef::wrap(anc) else {
continue;
};
if matches!(e.value().name(), "ul" | "ol") {
if owner.is_none() {
owner = Some(e);
} else {
depth += 1;
}
}
}
let Some(list_elem) = owner else {
return (None, 0);
};
if list_marker_suppressed(list_elem, style_sheet)
&& has_ancestor(list_elem, &["nav"])
{
return (Some(ListKind::Unmarked), depth);
}
if list_elem.value().name() == "ol" {
let start = list_elem
.value()
.attr("start")
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1);
let preceding = elem
.prev_siblings()
.filter(|n| matches!(n.value(), scraper::Node::Element(e) if e.name() == "li"))
.count() as u32;
(Some(ListKind::Ordered(start + preceding)), depth)
} else {
(Some(ListKind::Unordered), depth)
}
}
fn list_marker_suppressed(list_elem: scraper::ElementRef, style_sheet: &BookStyle) -> bool {
let by_class = list_elem
.value()
.attr("class")
.map(|classes| {
classes
.split_whitespace()
.any(|c| style_sheet.no_marker.contains(c))
})
.unwrap_or(false);
let by_inline = list_elem
.value()
.attr("style")
.map(style::inline_list_style_none)
.unwrap_or(false);
by_class || by_inline
}
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 is_link_tag(name: &str) -> bool {
name == "a"
}
struct StyleWalk {
runs: Vec<(usize, usize, bool, bool, bool)>,
links: Vec<(usize, usize, String)>,
}
fn walk(
node: ego_tree::NodeRef<scraper::Node>,
bold: bool,
italic: bool,
href: Option<&str>,
pos: &mut usize,
out: &mut StyleWalk,
) {
for child in node.children() {
match child.value() {
scraper::Node::Text(t) => {
let start = *pos;
*pos += t.len();
let link = href.is_some();
if (bold || italic || link) && *pos > start {
out.runs.push((start, *pos, bold, italic, link));
}
if let Some(h) = href {
if *pos > start {
out.links.push((start, *pos, h.to_string()));
}
}
}
scraper::Node::Element(e) => {
let name = e.name();
let child_href = if is_link_tag(name) {
e.attr("href").filter(|h| !h.trim().is_empty()).or(href)
} else {
href
};
walk(
child,
bold || is_bold_tag(name),
italic || is_italic_tag(name),
child_href,
pos,
out,
);
}
_ => {}
}
}
}
#[allow(clippy::type_complexity)]
fn collect_style_runs(
elem: scraper::ElementRef,
) -> (
Vec<(usize, usize, bool, bool, bool)>,
Vec<(usize, usize, String)>,
) {
let mut out = StyleWalk {
runs: Vec::new(),
links: Vec::new(),
};
let mut pos = 0usize;
walk(*elem, false, false, None, &mut pos, &mut out);
out.runs.dedup_by(|b, a| {
if a.1 == b.0 && a.2 == b.2 && a.3 == b.3 && a.4 == b.4 {
a.1 = b.1;
true
} else {
false
}
});
out.links.dedup_by(|b, a| {
if a.1 == b.0 && a.2 == b.2 {
a.1 = b.1;
true
} else {
false
}
});
(out.runs, out.links)
}
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)
}
static BQ_SELECTOR_OBJ: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("blockquote").expect("selector"));
static TR_SELECTOR_OBJ: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("tr").expect("selector"));
static TD_SELECTOR_OBJ: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("td, th").expect("selector"));
static TH_SELECTOR_OBJ: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("th").expect("selector"));
fn resolve_blockquote_context(segs: &mut [TextSegment], html: &Html) {
let bq_sel = BQ_SELECTOR_OBJ.clone();
let block_sel = BLOCK_SELECTOR_OBJ.clone();
for bq_elem in html.select(&bq_sel) {
let bq_children: Vec<_> = bq_elem.select(&block_sel).collect();
if bq_children.is_empty() {
continue;
}
let bq_text: String = bq_elem.text().collect();
let bq_trimmed = bq_text.trim();
if bq_trimmed.is_empty() {
continue;
}
let is_leaf = bq_children.len() == 1
&& bq_children[0].value().name() != "div"
&& bq_children[0].value().name() != "blockquote";
for seg in segs.iter_mut() {
if seg.tag == "blockquote" || seg.tag == "div" || seg.src.is_some() {
continue;
}
if seg.start >= bq_trimmed.len() {
continue;
}
let seg_text = if seg.end <= bq_trimmed.len() {
&bq_trimmed[seg.start..seg.end]
} else {
continue;
};
if seg_text.is_empty() {
continue;
}
if !bq_trimmed.contains(seg_text) {
continue;
}
seg.blockquote = if is_leaf {
BlockquoteKind::Leaf
} else {
BlockquoteKind::Children
};
}
}
}
fn extract_table_segments(
table: scraper::ElementRef,
text: &mut String,
id: Option<String>,
) -> Vec<TextSegment> {
let tr_sel = TR_SELECTOR_OBJ.clone();
let td_sel = TD_SELECTOR_OBJ.clone();
let rows: Vec<Vec<String>> = table
.select(&tr_sel)
.map(|tr| {
tr.select(&td_sel)
.map(|td| {
td.text()
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
})
.collect()
})
.filter(|cells: &Vec<String>| !cells.is_empty())
.collect();
if rows.is_empty() {
return Vec::new();
}
let has_th = table
.select(&tr_sel)
.next()
.map(|tr| tr.select(&TH_SELECTOR_OBJ).next().is_some())
.unwrap_or(false);
let uniform = rows.len() > 1 && rows[1..].iter().all(|r| r.len() == rows[0].len());
let (headers, body_rows) = if (has_th || uniform) && rows.len() > 1 {
(Some(&rows[0]), &rows[1..])
} else {
(None, &rows[..])
};
let mut out = Vec::new();
let mut push =
|content: &str, indent: f32, bold: bool, text: &mut String, id: &mut Option<String>| {
if content.is_empty() {
return;
}
if !text.is_empty() {
text.push('\n');
}
let start = text.len();
text.push_str(content);
let end = text.len();
let styles = if bold {
vec![StyleRun {
start,
end,
bold: true,
italic: false,
link: false,
}]
} else {
Vec::new()
};
out.push(TextSegment {
start,
end,
tag: "p".to_string(),
id: id.take(),
src: None,
caption: None,
indent,
styles,
list: None,
list_depth: 0,
blockquote: BlockquoteKind::None,
svg: None,
code_indent: false,
links: Vec::new(),
});
};
let mut pending_id = id;
for row in body_rows {
let mut cells = row.iter().enumerate();
if let Some((_, title)) = cells.next() {
let title = if title.is_empty() { "\u{2014}" } else { title };
push(title, 0.0, true, text, &mut pending_id);
}
for (ci, cell) in cells {
if cell.is_empty() {
continue;
}
let line = match headers.and_then(|h| h.get(ci)) {
Some(label) if !label.is_empty() => format!("{label}: {cell}"),
_ => cell.clone(),
};
push(&line, TABLE_CELL_INDENT_EM, false, text, &mut pending_id);
}
}
out
}
const TABLE_CELL_INDENT_EM: f32 = 1.5;
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(),
list: None,
list_depth: 0,
blockquote: BlockquoteKind::None,
svg: None,
code_indent: false,
links: 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;