1use super::style::{self, IndentMap, MAX_INDENT_EM};
6use scraper::{Html, Selector};
7use std::sync::LazyLock;
8
9#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
13pub struct TextSegment {
14 pub start: usize,
16 pub end: usize,
18 pub tag: String,
20 pub id: Option<String>,
22 pub src: Option<String>,
24 pub caption: Option<String>,
26 #[serde(default)]
31 pub indent: f32,
32 #[serde(default)]
34 pub styles: Vec<StyleRun>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45pub struct StyleRun {
46 pub start: usize,
47 pub end: usize,
48 pub bold: bool,
49 pub italic: bool,
50}
51
52impl TextSegment {
53 pub fn contains(&self, i: usize) -> bool {
55 i >= self.start && i < self.end
56 }
57}
58
59const BLOCK_SELECTOR: &str =
62 "p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, div, figure, img, image";
63const CONTAINER_TAGS: &[&str] = &["div", "blockquote"];
64
65static BLOCK_SELECTOR_OBJ: LazyLock<Selector> =
66 LazyLock::new(|| Selector::parse(BLOCK_SELECTOR).expect("valid selector"));
67static IMG_SELECTOR_OBJ: LazyLock<Selector> =
68 LazyLock::new(|| Selector::parse("img").expect("selector"));
69static CAP_SELECTOR_OBJ: LazyLock<Selector> =
70 LazyLock::new(|| Selector::parse("figcaption").expect("selector"));
71
72pub fn extract(xhtml: &str) -> (String, Vec<TextSegment>) {
76 extract_with_indents(xhtml, &IndentMap::new())
77}
78
79pub fn extract_with_indents(xhtml: &str, indents: &IndentMap) -> (String, Vec<TextSegment>) {
86 let html = Html::parse_fragment(xhtml);
87 let sel = BLOCK_SELECTOR_OBJ.clone();
88 let child_sel = BLOCK_SELECTOR_OBJ.clone();
89 let img_sel = IMG_SELECTOR_OBJ.clone();
90 let cap_sel = CAP_SELECTOR_OBJ.clone();
91
92 let mut text = String::new();
93 let mut segs: Vec<TextSegment> = Vec::new();
94 let mut figure_srcs: std::collections::HashSet<String> = std::collections::HashSet::new();
95
96 for elem in html.select(&sel) {
97 let tag = elem.value().name().to_string();
98 let pos = text.len();
99 let id = elem.value().attr("id").map(|s| s.to_string());
100
101 let seg = match tag.as_str() {
102 "figure" => extract_figure_segment(elem, &img_sel, &cap_sel, pos, id, &mut figure_srcs),
103 "img" => {
104 if figure_srcs.contains(elem.value().attr("src").unwrap_or("")) {
105 None
106 } else {
107 Some(TextSegment {
108 start: pos,
109 end: pos,
110 tag,
111 id,
112 src: elem.value().attr("src").map(|s| s.to_string()),
113 caption: None,
114 indent: 0.0,
115 styles: Vec::new(),
116 })
117 }
118 }
119 "image" => extract_svg_image_segment(elem, pos, id),
120 _ => extract_text_segment(elem, &child_sel, &mut text, pos, tag, id, &segs, indents),
121 };
122 if let Some(s) = seg {
123 segs.push(s);
124 }
125 }
126
127 let extra = scan_raw_svg_images(xhtml, &segs, &mut text);
128 segs.extend(extra);
129
130 (text, segs)
131}
132
133fn extract_figure_segment(
134 elem: scraper::ElementRef,
135 img_sel: &Selector,
136 cap_sel: &Selector,
137 pos: usize,
138 id: Option<String>,
139 figure_srcs: &mut std::collections::HashSet<String>,
140) -> Option<TextSegment> {
141 let src = elem
142 .select(img_sel)
143 .next()
144 .and_then(|i| i.value().attr("src"))
145 .map(|s| {
146 figure_srcs.insert(s.to_string());
147 s.to_string()
148 });
149 let cap = elem
150 .select(cap_sel)
151 .next()
152 .map(|c| c.text().collect::<String>().trim().to_string());
153 Some(TextSegment {
154 start: pos,
155 end: pos,
156 tag: "figure".to_string(),
157 id,
158 src,
159 caption: cap,
160 indent: 0.0,
161 styles: Vec::new(),
162 })
163}
164
165fn extract_svg_image_segment(
166 elem: scraper::ElementRef,
167 pos: usize,
168 id: Option<String>,
169) -> Option<TextSegment> {
170 let src = elem
171 .value()
172 .attr("xlink:href")
173 .or_else(|| elem.value().attr("href"))
174 .unwrap_or("");
175 if src.is_empty() {
176 return None;
177 }
178 Some(TextSegment {
179 start: pos,
180 end: pos,
181 tag: "image".to_string(),
182 id,
183 src: Some(src.to_string()),
184 caption: None,
185 indent: 0.0,
186 styles: Vec::new(),
187 })
188}
189
190fn extract_text_segment(
191 elem: scraper::ElementRef,
192 child_sel: &Selector,
193 text: &mut String,
194 pos: usize,
195 tag: String,
196 id: Option<String>,
197 segs: &[TextSegment],
198 indents: &IndentMap,
199) -> Option<TextSegment> {
200 if CONTAINER_TAGS.contains(&tag.as_str()) && elem.select(child_sel).next().is_some() {
201 return None;
202 }
203 let raw: String = elem.text().collect();
204 let indent = if tag == "pre" {
207 0.0
208 } else {
209 block_indent_em(elem, &raw, indents)
210 };
211 let t: &str = if tag == "pre" {
215 raw.trim_matches('\n').trim_end()
216 } else {
217 raw.trim()
218 };
219 if t.is_empty() {
220 return None;
221 }
222 if !text.is_empty() {
223 text.push('\n');
224 }
225 let content_start = text.len();
226 text.push_str(t);
227 let end = text.len();
228 let dup = segs
229 .last()
230 .map(|s| text[s.start..s.end] == text[content_start..end])
231 .unwrap_or(false);
232 if dup {
233 text.truncate(pos);
234 return None;
235 }
236 let lead = raw.len() - raw.trim_start().len();
239 let styles = if tag == "pre" {
240 Vec::new()
241 } else {
242 collect_style_runs(elem)
243 .into_iter()
244 .filter_map(|(a, b, bold, italic)| {
245 let a = content_start + a.saturating_sub(lead);
246 let b = content_start + b.saturating_sub(lead);
247 let (a, b) = (a.max(content_start).min(end), b.max(content_start).min(end));
248 (a < b).then_some(StyleRun {
249 start: a,
250 end: b,
251 bold,
252 italic,
253 })
254 })
255 .collect()
256 };
257 Some(TextSegment {
258 start: content_start,
259 end,
260 tag,
261 id,
262 src: None,
263 caption: None,
264 indent,
265 styles,
266 })
267}
268
269fn is_bold_tag(name: &str) -> bool {
270 matches!(name, "b" | "strong")
271}
272
273fn is_italic_tag(name: &str) -> bool {
274 matches!(name, "i" | "em" | "cite" | "var")
275}
276
277fn collect_style_runs(elem: scraper::ElementRef) -> Vec<(usize, usize, bool, bool)> {
283 fn walk(
284 node: ego_tree::NodeRef<scraper::Node>,
285 bold: bool,
286 italic: bool,
287 pos: &mut usize,
288 out: &mut Vec<(usize, usize, bool, bool)>,
289 ) {
290 for child in node.children() {
291 match child.value() {
292 scraper::Node::Text(t) => {
293 let start = *pos;
294 *pos += t.len();
295 if (bold || italic) && *pos > start {
296 out.push((start, *pos, bold, italic));
297 }
298 }
299 scraper::Node::Element(e) => {
300 let name = e.name();
301 walk(
302 child,
303 bold || is_bold_tag(name),
304 italic || is_italic_tag(name),
305 pos,
306 out,
307 );
308 }
309 _ => {}
310 }
311 }
312 }
313 let mut out = Vec::new();
314 let mut pos = 0usize;
315 walk(*elem, false, false, &mut pos, &mut out);
316 out.dedup_by(|b, a| {
318 if a.1 == b.0 && a.2 == b.2 && a.3 == b.3 {
319 a.1 = b.1;
320 true
321 } else {
322 false
323 }
324 });
325 out
326}
327
328const EM_PER_LEADING_SPACE: f32 = 0.5;
336
337fn block_indent_em(elem: scraper::ElementRef, raw: &str, indents: &IndentMap) -> f32 {
343 let from_class = elem
344 .value()
345 .attr("class")
346 .map(|classes| {
347 classes
348 .split_whitespace()
349 .filter_map(|c| indents.get(c).copied())
350 .fold(0.0f32, f32::max)
351 })
352 .unwrap_or(0.0);
353 let from_style = elem
354 .value()
355 .attr("style")
356 .and_then(style::inline_indent_em)
357 .unwrap_or(0.0);
358 let leading = raw
359 .chars()
360 .take_while(|c| *c == ' ' || *c == '\u{00A0}' || *c == '\t')
361 .count() as f32;
362 (from_class.max(from_style) + leading * EM_PER_LEADING_SPACE).min(MAX_INDENT_EM)
363}
364
365fn scan_raw_svg_images(xhtml: &str, segs: &[TextSegment], text: &mut String) -> Vec<TextSegment> {
366 let captured_srcs: std::collections::HashSet<String> =
367 segs.iter().filter_map(|s| s.src.clone()).collect();
368 let mut extra: Vec<TextSegment> = Vec::new();
369 let mut search = 0;
370 while let Some(pos) = xhtml[search..].find("<image") {
371 let abs = search + pos;
372 let tag_end = xhtml[abs..]
373 .find('>')
374 .map(|p| abs + p)
375 .unwrap_or(xhtml.len());
376 let tag = &xhtml[abs..tag_end];
377 for attr in &["xlink:href", "href"] {
378 if let Some(eq) = tag.find(attr) {
379 let rest = &tag[eq + attr.len()..];
380 let rest = rest.trim_start();
381 if let Some(after_eq) = rest.strip_prefix('=') {
382 let v = after_eq.trim_start();
383 let q = v.chars().next().unwrap_or('"');
384 if (q == '"' || q == '\'') && v.len() > 1 {
385 if let Some(end) = v[1..].find(q) {
386 let src = &v[1..1 + end];
387 if !captured_srcs.contains(src) && !src.is_empty() {
388 extra.push(TextSegment {
389 start: text.len(),
390 end: text.len(),
391 tag: "image".to_string(),
392 id: None,
393 src: Some(src.to_string()),
394 caption: None,
395 indent: 0.0,
396 styles: Vec::new(),
397 });
398 }
399 break;
400 }
401 }
402 }
403 }
404 }
405 search = tag_end;
406 }
407 extra
408}
409
410pub fn segment_at(segs: &[TextSegment], i: usize) -> Option<&TextSegment> {
412 segs.iter().find(|s| s.contains(i))
413}
414#[cfg(test)]
415mod tests;