1mod arena;
22pub mod element_ref;
23pub mod optimize;
24mod role_map;
25mod transform;
26mod tree_sink;
27
28pub use arena::{ArenaDom, ArenaNodeData};
29
30pub use crate::style::{Origin, Stylesheet};
32
33use html5ever::driver::ParseOpts;
34use html5ever::tendril::TendrilSink;
35
36use crate::model::Chapter;
37use tree_sink::ArenaSink;
38
39fn looks_like_xhtml(html: &str) -> bool {
43 let end = html.floor_char_boundary(500);
44 let prefix = &html[..end];
45 prefix.contains("<?xml") || prefix.contains("xmlns=")
46}
47
48pub(crate) fn parse_dom(html: &str) -> ArenaDom {
55 if looks_like_xhtml(html) {
56 let sink = ArenaSink::new();
57 let result =
58 xml5ever::driver::parse_document(sink, xml5ever::driver::XmlParseOpts::default())
59 .from_utf8()
60 .one(html.as_bytes());
61 let dom = result.into_dom();
62
63 if let Some(body) = dom.find_by_tag("body")
66 && dom.children(body).next().is_some()
67 {
68 return dom;
69 }
70 }
71
72 let sink = ArenaSink::new();
74 let result = html5ever::parse_document(sink, ParseOpts::default())
75 .from_utf8()
76 .one(html.as_bytes());
77 result.into_dom()
78}
79
80pub fn compile_html(html: &str, author_stylesheets: &[(Stylesheet, Origin)]) -> Chapter {
106 let dom = parse_dom(html);
107 let refs: Vec<(&Stylesheet, Origin)> =
108 author_stylesheets.iter().map(|(s, o)| (s, *o)).collect();
109 compile_dom(&dom, &refs)
110}
111
112pub(crate) fn compile_dom(dom: &ArenaDom, author_stylesheets: &[(&Stylesheet, Origin)]) -> Chapter {
118 let ua = transform::user_agent_stylesheet_arc();
120 let mut all_stylesheets: Vec<(&Stylesheet, Origin)> =
121 Vec::with_capacity(author_stylesheets.len() + 1);
122 all_stylesheets.push((ua.as_ref(), Origin::UserAgent));
123 all_stylesheets.extend_from_slice(author_stylesheets);
124
125 let mut chapter = transform::transform(dom, &all_stylesheets);
127
128 optimize::optimize(&mut chapter);
130
131 chapter
132}
133
134#[cfg(test)]
140pub(crate) fn compile_html_bytes(
141 html: &[u8],
142 author_stylesheets: &[(Stylesheet, Origin)],
143) -> Chapter {
144 let hint_encoding = crate::util::extract_xml_encoding(html);
146
147 let html_str = crate::util::decode_text(html, hint_encoding);
149
150 compile_html(&html_str, author_stylesheets)
151}
152
153#[cfg(test)]
158pub(crate) fn extract_stylesheets(html: &str) -> (Vec<String>, Vec<String>) {
159 extract_stylesheets_from_dom(&parse_dom(html))
160}
161
162pub(crate) fn extract_stylesheets_from_dom(dom: &ArenaDom) -> (Vec<String>, Vec<String>) {
167 let mut linked = Vec::new();
168 let mut inline = Vec::new();
169
170 let mut stack = vec![dom.document()];
172 while let Some(id) = stack.pop() {
173 if let Some(node) = dom.get(id)
174 && let ArenaNodeData::Element { name, attrs, .. } = &node.data
175 {
176 match name.local.as_ref() {
177 "link" => {
178 let is_stylesheet = attrs
179 .iter()
180 .any(|a| a.name.local.as_ref() == "rel" && a.value == "stylesheet");
181 if is_stylesheet
182 && let Some(href) = attrs
183 .iter()
184 .find(|a| a.name.local.as_ref() == "href")
185 .map(|a| a.value.clone())
186 {
187 linked.push(href);
188 }
189 }
190 "style" => {
191 let mut text = String::new();
193 for child in dom.children(id) {
194 if let Some(t) = dom.text_content(child) {
195 text.push_str(t);
196 }
197 }
198 if !text.trim().is_empty() {
199 inline.push(text);
200 }
201 }
202 _ => {}
203 }
204 }
205
206 let children: Vec<_> = dom.children(id).collect();
208 for child in children.into_iter().rev() {
209 stack.push(child);
210 }
211 }
212
213 (linked, inline)
214}
215
216pub fn resolve_path(base: &str, rel: &str) -> String {
250 use std::path::{Component, Path};
251
252 let rel_path = Path::new(rel);
253
254 if rel_path.has_root() {
256 return rel.trim_start_matches('/').to_string();
257 }
258
259 if rel.contains("://") || rel.starts_with("data:") {
261 return rel.to_string();
262 }
263
264 let base_path = Path::new(base);
266 let mut stack: Vec<&str> = base_path
267 .parent()
268 .unwrap_or(Path::new(""))
269 .components()
270 .filter_map(|c| {
271 if let Component::Normal(s) = c {
272 s.to_str()
273 } else {
274 None
275 }
276 })
277 .collect();
278
279 for component in rel_path.components() {
281 match component {
282 Component::ParentDir => {
283 stack.pop(); }
285 Component::Normal(c) => {
286 if let Some(s) = c.to_str() {
287 stack.push(s);
288 }
289 }
290 Component::CurDir => {} _ => {}
292 }
293 }
294
295 stack.join("/")
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::model::Role;
303
304 #[test]
305 fn deeply_nested_html_does_not_overflow_stack() {
306 let handle = std::thread::Builder::new()
312 .stack_size(2 * 1024 * 1024)
313 .spawn(|| {
314 let depth = 3000;
315 let mut html = String::from("<html><body>");
316 html.push_str(&"<div>".repeat(depth));
317 html.push_str("deep");
318 html.push_str(&"</div>".repeat(depth));
319 html.push_str("</body></html>");
320 compile_html(&html, &[]).node_count()
321 })
322 .unwrap();
323 assert!(handle.join().unwrap() > 0);
324 }
325
326 fn full_text(chapter: &Chapter) -> String {
328 let mut out = String::new();
329 for id in chapter.iter_dfs() {
330 let node = chapter.node(id).unwrap();
331 if node.role == Role::Text && !node.text.is_empty() {
332 out.push_str(chapter.text(node.text));
333 }
334 }
335 out
336 }
337
338 #[test]
339 fn pre_preserves_whitespace_only_text_nodes() {
340 let html =
345 "<html><body><pre><span>fn a()</span>\n <span>fn b()</span></pre></body></html>";
346 let chapter = compile_html(html, &[]);
347 assert_eq!(full_text(&chapter), "fn a()\n fn b()");
348 }
349
350 #[test]
351 fn whitespace_between_inline_siblings_in_div_is_kept() {
352 let chapter = compile_html(
355 "<html><body><div><i>A</i> <i>B</i></div></body></html>",
356 &[],
357 );
358 assert_eq!(full_text(&chapter), "A B");
359
360 let chapter = compile_html(
361 "<html><body><div><i>A</i>\n<i>B</i></div></body></html>",
362 &[],
363 );
364 assert_eq!(full_text(&chapter), "A B");
365 }
366
367 #[test]
368 fn hidden_inline_between_inline_siblings_yields_single_space() {
369 let html = "<html><body><div><i>A</i>\n<span style=\"display:none\">X</span>\n<i>B</i></div></body></html>";
375 let chapter = compile_html(html, &[]);
376 assert_eq!(full_text(&chapter), "A B");
377 }
378
379 #[test]
380 fn indentation_between_blocks_is_still_dropped() {
381 let html = "<html><body><div>\n <p>One</p>\n <p>Two</p>\n</div></body></html>";
382 let chapter = compile_html(html, &[]);
383 assert_eq!(full_text(&chapter), "OneTwo");
384 }
385
386 #[test]
387 fn inline_style_attribute_applies() {
388 let chapter = compile_html(
389 r#"<html><body><p style="font-weight: bold">x</p></body></html>"#,
390 &[],
391 );
392 for id in chapter.iter_dfs() {
393 let node = chapter.node(id).unwrap();
394 if node.role == Role::Paragraph {
395 let style = chapter.styles.get(node.style).unwrap();
396 assert_eq!(style.font_weight, crate::style::FontWeight::BOLD);
397 return;
398 }
399 }
400 panic!("paragraph not found");
401 }
402
403 #[test]
404 fn inline_style_beats_selector_specificity_but_not_important() {
405 let css = "p.x { color: #00ff00; } p.y { color: #0000ff !important; }";
406 let author = Stylesheet::parse(css);
407
408 let chapter = compile_html(
410 r#"<html><body><p class="x" style="color: #ff0000">x</p></body></html>"#,
411 &[(author.clone(), Origin::Author)],
412 );
413 for id in chapter.iter_dfs() {
414 let node = chapter.node(id).unwrap();
415 if node.role == Role::Paragraph {
416 let style = chapter.styles.get(node.style).unwrap();
417 assert_eq!(style.color, Some(crate::style::Color::rgb(255, 0, 0)));
418 }
419 }
420
421 let chapter = compile_html(
423 r#"<html><body><p class="y" style="color: #ff0000">x</p></body></html>"#,
424 &[(author, Origin::Author)],
425 );
426 for id in chapter.iter_dfs() {
427 let node = chapter.node(id).unwrap();
428 if node.role == Role::Paragraph {
429 let style = chapter.styles.get(node.style).unwrap();
430 assert_eq!(style.color, Some(crate::style::Color::rgb(0, 0, 255)));
431 }
432 }
433 }
434
435 #[test]
436 fn html_element_styles_inherit_into_body() {
437 let author = Stylesheet::parse("html { color: #123456; }");
438 let chapter = compile_html(
439 "<html><body><p>t</p></body></html>",
440 &[(author, Origin::Author)],
441 );
442 for id in chapter.iter_dfs() {
443 let node = chapter.node(id).unwrap();
444 if node.role == Role::Paragraph {
445 let style = chapter.styles.get(node.style).unwrap();
446 assert_eq!(
447 style.color,
448 Some(crate::style::Color::rgb(0x12, 0x34, 0x56))
449 );
450 return;
451 }
452 }
453 panic!("paragraph not found");
454 }
455
456 #[test]
457 fn font_shorthand_flows_through_cascade() {
458 let author = Stylesheet::parse("p { font: italic bold 14px/1.5 Georgia, serif; }");
461 let chapter = compile_html(
462 "<html><body><p>t</p></body></html>",
463 &[(author, Origin::Author)],
464 );
465 for id in chapter.iter_dfs() {
466 let node = chapter.node(id).unwrap();
467 if node.role == Role::Paragraph {
468 let style = chapter.styles.get(node.style).unwrap();
469 assert_eq!(style.font_style, crate::style::FontStyle::Italic);
470 assert_eq!(style.font_weight, crate::style::FontWeight::BOLD);
471 assert_eq!(style.font_size, crate::style::Length::Px(14.0));
472 assert_eq!(style.font_family.as_deref(), Some("Georgia, serif"));
473 return;
474 }
475 }
476 panic!("paragraph not found");
477 }
478
479 #[test]
480 fn test_compile_simple_html() {
481 let html = "<html><body><p>Test paragraph</p></body></html>";
482 let chapter = compile_html(html, &[]);
483
484 assert!(chapter.node_count() >= 3);
486
487 let mut found_text = false;
489 for id in chapter.iter_dfs() {
490 if chapter.node(id).unwrap().role == Role::Text {
491 found_text = true;
492 }
493 }
494 assert!(found_text);
495 }
496
497 #[test]
498 fn test_compile_with_css() {
499 let html = "<p class='highlight'>Styled</p>";
500 let css = ".highlight { font-weight: bold; }";
501
502 let author = Stylesheet::parse(css);
503 let chapter = compile_html(html, &[(author, Origin::Author)]);
504
505 for id in chapter.iter_dfs() {
507 let node = chapter.node(id).unwrap();
508 if node.role == Role::Paragraph {
509 let style = chapter.styles.get(node.style).unwrap();
510 if style.font_weight == crate::style::FontWeight::BOLD {
511 return; }
513 }
514 }
515 panic!("Styled paragraph not found");
516 }
517
518 #[test]
519 fn test_extract_stylesheets() {
520 let html = r#"
521 <html>
522 <head>
523 <link rel="stylesheet" href="styles.css">
524 <link rel="stylesheet" href="theme.css">
525 <style>p { color: red; }</style>
526 </head>
527 <body><p>Content</p></body>
528 </html>
529 "#;
530
531 let (linked, inline) = extract_stylesheets(html);
532
533 assert_eq!(linked.len(), 2);
534 assert!(linked.contains(&"styles.css".to_string()));
535 assert!(linked.contains(&"theme.css".to_string()));
536
537 assert_eq!(inline.len(), 1);
538 assert!(inline[0].contains("color: red"));
539 }
540
541 #[test]
542 fn test_compile_html_bytes() {
543 let html = b"<p>Bytes test</p>";
544 let chapter = compile_html_bytes(html, &[]);
545
546 assert!(chapter.node_count() > 1);
547 }
548
549 #[test]
550 fn test_resolve_path_parent_dir() {
551 assert_eq!(
552 resolve_path("OEBPS/text/ch1.html", "../images/logo.png"),
553 "OEBPS/images/logo.png"
554 );
555 }
556
557 #[test]
558 fn test_resolve_path_same_dir() {
559 assert_eq!(
560 resolve_path("OEBPS/content.html", "images/photo.jpg"),
561 "OEBPS/images/photo.jpg"
562 );
563 }
564
565 #[test]
566 fn test_resolve_path_absolute() {
567 assert_eq!(
568 resolve_path("ch1.html", "/images/absolute.png"),
569 "images/absolute.png"
570 );
571 }
572
573 #[test]
574 fn test_resolve_path_multiple_parent() {
575 assert_eq!(
576 resolve_path("a/b/c/file.html", "../../images/test.png"),
577 "a/images/test.png"
578 );
579 }
580
581 #[test]
582 fn test_resolve_path_current_dir() {
583 assert_eq!(
584 resolve_path("OEBPS/ch1.html", "./images/test.png"),
585 "OEBPS/images/test.png"
586 );
587 }
588
589 #[test]
590 fn test_optimizer_merges_sibling_text_nodes() {
591 let html = r#"
601 <html><body>
602 <p>Hello, <b>World</b>!</p>
603 </body></html>
604 "#;
605 let chapter = compile_html(html, &[]);
606
607 let mut text_content = String::new();
609 for id in chapter.iter_dfs() {
610 let node = chapter.node(id).unwrap();
611 if node.role == Role::Text && !node.text.is_empty() {
612 text_content.push_str(chapter.text(node.text));
613 }
614 }
615
616 assert!(
618 text_content.contains("Hello"),
619 "Missing 'Hello' in: {}",
620 text_content
621 );
622 assert!(
623 text_content.contains("World"),
624 "Missing 'World' in: {}",
625 text_content
626 );
627 }
628
629 #[test]
630 fn test_optimizer_preserves_tree_structure() {
631 let html = r#"
633 <html><body>
634 <p>First paragraph</p>
635 <p>Second paragraph</p>
636 </body></html>
637 "#;
638 let chapter = compile_html(html, &[]);
639
640 let mut text_content = String::new();
642 for id in chapter.iter_dfs() {
643 let node = chapter.node(id).unwrap();
644 if node.role == Role::Text && !node.text.is_empty() {
645 text_content.push_str(chapter.text(node.text));
646 }
647 }
648
649 assert!(
651 text_content.contains("First paragraph"),
652 "Missing 'First paragraph' in: {}",
653 text_content
654 );
655 assert!(
656 text_content.contains("Second paragraph"),
657 "Missing 'Second paragraph' in: {}",
658 text_content
659 );
660 }
661
662 #[test]
663 fn test_resolve_path_url_passthrough() {
664 assert_eq!(
665 resolve_path("ch1.html", "https://example.com/image.png"),
666 "https://example.com/image.png"
667 );
668 assert_eq!(
669 resolve_path("ch1.html", "data:image/png;base64,abc"),
670 "data:image/png;base64,abc"
671 );
672 }
673
674 #[test]
675 fn test_br_survives_optimizer() {
676 let chapter = compile_html(
678 r#"<html xmlns="http://www.w3.org/1999/xhtml">
679 <body>
680 <blockquote>
681 <p>
682 <span>Line 1</span>
683 <br/>
684 <span>Line 2</span>
685 </p>
686 </blockquote>
687 </body></html>"#,
688 &[],
689 );
690
691 let mut found_break = false;
693 for id in chapter.iter_dfs() {
694 if chapter.node(id).unwrap().role == Role::Break {
695 found_break = true;
696 break;
697 }
698 }
699 assert!(found_break, "Break node lost during optimization");
700 }
701
702 #[test]
703 fn test_xhtml_self_closing_script_preserves_content() {
704 let html = r#"<html xmlns="http://www.w3.org/1999/xhtml">
708 <head>
709 <script src="book.js"/>
710 </head>
711 <body><p>Hello World</p></body>
712 </html>"#;
713 let chapter = compile_html(html, &[]);
714
715 let mut found_text = false;
716 for id in chapter.iter_dfs() {
717 let node = chapter.node(id).unwrap();
718 if node.role == Role::Text && !node.text.is_empty() {
719 let text = chapter.text(node.text);
720 if text.contains("Hello World") {
721 found_text = true;
722 }
723 }
724 }
725 assert!(
726 found_text,
727 "Self-closing <script/> in XHTML swallowed body content"
728 );
729 }
730
731 #[test]
732 fn test_looks_like_xhtml() {
733 assert!(looks_like_xhtml(
734 r#"<?xml version="1.0"?><html><body>Hi</body></html>"#
735 ));
736 assert!(looks_like_xhtml(
737 r#"<html xmlns="http://www.w3.org/1999/xhtml"><body>Hi</body></html>"#
738 ));
739 assert!(!looks_like_xhtml(
740 "<html><body><p>Plain HTML</p></body></html>"
741 ));
742 }
743
744 #[test]
745 fn test_plain_html_still_works() {
746 let html = "<html><body><p>Plain HTML</p></body></html>";
748 let chapter = compile_html(html, &[]);
749
750 let mut found_text = false;
751 for id in chapter.iter_dfs() {
752 let node = chapter.node(id).unwrap();
753 if node.role == Role::Text && !node.text.is_empty() {
754 let text = chapter.text(node.text);
755 if text.contains("Plain HTML") {
756 found_text = true;
757 }
758 }
759 }
760 assert!(found_text, "Plain HTML content should be preserved");
761 }
762
763 #[test]
764 fn test_xhtml_extract_stylesheets() {
765 let html = r#"<html xmlns="http://www.w3.org/1999/xhtml">
767 <head>
768 <link rel="stylesheet" href="style.css"/>
769 <script src="book.js"/>
770 <style>p { color: red; }</style>
771 </head>
772 <body><p>Content</p></body>
773 </html>"#;
774
775 let (linked, inline) = extract_stylesheets(html);
776 assert_eq!(linked.len(), 1);
777 assert!(linked.contains(&"style.css".to_string()));
778 assert_eq!(inline.len(), 1);
779 assert!(inline[0].contains("color: red"));
780 }
781}