mod arena;
pub mod element_ref;
pub mod optimize;
mod role_map;
mod transform;
mod tree_sink;
pub use arena::{ArenaDom, ArenaNodeData};
pub use crate::style::{Origin, Stylesheet};
use html5ever::driver::ParseOpts;
use html5ever::tendril::TendrilSink;
use crate::model::Chapter;
use tree_sink::ArenaSink;
fn looks_like_xhtml(html: &str) -> bool {
let end = html.floor_char_boundary(500);
let prefix = &html[..end];
prefix.contains("<?xml") || prefix.contains("xmlns=")
}
pub(crate) fn parse_dom(html: &str) -> ArenaDom {
if looks_like_xhtml(html) {
let sink = ArenaSink::new();
let result =
xml5ever::driver::parse_document(sink, xml5ever::driver::XmlParseOpts::default())
.from_utf8()
.one(html.as_bytes());
let dom = result.into_dom();
if let Some(body) = dom.find_by_tag("body")
&& dom.children(body).next().is_some()
{
return dom;
}
}
let sink = ArenaSink::new();
let result = html5ever::parse_document(sink, ParseOpts::default())
.from_utf8()
.one(html.as_bytes());
result.into_dom()
}
pub fn compile_html(html: &str, author_stylesheets: &[(Stylesheet, Origin)]) -> Chapter {
let dom = parse_dom(html);
let refs: Vec<(&Stylesheet, Origin)> =
author_stylesheets.iter().map(|(s, o)| (s, *o)).collect();
compile_dom(&dom, &refs)
}
pub(crate) fn compile_dom(dom: &ArenaDom, author_stylesheets: &[(&Stylesheet, Origin)]) -> Chapter {
let ua = transform::user_agent_stylesheet_arc();
let mut all_stylesheets: Vec<(&Stylesheet, Origin)> =
Vec::with_capacity(author_stylesheets.len() + 1);
all_stylesheets.push((ua.as_ref(), Origin::UserAgent));
all_stylesheets.extend_from_slice(author_stylesheets);
let mut chapter = transform::transform(dom, &all_stylesheets);
optimize::optimize(&mut chapter);
chapter
}
#[cfg(test)]
pub(crate) fn compile_html_bytes(
html: &[u8],
author_stylesheets: &[(Stylesheet, Origin)],
) -> Chapter {
let hint_encoding = crate::util::extract_xml_encoding(html);
let html_str = crate::util::decode_text(html, hint_encoding);
compile_html(&html_str, author_stylesheets)
}
#[cfg(test)]
pub(crate) fn extract_stylesheets(html: &str) -> (Vec<String>, Vec<String>) {
extract_stylesheets_from_dom(&parse_dom(html))
}
pub(crate) fn extract_stylesheets_from_dom(dom: &ArenaDom) -> (Vec<String>, Vec<String>) {
let mut linked = Vec::new();
let mut inline = Vec::new();
let mut stack = vec![dom.document()];
while let Some(id) = stack.pop() {
if let Some(node) = dom.get(id)
&& let ArenaNodeData::Element { name, attrs, .. } = &node.data
{
match name.local.as_ref() {
"link" => {
let is_stylesheet = attrs
.iter()
.any(|a| a.name.local.as_ref() == "rel" && a.value == "stylesheet");
if is_stylesheet
&& let Some(href) = attrs
.iter()
.find(|a| a.name.local.as_ref() == "href")
.map(|a| a.value.clone())
{
linked.push(href);
}
}
"style" => {
let mut text = String::new();
for child in dom.children(id) {
if let Some(t) = dom.text_content(child) {
text.push_str(t);
}
}
if !text.trim().is_empty() {
inline.push(text);
}
}
_ => {}
}
}
let children: Vec<_> = dom.children(id).collect();
for child in children.into_iter().rev() {
stack.push(child);
}
}
(linked, inline)
}
pub fn resolve_path(base: &str, rel: &str) -> String {
use std::path::{Component, Path};
let rel_path = Path::new(rel);
if rel_path.has_root() {
return rel.trim_start_matches('/').to_string();
}
if rel.contains("://") || rel.starts_with("data:") {
return rel.to_string();
}
let base_path = Path::new(base);
let mut stack: Vec<&str> = base_path
.parent()
.unwrap_or(Path::new(""))
.components()
.filter_map(|c| {
if let Component::Normal(s) = c {
s.to_str()
} else {
None
}
})
.collect();
for component in rel_path.components() {
match component {
Component::ParentDir => {
stack.pop(); }
Component::Normal(c) => {
if let Some(s) = c.to_str() {
stack.push(s);
}
}
Component::CurDir => {} _ => {}
}
}
stack.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Role;
#[test]
fn deeply_nested_html_does_not_overflow_stack() {
let handle = std::thread::Builder::new()
.stack_size(2 * 1024 * 1024)
.spawn(|| {
let depth = 3000;
let mut html = String::from("<html><body>");
html.push_str(&"<div>".repeat(depth));
html.push_str("deep");
html.push_str(&"</div>".repeat(depth));
html.push_str("</body></html>");
compile_html(&html, &[]).node_count()
})
.unwrap();
assert!(handle.join().unwrap() > 0);
}
fn full_text(chapter: &Chapter) -> String {
let mut out = String::new();
for id in chapter.iter_dfs() {
let node = chapter.node(id).unwrap();
if node.role == Role::Text && !node.text.is_empty() {
out.push_str(chapter.text(node.text));
}
}
out
}
#[test]
fn pre_preserves_whitespace_only_text_nodes() {
let html =
"<html><body><pre><span>fn a()</span>\n <span>fn b()</span></pre></body></html>";
let chapter = compile_html(html, &[]);
assert_eq!(full_text(&chapter), "fn a()\n fn b()");
}
#[test]
fn whitespace_between_inline_siblings_in_div_is_kept() {
let chapter = compile_html(
"<html><body><div><i>A</i> <i>B</i></div></body></html>",
&[],
);
assert_eq!(full_text(&chapter), "A B");
let chapter = compile_html(
"<html><body><div><i>A</i>\n<i>B</i></div></body></html>",
&[],
);
assert_eq!(full_text(&chapter), "A B");
}
#[test]
fn hidden_inline_between_inline_siblings_yields_single_space() {
let html = "<html><body><div><i>A</i>\n<span style=\"display:none\">X</span>\n<i>B</i></div></body></html>";
let chapter = compile_html(html, &[]);
assert_eq!(full_text(&chapter), "A B");
}
#[test]
fn indentation_between_blocks_is_still_dropped() {
let html = "<html><body><div>\n <p>One</p>\n <p>Two</p>\n</div></body></html>";
let chapter = compile_html(html, &[]);
assert_eq!(full_text(&chapter), "OneTwo");
}
#[test]
fn inline_style_attribute_applies() {
let chapter = compile_html(
r#"<html><body><p style="font-weight: bold">x</p></body></html>"#,
&[],
);
for id in chapter.iter_dfs() {
let node = chapter.node(id).unwrap();
if node.role == Role::Paragraph {
let style = chapter.styles.get(node.style).unwrap();
assert_eq!(style.font_weight, crate::style::FontWeight::BOLD);
return;
}
}
panic!("paragraph not found");
}
#[test]
fn inline_style_beats_selector_specificity_but_not_important() {
let css = "p.x { color: #00ff00; } p.y { color: #0000ff !important; }";
let author = Stylesheet::parse(css);
let chapter = compile_html(
r#"<html><body><p class="x" style="color: #ff0000">x</p></body></html>"#,
&[(author.clone(), Origin::Author)],
);
for id in chapter.iter_dfs() {
let node = chapter.node(id).unwrap();
if node.role == Role::Paragraph {
let style = chapter.styles.get(node.style).unwrap();
assert_eq!(style.color, Some(crate::style::Color::rgb(255, 0, 0)));
}
}
let chapter = compile_html(
r#"<html><body><p class="y" style="color: #ff0000">x</p></body></html>"#,
&[(author, Origin::Author)],
);
for id in chapter.iter_dfs() {
let node = chapter.node(id).unwrap();
if node.role == Role::Paragraph {
let style = chapter.styles.get(node.style).unwrap();
assert_eq!(style.color, Some(crate::style::Color::rgb(0, 0, 255)));
}
}
}
#[test]
fn html_element_styles_inherit_into_body() {
let author = Stylesheet::parse("html { color: #123456; }");
let chapter = compile_html(
"<html><body><p>t</p></body></html>",
&[(author, Origin::Author)],
);
for id in chapter.iter_dfs() {
let node = chapter.node(id).unwrap();
if node.role == Role::Paragraph {
let style = chapter.styles.get(node.style).unwrap();
assert_eq!(
style.color,
Some(crate::style::Color::rgb(0x12, 0x34, 0x56))
);
return;
}
}
panic!("paragraph not found");
}
#[test]
fn font_shorthand_flows_through_cascade() {
let author = Stylesheet::parse("p { font: italic bold 14px/1.5 Georgia, serif; }");
let chapter = compile_html(
"<html><body><p>t</p></body></html>",
&[(author, Origin::Author)],
);
for id in chapter.iter_dfs() {
let node = chapter.node(id).unwrap();
if node.role == Role::Paragraph {
let style = chapter.styles.get(node.style).unwrap();
assert_eq!(style.font_style, crate::style::FontStyle::Italic);
assert_eq!(style.font_weight, crate::style::FontWeight::BOLD);
assert_eq!(style.font_size, crate::style::Length::Px(14.0));
assert_eq!(style.font_family.as_deref(), Some("Georgia, serif"));
return;
}
}
panic!("paragraph not found");
}
#[test]
fn test_compile_simple_html() {
let html = "<html><body><p>Test paragraph</p></body></html>";
let chapter = compile_html(html, &[]);
assert!(chapter.node_count() >= 3);
let mut found_text = false;
for id in chapter.iter_dfs() {
if chapter.node(id).unwrap().role == Role::Text {
found_text = true;
}
}
assert!(found_text);
}
#[test]
fn test_compile_with_css() {
let html = "<p class='highlight'>Styled</p>";
let css = ".highlight { font-weight: bold; }";
let author = Stylesheet::parse(css);
let chapter = compile_html(html, &[(author, Origin::Author)]);
for id in chapter.iter_dfs() {
let node = chapter.node(id).unwrap();
if node.role == Role::Paragraph {
let style = chapter.styles.get(node.style).unwrap();
if style.font_weight == crate::style::FontWeight::BOLD {
return; }
}
}
panic!("Styled paragraph not found");
}
#[test]
fn test_extract_stylesheets() {
let html = r#"
<html>
<head>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="theme.css">
<style>p { color: red; }</style>
</head>
<body><p>Content</p></body>
</html>
"#;
let (linked, inline) = extract_stylesheets(html);
assert_eq!(linked.len(), 2);
assert!(linked.contains(&"styles.css".to_string()));
assert!(linked.contains(&"theme.css".to_string()));
assert_eq!(inline.len(), 1);
assert!(inline[0].contains("color: red"));
}
#[test]
fn test_compile_html_bytes() {
let html = b"<p>Bytes test</p>";
let chapter = compile_html_bytes(html, &[]);
assert!(chapter.node_count() > 1);
}
#[test]
fn test_resolve_path_parent_dir() {
assert_eq!(
resolve_path("OEBPS/text/ch1.html", "../images/logo.png"),
"OEBPS/images/logo.png"
);
}
#[test]
fn test_resolve_path_same_dir() {
assert_eq!(
resolve_path("OEBPS/content.html", "images/photo.jpg"),
"OEBPS/images/photo.jpg"
);
}
#[test]
fn test_resolve_path_absolute() {
assert_eq!(
resolve_path("ch1.html", "/images/absolute.png"),
"images/absolute.png"
);
}
#[test]
fn test_resolve_path_multiple_parent() {
assert_eq!(
resolve_path("a/b/c/file.html", "../../images/test.png"),
"a/images/test.png"
);
}
#[test]
fn test_resolve_path_current_dir() {
assert_eq!(
resolve_path("OEBPS/ch1.html", "./images/test.png"),
"OEBPS/images/test.png"
);
}
#[test]
fn test_optimizer_merges_sibling_text_nodes() {
let html = r#"
<html><body>
<p>Hello, <b>World</b>!</p>
</body></html>
"#;
let chapter = compile_html(html, &[]);
let mut text_content = String::new();
for id in chapter.iter_dfs() {
let node = chapter.node(id).unwrap();
if node.role == Role::Text && !node.text.is_empty() {
text_content.push_str(chapter.text(node.text));
}
}
assert!(
text_content.contains("Hello"),
"Missing 'Hello' in: {}",
text_content
);
assert!(
text_content.contains("World"),
"Missing 'World' in: {}",
text_content
);
}
#[test]
fn test_optimizer_preserves_tree_structure() {
let html = r#"
<html><body>
<p>First paragraph</p>
<p>Second paragraph</p>
</body></html>
"#;
let chapter = compile_html(html, &[]);
let mut text_content = String::new();
for id in chapter.iter_dfs() {
let node = chapter.node(id).unwrap();
if node.role == Role::Text && !node.text.is_empty() {
text_content.push_str(chapter.text(node.text));
}
}
assert!(
text_content.contains("First paragraph"),
"Missing 'First paragraph' in: {}",
text_content
);
assert!(
text_content.contains("Second paragraph"),
"Missing 'Second paragraph' in: {}",
text_content
);
}
#[test]
fn test_resolve_path_url_passthrough() {
assert_eq!(
resolve_path("ch1.html", "https://example.com/image.png"),
"https://example.com/image.png"
);
assert_eq!(
resolve_path("ch1.html", "data:image/png;base64,abc"),
"data:image/png;base64,abc"
);
}
#[test]
fn test_br_survives_optimizer() {
let chapter = compile_html(
r#"<html xmlns="http://www.w3.org/1999/xhtml">
<body>
<blockquote>
<p>
<span>Line 1</span>
<br/>
<span>Line 2</span>
</p>
</blockquote>
</body></html>"#,
&[],
);
let mut found_break = false;
for id in chapter.iter_dfs() {
if chapter.node(id).unwrap().role == Role::Break {
found_break = true;
break;
}
}
assert!(found_break, "Break node lost during optimization");
}
#[test]
fn test_xhtml_self_closing_script_preserves_content() {
let html = r#"<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<script src="book.js"/>
</head>
<body><p>Hello World</p></body>
</html>"#;
let chapter = compile_html(html, &[]);
let mut found_text = false;
for id in chapter.iter_dfs() {
let node = chapter.node(id).unwrap();
if node.role == Role::Text && !node.text.is_empty() {
let text = chapter.text(node.text);
if text.contains("Hello World") {
found_text = true;
}
}
}
assert!(
found_text,
"Self-closing <script/> in XHTML swallowed body content"
);
}
#[test]
fn test_looks_like_xhtml() {
assert!(looks_like_xhtml(
r#"<?xml version="1.0"?><html><body>Hi</body></html>"#
));
assert!(looks_like_xhtml(
r#"<html xmlns="http://www.w3.org/1999/xhtml"><body>Hi</body></html>"#
));
assert!(!looks_like_xhtml(
"<html><body><p>Plain HTML</p></body></html>"
));
}
#[test]
fn test_plain_html_still_works() {
let html = "<html><body><p>Plain HTML</p></body></html>";
let chapter = compile_html(html, &[]);
let mut found_text = false;
for id in chapter.iter_dfs() {
let node = chapter.node(id).unwrap();
if node.role == Role::Text && !node.text.is_empty() {
let text = chapter.text(node.text);
if text.contains("Plain HTML") {
found_text = true;
}
}
}
assert!(found_text, "Plain HTML content should be preserved");
}
#[test]
fn test_xhtml_extract_stylesheets() {
let html = r#"<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<link rel="stylesheet" href="style.css"/>
<script src="book.js"/>
<style>p { color: red; }</style>
</head>
<body><p>Content</p></body>
</html>"#;
let (linked, inline) = extract_stylesheets(html);
assert_eq!(linked.len(), 1);
assert!(linked.contains(&"style.css".to_string()));
assert_eq!(inline.len(), 1);
assert!(inline[0].contains("color: red"));
}
}