use std::collections::HashMap;
use crate::Document;
use serde_json::Value;
use crate::TextSplitter;
pub struct HtmlHeaderTextSplitter {
headers_to_split_on: Vec<(String, String)>,
}
impl HtmlHeaderTextSplitter {
pub fn new(headers_to_split_on: Vec<(String, String)>) -> Self {
Self {
headers_to_split_on,
}
}
pub fn default_headers() -> Self {
Self::new(vec![
("h1".to_string(), "Header 1".to_string()),
("h2".to_string(), "Header 2".to_string()),
("h3".to_string(), "Header 3".to_string()),
])
}
pub fn split_html(&self, text: &str) -> Vec<Document> {
let mut documents = Vec::new();
let mut current_headers: HashMap<String, String> = HashMap::new();
let mut current_content = String::new();
let mut doc_index = 0;
let header_levels: HashMap<String, usize> = self
.headers_to_split_on
.iter()
.enumerate()
.map(|(i, (tag, _))| (tag.to_lowercase(), i))
.collect();
for line in text.lines() {
let trimmed = line.trim();
let mut matched = None;
for (tag, metadata_key) in &self.headers_to_split_on {
let open_tag = format!("<{}", tag.to_lowercase());
let trimmed_lower = trimmed.to_lowercase();
if trimmed_lower.starts_with(&open_tag) {
let header_text = extract_tag_content(trimmed, tag);
matched = Some((tag.clone(), metadata_key.clone(), header_text));
break;
}
}
if let Some((tag, metadata_key, header_text)) = matched {
let content = current_content.trim().to_string();
if !content.is_empty() {
let mut metadata: HashMap<String, Value> = current_headers
.iter()
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
.collect();
metadata.insert("chunk_index".to_string(), Value::Number(doc_index.into()));
documents.push(Document::with_metadata(
format!("chunk-{doc_index}"),
content,
metadata,
));
doc_index += 1;
}
let current_level = header_levels.get(&tag.to_lowercase()).copied().unwrap_or(0);
let keys_to_remove: Vec<String> = current_headers
.keys()
.filter(|k| {
self.headers_to_split_on
.iter()
.find(|(_, mk)| mk == *k)
.and_then(|(t, _)| header_levels.get(&t.to_lowercase()))
.map(|level| *level >= current_level)
.unwrap_or(false)
})
.cloned()
.collect();
for key in keys_to_remove {
current_headers.remove(&key);
}
current_headers.insert(metadata_key, header_text);
current_content.clear();
} else {
let stripped = strip_simple_tags(trimmed);
let stripped = stripped.trim();
if !stripped.is_empty() {
if !current_content.is_empty() {
current_content.push('\n');
}
current_content.push_str(stripped);
}
}
}
let content = current_content.trim().to_string();
if !content.is_empty() {
let mut metadata: HashMap<String, Value> = current_headers
.iter()
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
.collect();
metadata.insert("chunk_index".to_string(), Value::Number(doc_index.into()));
documents.push(Document::with_metadata(
format!("chunk-{doc_index}"),
content,
metadata,
));
}
documents
}
}
fn extract_tag_content(line: &str, tag: &str) -> String {
let close_tag = format!("</{}>", tag.to_lowercase());
if let Some(start) = line.find('>') {
let rest = &line[start + 1..];
let lower_rest = rest.to_lowercase();
if let Some(end) = lower_rest.find(&close_tag) {
return rest[..end].trim().to_string();
}
return rest.trim().to_string();
}
String::new()
}
fn strip_simple_tags(text: &str) -> String {
let mut result = String::new();
let mut in_tag = false;
for ch in text.chars() {
if ch == '<' {
in_tag = true;
} else if ch == '>' {
in_tag = false;
} else if !in_tag {
result.push(ch);
}
}
result
}
impl TextSplitter for HtmlHeaderTextSplitter {
fn split_text(&self, text: &str) -> Vec<String> {
self.split_html(text)
.into_iter()
.map(|d| d.content)
.collect()
}
}