Skip to main content

boko/dom/
mod.rs

1//! HTML to IR compiler pipeline.
2//!
3//! This module transforms HTML content with CSS stylesheets into the
4//! normalized IR (Intermediate Representation) format.
5//!
6//! # Example
7//!
8//! ```
9//! use boko::{compile_html, Stylesheet, Origin};
10//!
11//! let html = "<html><body><p>Hello, World!</p></body></html>";
12//! let css = "p { color: blue; }";
13//!
14//! let author_css = Stylesheet::parse(css);
15//! let chapter = compile_html(html, &[(author_css, Origin::Author)]);
16//!
17//! // The chapter now contains normalized IR nodes
18//! assert!(chapter.node_count() > 1);
19//! ```
20
21mod arena;
22pub mod element_ref;
23pub mod optimize;
24mod role_map;
25mod transform;
26mod tree_sink;
27
28pub use arena::{ArenaDom, ArenaNodeData};
29
30// Re-export style types for convenience
31pub use crate::style::{Origin, Stylesheet};
32
33use html5ever::driver::ParseOpts;
34use html5ever::tendril::TendrilSink;
35
36use crate::model::Chapter;
37use tree_sink::ArenaSink;
38
39/// Check if content looks like XHTML/XML based on the first ~500 bytes.
40///
41/// Checks for XML declaration (`<?xml`) or XHTML namespace (`xmlns=`).
42fn 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
48/// Parse HTML/XHTML into an ArenaDom.
49///
50/// Uses xml5ever for XHTML content (detected by `<?xml` or `xmlns=` in the
51/// first 500 bytes), falling back to html5ever for plain HTML. This correctly
52/// handles self-closing tags like `<script/>` which are valid in XHTML but
53/// cause content loss with HTML5 parsing.
54pub(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        // Verify xml5ever produced a usable tree (has a body with children).
64        // Fall through to html5ever if not.
65        if let Some(body) = dom.find_by_tag("body")
66            && dom.children(body).next().is_some()
67        {
68            return dom;
69        }
70    }
71
72    // Fallback: html5ever (permissive HTML5 parser)
73    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
80/// Compile HTML content to IR.
81///
82/// This is the main entry point for the compiler pipeline.
83/// Automatically detects XHTML and uses the appropriate parser.
84///
85/// # Arguments
86///
87/// * `html` - The HTML content to parse
88/// * `stylesheets` - Author stylesheets with their origins (user-agent stylesheet is added automatically)
89///
90/// # Returns
91///
92/// A `Chapter` containing the normalized content tree.
93///
94/// # Example
95///
96/// ```
97/// use boko::{compile_html, Stylesheet, Origin};
98///
99/// let html = "<p class='intro'>Welcome!</p>";
100/// let css = ".intro { font-weight: bold; }";
101///
102/// let author = Stylesheet::parse(css);
103/// let chapter = compile_html(html, &[(author, Origin::Author)]);
104/// ```
105pub 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
112/// Compile an already-parsed DOM to IR with borrowed stylesheets.
113///
114/// Internal hot path shared by [`compile_html`] and the importers: no
115/// stylesheet is cloned — the UA sheet is shared per thread and author
116/// sheets are borrowed (typically from `Arc<Stylesheet>` caches).
117pub(crate) fn compile_dom(dom: &ArenaDom, author_stylesheets: &[(&Stylesheet, Origin)]) -> Chapter {
118    // Build complete stylesheet list with UA defaults
119    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    // Transform to IR
126    let mut chapter = transform::transform(dom, &all_stylesheets);
127
128    // Optimize: merge adjacent text nodes with identical styles
129    optimize::optimize(&mut chapter);
130
131    chapter
132}
133
134/// Compile HTML bytes to IR.
135///
136/// Convenience wrapper that handles byte-to-string conversion with proper
137/// encoding detection. Supports UTF-8, Windows-1252, and other encodings
138/// via the XML declaration.
139#[cfg(test)]
140pub(crate) fn compile_html_bytes(
141    html: &[u8],
142    author_stylesheets: &[(Stylesheet, Origin)],
143) -> Chapter {
144    // Extract encoding from XML declaration if present
145    let hint_encoding = crate::util::extract_xml_encoding(html);
146
147    // Decode with proper encoding support
148    let html_str = crate::util::decode_text(html, hint_encoding);
149
150    compile_html(&html_str, author_stylesheets)
151}
152
153/// Extract stylesheet links and inline styles from HTML.
154///
155/// Returns a list of (href, media) tuples for linked stylesheets,
156/// and a list of inline CSS content.
157#[cfg(test)]
158pub(crate) fn extract_stylesheets(html: &str) -> (Vec<String>, Vec<String>) {
159    extract_stylesheets_from_dom(&parse_dom(html))
160}
161
162/// Extract stylesheet references from an already-parsed DOM.
163///
164/// Internal importer hot path: lets `load_chapter` parse each chapter's HTML
165/// once and reuse the DOM for both stylesheet discovery and IR compilation.
166pub(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    // Find all link[rel=stylesheet] and style elements
171    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                    // Collect text content
192                    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        // Add children to stack (reverse for left-to-right order)
207        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
216/// Resolve a relative path against a base path logically (no filesystem access).
217///
218/// This is used to canonicalize paths like `../images/photo.jpg` relative to
219/// a chapter file like `OEBPS/text/ch1.html` into an absolute archive path
220/// like `OEBPS/images/photo.jpg`.
221///
222/// # Arguments
223///
224/// * `base` - The base file path (e.g., `OEBPS/text/ch1.html`)
225/// * `rel` - The relative path to resolve (e.g., `../images/photo.jpg`)
226///
227/// # Returns
228///
229/// The resolved path as a string, normalized with forward slashes.
230///
231/// # Examples
232///
233/// ```ignore (crate-internal; exercised by unit tests below)
234/// use crate::dom::resolve_path;
235///
236/// assert_eq!(
237///     resolve_path("OEBPS/text/ch1.html", "../images/logo.png"),
238///     "OEBPS/images/logo.png"
239/// );
240/// assert_eq!(
241///     resolve_path("OEBPS/content.html", "images/photo.jpg"),
242///     "OEBPS/images/photo.jpg"
243/// );
244/// assert_eq!(
245///     resolve_path("ch1.html", "/images/absolute.png"),
246///     "images/absolute.png"
247/// );
248/// ```
249pub 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 absolute (starts with /), treat as archive root
255    if rel_path.has_root() {
256        return rel.trim_start_matches('/').to_string();
257    }
258
259    // If it's a URL (http://, https://, data:, etc.), return as-is
260    if rel.contains("://") || rel.starts_with("data:") {
261        return rel.to_string();
262    }
263
264    // Pop the filename from base to get the directory
265    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    // Process relative path components
280    for component in rel_path.components() {
281        match component {
282            Component::ParentDir => {
283                stack.pop(); // Handle ".."
284            }
285            Component::Normal(c) => {
286                if let Some(s) = c.to_str() {
287                    stack.push(s);
288                }
289            }
290            Component::CurDir => {} // Handle "." (no-op)
291            _ => {}
292        }
293    }
294
295    // Join with forward slashes for ZIP compatibility
296    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        // Deeply nested elements overflowed the stack in compile_html before the
307        // transform gained a depth cap. Run on a small (1 MiB) stack so a modest
308        // nesting depth is enough to blow it if the cap ever regresses — this
309        // keeps the test both sensitive and fast (html5ever's parse is O(depth²),
310        // so we deliberately avoid huge depths).
311        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    /// Concatenate every text node in document order.
327    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        // The whitespace-only node between the spans carries all of the line
341        // structure — the standard shape of syntax-highlighted code. It used
342        // to be dropped by the whitespace heuristics before the white-space
343        // check ran.
344        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        // Browsers render both of these as "A B"; the space/newline between
353        // the <i> elements is a word separator, not indentation.
354        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        // A display:none inline between two inline siblings (with newlines on
370        // both sides) must collapse to a single space, not a double space:
371        // both surrounding whitespace nodes are kept, the hidden element
372        // vanishes, and the vacuum pass must clean one of the resulting
373        // adjacent spaces. Browsers render this as "A B".
374        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        // Inline normal beats any selector specificity...
409        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        // ...but loses to a stylesheet !important.
422        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        // The `font` shorthand must expand and reach the computed style, not
459        // be dropped wholesale.
460        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        // Should have at least root + p (Text) + text content
485        assert!(chapter.node_count() >= 3);
486
487        // Verify there's at least one Text node
488        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        // Find a styled Paragraph node and check its style
506        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; // Found the styled paragraph
512                }
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        // The optimizer merges adjacent sibling Text nodes with the same style.
592        // Note: <b>A</b><b>B</b> creates separate Inline containers, so those
593        // Text nodes are NOT siblings and won't be merged. This tests the case
594        // where Text nodes are actual siblings (e.g., from text interspersed
595        // with inline elements that get stripped).
596
597        // Direct test of the optimizer unit tests cover the merge logic.
598        // This integration test verifies the optimizer runs without corrupting
599        // the tree structure.
600        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        // Collect all text content
608        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        // All text should be preserved
617        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        // The optimizer should not corrupt the tree structure
632        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        // Collect all text content via DFS traversal
641        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        // Both paragraphs should be present
650        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        // Verify Break nodes survive the full compile_html pipeline (including optimizer)
677        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        // Should have a Break node
692        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        // EPUB XHTML files often have self-closing <script/> tags.
705        // In HTML5 parsing, <script/> swallows everything after it.
706        // xml5ever handles this correctly.
707        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        // Plain HTML without xmlns should use html5ever and still work fine
747        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        // Stylesheet extraction should also work with XHTML
766        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}