Skip to main content

ppt_rs/import/
html.rs

1//! HTML to PowerPoint conversion
2//!
3//! Parses HTML content and converts it into PowerPoint slide structures.
4//! No external dependencies required - uses a lightweight state-machine parser.
5//!
6//! # This is the Basic HTML Parser
7//!
8//! This parser is designed for simple, fast HTML-to-PowerPoint conversion
9//! with minimal dependencies. For advanced web scraping and content extraction,
10//! see the `web2ppt::parser` module (requires scraper crate).
11//!
12//! ## When to use this parser:
13//! - Converting simple HTML strings
14//! - Processing well-structured HTML files
15//! - When you want zero external dependencies
16//! - For embedded applications
17//!
18//! ## When to use web2ppt::parser instead:
19//! - Processing live web pages with navigation/ads
20//! - When you need intelligent content extraction
21//! - For automatic content cleaning and detection
22//! - When you need to handle complex web page structures
23//!
24//! # Supported HTML elements
25//!
26//! - `<h1>` → New slide title
27//! - `<h2>` through `<h6>` → Bold section headers on current slide
28//! - `<p>` → Bullet points / paragraphs
29//! - `<ul>/<ol>` with `<li>` → List items
30//! - `<table>` with `<tr>/<th>/<td>` → Table objects (styled header row)
31//! - `<pre>/<code>` → Code blocks
32//! - `<img>` → Image placeholders (with alt text)
33//! - `<blockquote>` → Speaker notes
34//! - `<strong>/<b>` → Bold text (via markdown-style `**`)
35//! - `<em>/<i>` → Italic text (via markdown-style `*`)
36//! - `<a href="...">` → Hyperlink text
37//! - `<hr>` → Slide break
38//! - `<br>` → Line break within text
39//! - `<title>` → Presentation title (falls back to first `<h1>`)
40
41use crate::generator::{CodeBlock, SlideContent};
42use crate::generator::slide_content::{BulletPoint, BulletStyle, BulletTextFormat};
43
44/// Options for HTML parsing
45#[derive(Clone, Debug)]
46pub struct HtmlParseOptions {
47    /// Maximum slides to generate
48    pub max_slides: usize,
49    /// Maximum bullet points per slide
50    pub max_bullets: usize,
51    /// Include code blocks
52    pub include_code: bool,
53    /// Include tables
54    pub include_tables: bool,
55    /// Include image placeholders
56    pub include_images: bool,
57}
58
59impl Default for HtmlParseOptions {
60    fn default() -> Self {
61        Self {
62            max_slides: 50,
63            max_bullets: 10,
64            include_code: true,
65            include_tables: true,
66            include_images: true,
67        }
68    }
69}
70
71impl HtmlParseOptions {
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    pub fn max_slides(mut self, n: usize) -> Self {
77        self.max_slides = n;
78        self
79    }
80
81    pub fn max_bullets(mut self, n: usize) -> Self {
82        self.max_bullets = n;
83        self
84    }
85
86    pub fn include_code(mut self, include: bool) -> Self {
87        self.include_code = include;
88        self
89    }
90
91    pub fn include_tables(mut self, include: bool) -> Self {
92        self.include_tables = include;
93        self
94    }
95
96    pub fn include_images(mut self, include: bool) -> Self {
97        self.include_images = include;
98        self
99    }
100}
101
102/// Parse HTML content into slides with default options
103pub fn parse_html(html: &str) -> Result<Vec<SlideContent>, String> {
104    Html2Ppt::with_options(HtmlParseOptions::default()).parse(html)
105}
106
107/// Parse HTML content into slides with custom options
108pub fn parse_html_with_options(html: &str, options: HtmlParseOptions) -> Result<Vec<SlideContent>, String> {
109    Html2Ppt::with_options(options).parse(html)
110}
111
112// ---------------------------------------------------------------------------
113// Struct-based HTML parser (no lifetimes trickery)
114// ---------------------------------------------------------------------------
115
116/// A simple tag-based HTML event
117#[derive(Debug)]
118enum HtmlEvent {
119    OpenTag { name: String, attrs: Vec<(String, String)> },
120    CloseTag(String),
121    Text(String),
122}
123
124/// Decode common HTML entities
125fn decode_entities(s: &str) -> String {
126    let mut out = String::with_capacity(s.len());
127    let bytes = s.as_bytes();
128    let mut i = 0;
129    while i < bytes.len() {
130        if bytes[i] == b'&' {
131            if let Some(end) = s[i..].find(';') {
132                let entity = &s[i + 1..i + end];
133                let ch = match entity {
134                    "amp" => Some('&'),
135                    "lt" => Some('<'),
136                    "gt" => Some('>'),
137                    "quot" => Some('"'),
138                    "apos" | "#39" | "#x27" => Some('\''),
139                    "nbsp" => Some('\u{00a0}'),
140                    "#x2018" => Some('\u{2018}'),
141                    "#x2019" => Some('\u{2019}'),
142                    "#x201c" => Some('\u{201c}'),
143                    "#x201d" => Some('\u{201d}'),
144                    "#x2014" => Some('\u{2014}'),
145                    "#x2013" => Some('\u{2013}'),
146                    _ => {
147                        if let Some(hex) = entity.strip_prefix("#x") {
148                            u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
149                        } else if let Some(num) = entity.strip_prefix('#') {
150                            num.parse::<u32>().ok().and_then(char::from_u32)
151                        } else {
152                            None
153                        }
154                    }
155                };
156                if let Some(c) = ch {
157                    out.push(c);
158                    i = i + end + 1;
159                    continue;
160                }
161            }
162        }
163        // Preserve full Unicode characters (not just ASCII bytes)
164        let c = s[i..].chars().next().unwrap();
165        out.push(c);
166        i += c.len_utf8();
167    }
168    out
169}
170
171// ---------------------------------------------------------------------------
172// Inline CSS style parsing
173// ---------------------------------------------------------------------------
174
175/// Convert a named CSS color to its 6-digit hex representation
176fn css_named_color(name: &str) -> Option<&'static str> {
177    match name {
178        "red" => Some("FF0000"),
179        "blue" => Some("0000FF"),
180        "green" => Some("008000"),
181        "yellow" => Some("FFFF00"),
182        "white" => Some("FFFFFF"),
183        "black" => Some("000000"),
184        "gray" | "grey" => Some("808080"),
185        "silver" => Some("C0C0C0"),
186        "maroon" => Some("800000"),
187        "purple" => Some("800080"),
188        "fuchsia" => Some("FF00FF"),
189        "lime" => Some("00FF00"),
190        "olive" => Some("808000"),
191        "navy" => Some("000080"),
192        "teal" => Some("008080"),
193        "aqua" => Some("00FFFF"),
194        "orange" => Some("FFA500"),
195        "pink" => Some("FFC0CB"),
196        "coral" => Some("FF7F50"),
197        "tomato" => Some("FF6347"),
198        "darkred" => Some("8B0000"),
199        "darkblue" => Some("00008B"),
200        "darkgreen" => Some("006400"),
201        "darkgray" | "darkgrey" => Some("A9A9A9"),
202        "lightgray" | "lightgrey" => Some("D3D3D3"),
203        "darkorange" => Some("FF8C00"),
204        "brown" => Some("A52A2A"),
205        "crimson" => Some("DC143C"),
206        "gold" => Some("FFD700"),
207        "goldenrod" => Some("DAA520"),
208        "indigo" => Some("4B0082"),
209        "salmon" => Some("FA8072"),
210        "chocolate" => Some("D2691E"),
211        "steelblue" => Some("4682B4"),
212        "violet" => Some("EE82EE"),
213        "orchid" => Some("DA70D6"),
214        "plum" => Some("DDA0DD"),
215        "wheat" => Some("F5DEB3"),
216        "deeppink" => Some("FF1493"),
217        "hotpink" => Some("FF69B4"),
218        "royalblue" => Some("4169E1"),
219        "skyblue" => Some("87CEEB"),
220        "seagreen" => Some("2E8B57"),
221        "forestgreen" => Some("228B22"),
222        _ => None,
223    }
224}
225
226/// Parse a CSS color value to a 6-digit hex string (without #)
227fn parse_css_color(value: &str) -> Option<String> {
228    let value = value.trim();
229    if let Some(hex) = value.strip_prefix('#') {
230        let hex = match hex.len() {
231            3 => hex.chars().map(|c| format!("{c}{c}")).collect::<String>(),
232            6 => hex.to_string(),
233            8 => hex[..6].to_string(), // ignore alpha
234            _ => return None,
235        };
236        Some(hex.to_uppercase())
237    } else if let Some(named) = css_named_color(value) {
238        Some(named.to_string())
239    } else if let Some(rgb) = value.strip_prefix("rgba(").or_else(|| value.strip_prefix("rgb(")) {
240        if let Some(end) = rgb.rfind(')') {
241            let parts: Vec<&str> = rgb[..end].split(',').collect();
242            if parts.len() >= 3 {
243                let r = parts[0].trim().parse::<u8>().ok()?;
244                let g = parts[1].trim().parse::<u8>().ok()?;
245                let b = parts[2].trim().parse::<u8>().ok()?;
246                return Some(format!("{:02X}{:02X}{:02X}", r, g, b));
247            }
248        }
249        None
250    } else {
251        None
252    }
253}
254
255/// Parse a CSS font-size value to points
256fn parse_font_size(value: &str) -> Option<u32> {
257    let value = value.trim();
258    if let Some(px) = value.strip_suffix("px") {
259        let px = px.trim().parse::<f64>().ok()?;
260        Some((px / 1.333).round() as u32)
261    } else if let Some(pt) = value.strip_suffix("pt") {
262        let pt = pt.trim().parse::<f64>().ok()?;
263        Some(pt.round() as u32)
264    } else {
265        value.parse::<u32>().ok()
266    }
267}
268
269/// Check if a CSS font-weight value represents bold
270fn is_font_weight_bold(value: &str) -> bool {
271    matches!(value.trim().to_lowercase().as_str(), "bold" | "bolder" | "700" | "800" | "900")
272}
273
274/// Check if a CSS font-style represents italic
275fn is_font_style_italic(value: &str) -> bool {
276    matches!(value.trim().to_lowercase().as_str(), "italic" | "oblique")
277}
278
279/// Parsed inline CSS style declarations
280#[derive(Clone, Debug, Default)]
281struct InlineStyle {
282    color: Option<String>,
283    background_color: Option<String>,
284    font_size: Option<u32>,
285    font_weight: Option<String>,
286    font_style: Option<String>,
287    text_decoration: Option<String>,
288    font_family: Option<String>,
289    text_align: Option<String>,
290    margin_top: Option<String>,
291    margin_bottom: Option<String>,
292    margin_left: Option<String>,
293    margin_right: Option<String>,
294    padding: Option<String>,
295    border: Option<String>,
296    line_height: Option<String>,
297    letter_spacing: Option<String>,
298}
299
300impl InlineStyle {
301    fn parse(style_str: &str) -> Self {
302        let mut style = InlineStyle::default();
303        for decl in style_str.split(';') {
304            let decl = decl.trim();
305            if decl.is_empty() {
306                continue;
307            }
308            if let Some(eq) = decl.find(':') {
309                let prop = decl[..eq].trim().to_lowercase();
310                let value = decl[eq + 1..].trim();
311                match prop.as_str() {
312                    "color" => style.color = parse_css_color(value),
313                    "background-color" => style.background_color = parse_css_color(value),
314                    "font-size" => style.font_size = parse_font_size(value),
315                    "font-weight" => style.font_weight = Some(value.to_string()),
316                    "font-style" => style.font_style = Some(value.to_string()),
317                    "text-decoration" => style.text_decoration = Some(value.to_string()),
318                    "font-family" => {
319                        style.font_family = Some(value.trim_matches('"').trim_matches('\'').to_string());
320                    }
321                    "text-align" => style.text_align = Some(value.to_string()),
322                    "margin-top" => style.margin_top = Some(value.to_string()),
323                    "margin-bottom" => style.margin_bottom = Some(value.to_string()),
324                    "margin-left" => style.margin_left = Some(value.to_string()),
325                    "margin-right" => style.margin_right = Some(value.to_string()),
326                    "padding" => style.padding = Some(value.to_string()),
327                    "border" => style.border = Some(value.to_string()),
328                    "line-height" => style.line_height = Some(value.to_string()),
329                    "letter-spacing" => style.letter_spacing = Some(value.to_string()),
330                    _ => {}
331                }
332            }
333        }
334        style
335    }
336
337    /// Merge another style on top, with the other's non-None values taking precedence
338    fn merge(&self, other: &InlineStyle) -> InlineStyle {
339        InlineStyle {
340            color: other.color.clone().or_else(|| self.color.clone()),
341            background_color: other.background_color.clone().or_else(|| self.background_color.clone()),
342            font_size: other.font_size.or(self.font_size),
343            font_weight: other.font_weight.clone().or_else(|| self.font_weight.clone()),
344            font_style: other.font_style.clone().or_else(|| self.font_style.clone()),
345            text_decoration: other.text_decoration.clone().or_else(|| self.text_decoration.clone()),
346            font_family: other.font_family.clone().or_else(|| self.font_family.clone()),
347            text_align: other.text_align.clone().or_else(|| self.text_align.clone()),
348            margin_top: other.margin_top.clone().or_else(|| self.margin_top.clone()),
349            margin_bottom: other.margin_bottom.clone().or_else(|| self.margin_bottom.clone()),
350            margin_left: other.margin_left.clone().or_else(|| self.margin_left.clone()),
351            margin_right: other.margin_right.clone().or_else(|| self.margin_right.clone()),
352            padding: other.padding.clone().or_else(|| self.padding.clone()),
353            border: other.border.clone().or_else(|| self.border.clone()),
354            line_height: other.line_height.clone().or_else(|| self.line_height.clone()),
355            letter_spacing: other.letter_spacing.clone().or_else(|| self.letter_spacing.clone()),
356        }
357    }
358
359    /// Returns true if no style properties are set
360    fn is_empty(&self) -> bool {
361        self.color.is_none()
362            && self.background_color.is_none()
363            && self.font_size.is_none()
364            && self.font_weight.is_none()
365            && self.font_style.is_none()
366            && self.text_decoration.is_none()
367            && self.font_family.is_none()
368            && self.text_align.is_none()
369            && self.margin_top.is_none()
370            && self.margin_bottom.is_none()
371            && self.margin_left.is_none()
372            && self.margin_right.is_none()
373            && self.padding.is_none()
374            && self.border.is_none()
375            && self.line_height.is_none()
376            && self.letter_spacing.is_none()
377    }
378
379    /// Convert to a BulletTextFormat for PPTX output. Returns None if no relevant properties set.
380    fn to_bullet_format(&self) -> Option<BulletTextFormat> {
381        if self.is_empty() {
382            return None;
383        }
384        let mut fmt = BulletTextFormat::new();
385        if let Some(ref c) = self.color {
386            fmt = fmt.color(c);
387        }
388        if let Some(ref bg) = self.background_color {
389            fmt = fmt.highlight(bg);
390        }
391        if let Some(sz) = self.font_size {
392            fmt = fmt.font_size(sz);
393        }
394        if let Some(ref fw) = self.font_weight {
395            if is_font_weight_bold(fw) {
396                fmt = fmt.bold();
397            }
398        }
399        if let Some(ref fs) = self.font_style {
400            if is_font_style_italic(fs) {
401                fmt = fmt.italic();
402            }
403        }
404        if let Some(ref td) = self.text_decoration {
405            if td.contains("underline") {
406                fmt = fmt.underline();
407            }
408            if td.contains("line-through") {
409                fmt = fmt.strikethrough();
410            }
411        }
412        if let Some(ref ff) = self.font_family {
413            fmt = fmt.font_family(ff);
414        }
415        Some(fmt)
416    }
417}
418
419/// Tags that are void (self-closing) and should not push/pop the style stack
420const VOID_TAGS: &[&str] = &[
421    "area", "base", "br", "col", "embed", "hr", "img", "input",
422    "link", "meta", "param", "source", "track", "wbr",
423];
424
425/// Walk through HTML and produce events
426fn tokenize_html(html: &str) -> Vec<HtmlEvent> {
427    let mut events = Vec::new();
428    let chars: Vec<char> = html.chars().collect();
429    let len = chars.len();
430    let mut i = 0;
431
432    while i < len {
433        if chars[i] == '<' {
434            i += 1;
435            if i >= len {
436                break;
437            }
438
439            // Comment <!-- ... -->
440            if i + 3 <= len && chars[i] == '!' && i + 1 < len && chars[i + 1] == '-' && i + 2 < len && chars[i + 2] == '-' {
441                // skip to -->
442                i += 3;
443                while i + 2 < len && !(chars[i] == '-' && chars[i + 1] == '-' && chars[i + 2] == '>') {
444                    i += 1;
445                }
446                i += 3; // skip -->
447                continue;
448            }
449
450            // Doctype/other <!...>
451            if chars[i] == '!' {
452                while i < len && chars[i] != '>' {
453                    i += 1;
454                }
455                i += 1;
456                continue;
457            }
458
459            // Closing tag </...>
460            if chars[i] == '/' {
461                i += 1;
462                // skip whitespace
463                while i < len && (chars[i] == ' ' || chars[i] == '\t' || chars[i] == '\n' || chars[i] == '\r') {
464                    i += 1;
465                }
466                let mut name = String::new();
467                while i < len && chars[i] != '>' {
468                    if chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == ':' || chars[i] == '_' || chars[i] == '.' {
469                        name.push(chars[i]);
470                    }
471                    i += 1;
472                }
473                if i < len {
474                    i += 1; // skip '>'
475                }
476                if !name.is_empty() {
477                    events.push(HtmlEvent::CloseTag(name.to_lowercase()));
478                }
479                continue;
480            }
481
482            // Opening or self-closing tag
483            // Skip whitespace before tag name
484            while i < len && (chars[i] == ' ' || chars[i] == '\t' || chars[i] == '\n' || chars[i] == '\r') {
485                i += 1;
486            }
487            let mut name = String::new();
488            while i < len && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == ':' || chars[i] == '_' || chars[i] == '.') {
489                name.push(chars[i]);
490                i += 1;
491            }
492            let tag_name = name.to_lowercase();
493
494            // Parse attributes
495            let mut attrs: Vec<(String, String)> = Vec::new();
496            let mut self_closing = false;
497
498            while i < len && chars[i] != '>' {
499                // Skip whitespace
500                while i < len && (chars[i] == ' ' || chars[i] == '\t' || chars[i] == '\n' || chars[i] == '\r') {
501                    i += 1;
502                }
503                if i >= len || chars[i] == '>' {
504                    break;
505                }
506                if chars[i] == '/' {
507                    self_closing = true;
508                    i += 1;
509                    continue;
510                }
511
512                // Read attribute name
513                let mut attr_name = String::new();
514                while i < len && chars[i] != '=' && chars[i] != '>' && chars[i] != ' ' && chars[i] != '\t' && chars[i] != '\n' && chars[i] != '\r' && chars[i] != '/' {
515                    attr_name.push(chars[i]);
516                    i += 1;
517                }
518
519                // Skip whitespace around =
520                while i < len && (chars[i] == ' ' || chars[i] == '\t' || chars[i] == '\n' || chars[i] == '\r') {
521                    i += 1;
522                }
523
524                let mut attr_value = String::new();
525                if i < len && chars[i] == '=' {
526                    i += 1;
527                    // Skip whitespace after =
528                    while i < len && (chars[i] == ' ' || chars[i] == '\t' || chars[i] == '\n' || chars[i] == '\r') {
529                        i += 1;
530                    }
531                    if i < len && (chars[i] == '"' || chars[i] == '\'') {
532                        let quote = chars[i];
533                        i += 1;
534                        while i < len && chars[i] != quote {
535                            attr_value.push(chars[i]);
536                            i += 1;
537                        }
538                        if i < len {
539                            i += 1; // skip closing quote
540                        }
541                    } else {
542                        // unquoted value
543                        while i < len && chars[i] != '>' && chars[i] != ' ' && chars[i] != '\t' && chars[i] != '\n' && chars[i] != '\r' && chars[i] != '/' {
544                            attr_value.push(chars[i]);
545                            i += 1;
546                        }
547                    }
548                }
549
550                attrs.push((attr_name.to_lowercase(), decode_entities(&attr_value)));
551            }
552
553            if i < len {
554                i += 1; // skip '>'
555            }
556
557            if !tag_name.is_empty() {
558                let void_tags = [
559                    "area", "base", "br", "col", "embed", "hr", "img", "input",
560                    "link", "meta", "param", "source", "track", "wbr",
561                ];
562                let is_void = void_tags.contains(&tag_name.as_str());
563
564                if self_closing || is_void {
565                    events.push(HtmlEvent::OpenTag { name: tag_name, attrs });
566                } else {
567                    events.push(HtmlEvent::OpenTag { name: tag_name, attrs });
568                }
569            }
570        } else {
571            // Text content
572            let mut text = String::new();
573            while i < len && chars[i] != '<' {
574                text.push(chars[i]);
575                i += 1;
576            }
577            let trimmed = text.trim();
578            if !trimmed.is_empty() {
579                events.push(HtmlEvent::Text(decode_entities(&text)));
580            }
581        }
582    }
583
584    events
585}
586
587// ---------------------------------------------------------------------------
588// Slide builder from events
589// ---------------------------------------------------------------------------
590
591/// Tags whose content should be entirely skipped
592const SKIP_TAGS: &[&str] = &[
593    "script", "style", "noscript", "nav", "form", "svg", "canvas", "iframe",
594    "title",
595];
596
597struct HtmlSlideParser {
598    options: HtmlParseOptions,
599    slides: Vec<SlideContent>,
600    current_slide: Option<SlideContent>,
601    text_buffer: String,
602    tag_stack: Vec<String>,
603    style_stack: Vec<InlineStyle>,
604    in_list: bool,
605    in_table: bool,
606    in_code: bool,
607    in_blockquote: bool,
608    italic: bool,
609    list_items: Vec<(String, Option<BulletTextFormat>)>,
610    table_rows: Vec<Vec<String>>,
611    current_row: Vec<String>,
612    current_cell: String,
613    code_content: String,
614    blockquote_text: String,
615    presentation_title: Option<String>,
616    current_href: Option<String>,
617}
618
619impl HtmlSlideParser {
620    fn new(options: HtmlParseOptions) -> Self {
621        Self {
622            options,
623            slides: Vec::new(),
624            current_slide: None,
625            text_buffer: String::new(),
626            tag_stack: Vec::new(),
627            style_stack: Vec::new(),
628            in_list: false,
629            in_table: false,
630            in_code: false,
631            in_blockquote: false,
632            italic: false,
633            list_items: Vec::new(),
634            table_rows: Vec::new(),
635            current_row: Vec::new(),
636            current_cell: String::new(),
637            code_content: String::new(),
638            blockquote_text: String::new(),
639            presentation_title: None,
640            current_href: None,
641        }
642    }
643
644    /// Return the current active style (top of the style stack)
645    fn active_style(&self) -> Option<&InlineStyle> {
646        self.style_stack.last()
647    }
648
649    /// Create a BulletPoint with the current active style applied
650    #[allow(dead_code)]
651    fn make_bullet(&self, text: &str, bullet_style: BulletStyle) -> BulletPoint {
652        let mut bp = BulletPoint::new(text).with_style(bullet_style);
653        if let Some(ref s) = self.active_style() {
654            if let Some(fmt) = s.to_bullet_format() {
655                bp = bp.with_format(fmt);
656            }
657        }
658        bp
659    }
660
661    fn parse(&mut self, events: &[HtmlEvent]) -> Result<Vec<SlideContent>, String> {
662        for event in events {
663            match event {
664                HtmlEvent::OpenTag { name, attrs } => {
665                    self.tag_stack.push(name.clone());
666                    self.handle_open_tag(name, attrs);
667                }
668                HtmlEvent::CloseTag(name) => {
669                    self.handle_close_tag(name);
670                    self.tag_stack.pop();
671                }
672                HtmlEvent::Text(text) => {
673                    self.handle_text(text);
674                }
675            }
676        }
677
678        self.finalize_current_slide();
679
680        if self.slides.is_empty() {
681            return Err("No slide content found in HTML".to_string());
682        }
683
684        // Trim to max_slides
685        if self.slides.len() > self.options.max_slides {
686            self.slides.truncate(self.options.max_slides);
687        }
688
689        Ok(std::mem::take(&mut self.slides))
690    }
691
692    fn is_inside_skip_tag(&self) -> bool {
693        self.tag_stack.iter().any(|t| SKIP_TAGS.contains(&t.as_str()))
694    }
695
696    fn handle_open_tag(&mut self, name: &str, attrs: &[(String, String)]) {
697        if self.is_inside_skip_tag() {
698            return;
699        }
700
701        // Push style for this element (inherits from parent). Skip void tags.
702        if !VOID_TAGS.contains(&name) {
703            let parent = self.style_stack.last().cloned().unwrap_or_default();
704            let style = if let Some(style_attr) = attrs.iter().find(|(k, _)| k == "style") {
705                parent.merge(&InlineStyle::parse(&style_attr.1))
706            } else {
707                parent
708            };
709            self.style_stack.push(style);
710        }
711
712        match name {
713            "h1" => {
714                self.flush_text_buffer();
715                self.finalize_current_slide();
716            }
717            "h2" | "h3" | "h4" | "h5" | "h6" => {
718                self.flush_text_buffer();
719            }
720            "p" | "div" | "article" | "section" | "main" | "li" => {}
721            "pre" => {
722                self.in_code = true;
723                self.code_content.clear();
724            }
725            "table" => {
726                self.in_table = true;
727                self.table_rows.clear();
728            }
729            "blockquote" => {
730                self.in_blockquote = true;
731                self.blockquote_text.clear();
732            }
733            "ul" | "ol" => {
734                self.in_list = true;
735                self.list_items.clear();
736            }
737            "strong" | "b" => {
738                self.text_buffer.push_str("**");
739            }
740            "em" | "i" => {
741                self.text_buffer.push('*');
742                self.italic = true;
743            }
744            "title" => {}
745            "img" => {
746                if self.options.include_images {
747                    let alt = attrs.iter().find(|(k, _)| k == "alt").map(|(_, v)| v.as_str()).unwrap_or("");
748                    let src = attrs.iter().find(|(k, _)| k == "src").map(|(_, v)| v.as_str()).unwrap_or("");
749
750                    if !src.is_empty() {
751                        // Try to download and embed the actual image
752                        if let Some(image) = self.load_image(src, alt) {
753                            if let Some(ref mut slide) = self.current_slide {
754                                slide.images.push(image);
755                            } else {
756                                let mut slide = SlideContent::new("Image");
757                                slide.images.push(image);
758                                self.current_slide = Some(slide);
759                            }
760                        } else {
761                            // Fallback to placeholder if image loading fails
762                            let label = if alt.is_empty() { src } else { alt };
763                            self.add_paragraph(&format!("[Image: {}]", label));
764                        }
765                    }
766                }
767            }
768            "a" => {
769                // Handle hyperlinks - just mark that we're inside a link
770                // The actual link text will be handled in text processing
771                if let Some(href) = attrs.iter().find(|(k, _)| k == "href").map(|(_, v)| v.as_str()) {
772                    // Store the current href for later use
773                    self.current_href = Some(href.to_string());
774                }
775            }
776            "br" => {
777                self.text_buffer.push('\n');
778            }
779            "hr" => {
780                self.flush_text_buffer();
781                self.finalize_current_slide();
782            }
783            _ => {}
784        }
785    }
786
787    fn handle_close_tag(&mut self, name: &str) {
788        if self.is_inside_skip_tag() {
789            return;
790        }
791
792        match name {
793            "h1" => {
794                let title = std::mem::take(&mut self.text_buffer).trim().to_string();
795                if self.presentation_title.is_none() && !title.is_empty() {
796                    self.presentation_title = Some(title.clone());
797                }
798                let slide_title = if title.is_empty() { "Slide".to_string() } else { title };
799                let mut slide = SlideContent::new(&slide_title);
800                // Apply title-level styles from active style
801                if let Some(ref s) = self.active_style() {
802                    if let Some(ref c) = s.color { slide = slide.title_color(c); }
803                    if let Some(sz) = s.font_size { slide = slide.title_size(sz); }
804                    if let Some(ref fw) = s.font_weight { if is_font_weight_bold(fw) { slide = slide.title_bold(true); } }
805                    if let Some(ref fs) = s.font_style { if is_font_style_italic(fs) { slide = slide.title_italic(true); } }
806                    if let Some(ref td) = s.text_decoration { if td.contains("underline") { slide = slide.title_underline(true); } }
807                }
808                self.current_slide = Some(slide);
809            }
810            "h2" | "h3" | "h4" | "h5" | "h6" => {
811                let text = std::mem::take(&mut self.text_buffer).trim().to_string();
812                if !text.is_empty() {
813                    self.add_formatted_text(&format!("**{}**", text));
814                }
815            }
816            "p" => {
817                let text = std::mem::take(&mut self.text_buffer).trim().to_string();
818                if !text.is_empty() {
819                    self.add_paragraph(&text);
820                }
821            }
822            "div" | "article" | "section" | "main" => {
823                let text = std::mem::take(&mut self.text_buffer).trim().to_string();
824                if !text.is_empty() {
825                    self.add_paragraph(&text);
826                }
827            }
828            "li" => {
829                let item = std::mem::take(&mut self.text_buffer).trim().to_string();
830                if !item.is_empty() {
831                    let item_style = self.active_style().and_then(|s| s.to_bullet_format());
832                    self.list_items.push((item, item_style));
833                }
834            }
835            "ul" | "ol" => {
836                self.flush_list_items();
837                self.in_list = false;
838            }
839            "pre" => {
840                self.in_code = false;
841                self.flush_code_block();
842            }
843            "table" => {
844                self.in_table = false;
845                self.flush_table();
846            }
847            "blockquote" => {
848                self.in_blockquote = false;
849                self.flush_blockquote();
850            }
851            "th" | "td" => {
852                let cell = std::mem::take(&mut self.current_cell).trim().to_string();
853                self.current_row.push(cell);
854            }
855            "tr" => {
856                if !self.current_row.is_empty() {
857                    self.table_rows.push(std::mem::take(&mut self.current_row));
858                    self.current_row = Vec::new();
859                }
860            }
861            "strong" | "b" => {
862                self.text_buffer.push_str("**");
863            }
864            "em" | "i" => {
865                self.text_buffer.push('*');
866                self.italic = false;
867            }
868            "a" => {
869                // When closing an anchor tag, we have the link text in the buffer
870                // and the href in current_href. For now, we'll just clear the href
871                // since PowerPoint hyperlink support requires more complex XML handling
872                self.current_href = None;
873            }
874            _ => {}
875        }
876
877        // Pop style stack for non-void tags (mirrors push in handle_open_tag)
878        if !VOID_TAGS.contains(&name) {
879            self.style_stack.pop();
880        }
881    }
882
883    fn handle_text(&mut self, text: &str) {
884        if self.is_inside_skip_tag() {
885            return;
886        }
887
888        if self.in_code {
889            self.code_content.push_str(text);
890        } else if self.in_table {
891            self.current_cell.push_str(text);
892        } else if self.in_blockquote {
893            self.blockquote_text.push_str(text);
894        } else if self.in_list {
895            self.text_buffer.push_str(text);
896        } else {
897            self.text_buffer.push_str(text);
898        }
899    }
900
901    fn add_formatted_text(&mut self, text: &str) {
902        let fmt = self.active_style().and_then(|s| s.to_bullet_format());
903        if let Some(ref mut slide) = self.current_slide {
904            let mut bp = BulletPoint::new(text).with_style(slide.bullet_style);
905            if let Some(ref f) = fmt {
906                bp = bp.with_format(f.clone());
907            }
908            slide.content.push(text.to_string());
909            slide.bullets.push(bp);
910        } else {
911            let mut slide = SlideContent::new("Slide");
912            let mut bp = BulletPoint::new(text).with_style(slide.bullet_style);
913            if let Some(ref f) = fmt {
914                bp = bp.with_format(f.clone());
915            }
916            slide.content.push(text.to_string());
917            slide.bullets.push(bp);
918            self.current_slide = Some(slide);
919        }
920    }
921
922    fn add_paragraph(&mut self, text: &str) {
923        let fmt = self.active_style().and_then(|s| s.to_bullet_format());
924        if let Some(ref mut slide) = self.current_slide {
925            if slide.content.len() < self.options.max_bullets {
926                let mut bp = BulletPoint::new(text).with_style(slide.bullet_style);
927                if let Some(ref f) = fmt {
928                    bp = bp.with_format(f.clone());
929                }
930                slide.content.push(text.to_string());
931                slide.bullets.push(bp);
932            }
933        } else {
934            let title = self.presentation_title.clone().unwrap_or_else(|| "Overview".to_string());
935            let mut slide = SlideContent::new(&title);
936            let mut bp = BulletPoint::new(text).with_style(slide.bullet_style);
937            if let Some(ref f) = fmt {
938                bp = bp.with_format(f.clone());
939            }
940            slide.content.push(text.to_string());
941            slide.bullets.push(bp);
942            self.current_slide = Some(slide);
943        }
944    }
945
946    fn flush_text_buffer(&mut self) {
947        let text = std::mem::take(&mut self.text_buffer);
948        let trimmed = text.trim().to_string();
949        if !trimmed.is_empty() {
950            self.add_paragraph(&trimmed);
951        }
952    }
953
954    fn flush_list_items(&mut self) {
955        let items = std::mem::take(&mut self.list_items);
956        if items.is_empty() {
957            return;
958        }
959
960        if let Some(ref mut slide) = self.current_slide {
961            for (item, item_style) in items {
962                if slide.content.len() < self.options.max_bullets {
963                    let mut bp = BulletPoint::new(&item).with_style(slide.bullet_style);
964                    if let Some(ref f) = item_style {
965                        bp = bp.with_format(f.clone());
966                    }
967                    slide.content.push(item);
968                    slide.bullets.push(bp);
969                }
970            }
971        } else {
972            let title = self.presentation_title.clone().unwrap_or_else(|| "Key Points".to_string());
973            let mut slide = SlideContent::new(&title);
974            for (item, item_style) in items {
975                if slide.content.len() < self.options.max_bullets {
976                    let mut bp = BulletPoint::new(&item).with_style(slide.bullet_style);
977                    if let Some(ref f) = item_style {
978                        bp = bp.with_format(f.clone());
979                    }
980                    slide.content.push(item);
981                    slide.bullets.push(bp);
982                }
983            }
984            self.current_slide = Some(slide);
985        }
986    }
987
988    fn flush_table(&mut self) {
989        if !self.options.include_tables || self.table_rows.is_empty() {
990            return;
991        }
992
993        let rows = std::mem::take(&mut self.table_rows);
994        let table = crate::generator::table::table_from_string_rows(rows, true);
995
996        if let Some(ref mut slide) = self.current_slide {
997            slide.table = Some(table);
998            slide.has_table = true;
999        } else {
1000            let mut slide = SlideContent::new("Data Table");
1001            slide.table = Some(table);
1002            slide.has_table = true;
1003            self.current_slide = Some(slide);
1004        }
1005    }
1006
1007    fn flush_code_block(&mut self) {
1008        if !self.options.include_code || self.code_content.is_empty() {
1009            return;
1010        }
1011
1012        let code = std::mem::take(&mut self.code_content);
1013        let code_block = CodeBlock::new(code.trim(), "text");
1014
1015        if let Some(ref mut slide) = self.current_slide {
1016            slide.code_blocks.push(code_block);
1017        } else {
1018            let mut slide = SlideContent::new("Code");
1019            slide.code_blocks.push(code_block);
1020            self.current_slide = Some(slide);
1021        }
1022    }
1023
1024    fn flush_blockquote(&mut self) {
1025        let text = std::mem::take(&mut self.blockquote_text).trim().to_string();
1026        if text.is_empty() {
1027            return;
1028        }
1029
1030        if let Some(ref mut slide) = self.current_slide {
1031            slide.notes = Some(text);
1032        }
1033    }
1034
1035    fn finalize_current_slide(&mut self) {
1036        self.flush_text_buffer();
1037        self.flush_list_items();
1038        if let Some(slide) = self.current_slide.take() {
1039            self.slides.push(slide);
1040        }
1041    }
1042
1043    /// Load an image from URL or local file path
1044    fn load_image(&self, src: &str, _alt: &str) -> Option<crate::generator::Image> {
1045        use crate::generator::ImageBuilder;
1046        use std::path::Path;
1047
1048        // Check if it's a URL
1049        if src.starts_with("http://") || src.starts_with("https://") {
1050            // Try to download the image
1051            #[cfg(feature = "web2ppt")]
1052            {
1053                if let Ok(bytes) = self.download_image(src) {
1054                    let img = ImageBuilder::auto(bytes)
1055                        .at(2000000, 2000000)
1056                        .size(5000000, 3000000)
1057                        .build();
1058                    return Some(img);
1059                }
1060            }
1061            None
1062        } else {
1063            // Try to load from local file path
1064            let path = Path::new(src);
1065            if path.exists() {
1066                if let Ok(bytes) = std::fs::read(path) {
1067                    let img = ImageBuilder::auto(bytes)
1068                        .at(2000000, 2000000)
1069                        .size(5000000, 3000000)
1070                        .build();
1071                    return Some(img);
1072                }
1073            }
1074            None
1075        }
1076    }
1077
1078    /// Download an image from a URL (requires web2ppt feature)
1079    #[cfg(feature = "web2ppt")]
1080    fn download_image(&self, url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
1081        use reqwest::blocking::Client;
1082        use std::time::Duration;
1083
1084        let client = Client::builder()
1085            .timeout(Duration::from_secs(30))
1086            .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
1087            .build()?;
1088
1089        let response = client.get(url).send()?;
1090        if response.status().is_success() {
1091            Ok(response.bytes()?.to_vec())
1092        } else {
1093            Err(format!("Failed to download image: {}", response.status()).into())
1094        }
1095    }
1096}
1097
1098// ---------------------------------------------------------------------------
1099// Public API
1100// ---------------------------------------------------------------------------
1101
1102/// HTML to PowerPoint converter
1103pub struct Html2Ppt {
1104    options: HtmlParseOptions,
1105}
1106
1107impl Html2Ppt {
1108    pub fn new() -> Self {
1109        Self::with_options(HtmlParseOptions::default())
1110    }
1111
1112    pub fn with_options(options: HtmlParseOptions) -> Self {
1113        Self { options }
1114    }
1115
1116    /// Parse an HTML string into slide content
1117    pub fn parse(&self, html: &str) -> Result<Vec<SlideContent>, String> {
1118        let events = tokenize_html(html);
1119        HtmlSlideParser::new(self.options.clone()).parse(&events)
1120    }
1121
1122    /// Parse HTML from a file path
1123    pub fn parse_file(&self, path: &str) -> Result<Vec<SlideContent>, String> {
1124        let html = std::fs::read_to_string(path)
1125            .map_err(|e| format!("Failed to read HTML file: {e}"))?;
1126        self.parse(&html)
1127    }
1128}
1129
1130impl Default for Html2Ppt {
1131    fn default() -> Self {
1132        Self::new()
1133    }
1134}
1135
1136// ---------------------------------------------------------------------------
1137// Tests
1138// ---------------------------------------------------------------------------
1139
1140#[cfg(test)]
1141mod tests {
1142    use super::*;
1143
1144    #[test]
1145    fn test_tokenize_basic() {
1146        let events = tokenize_html("<h1>Hello</h1>");
1147        assert_eq!(events.len(), 3);
1148        match &events[0] {
1149            HtmlEvent::OpenTag { name, .. } => assert_eq!(name, "h1"),
1150            _ => panic!("expected OpenTag"),
1151        }
1152        match &events[1] {
1153            HtmlEvent::Text(t) => assert_eq!(t.trim(), "Hello"),
1154            _ => panic!("expected Text"),
1155        }
1156        match &events[2] {
1157            HtmlEvent::CloseTag(n) => assert_eq!(n, "h1"),
1158            _ => panic!("expected CloseTag"),
1159        }
1160    }
1161
1162    #[test]
1163    fn test_simple_headings() {
1164        let html = "<h1>First Slide</h1><p>Some content</p><h1>Second Slide</h1>";
1165        let slides = parse_html(html).unwrap();
1166        assert_eq!(slides.len(), 2);
1167        assert_eq!(slides[0].title, "First Slide");
1168        assert_eq!(slides[1].title, "Second Slide");
1169        assert_eq!(slides[1].content.len(), 0);
1170    }
1171
1172    #[test]
1173    fn test_table() {
1174        let html = r#"
1175            <html><body>
1176                <h1>Data</h1>
1177                <table>
1178                    <tr><th>Name</th><th>Value</th></tr>
1179                    <tr><td>A</td><td>1</td></tr>
1180                    <tr><td>B</td><td>2</td></tr>
1181                </table>
1182            </body></html>
1183        "#;
1184        let slides = parse_html(html).unwrap();
1185        assert!(slides[0].table.is_some());
1186    }
1187
1188    #[test]
1189    fn test_code_block() {
1190        let html = r#"
1191            <html><body>
1192                <h1>Code Example</h1>
1193                <pre><code>fn main() { println!("hello"); }</code></pre>
1194            </body></html>
1195        "#;
1196        let slides = parse_html(html).unwrap();
1197        assert!(!slides[0].code_blocks.is_empty());
1198        assert!(slides[0].code_blocks[0].code.contains("fn main()"));
1199    }
1200
1201    #[test]
1202    fn test_blockquote_notes() {
1203        let html = r#"
1204            <html><body>
1205                <h1>Slide</h1>
1206                <p>Content</p>
1207                <blockquote>Speaker note here</blockquote>
1208            </body></html>
1209        "#;
1210        let slides = parse_html(html).unwrap();
1211        assert_eq!(slides[0].notes, Some("Speaker note here".to_string()));
1212    }
1213
1214    #[test]
1215    fn test_hr_slide_break() {
1216        let html = "<h1>Slide 1</h1><p>Content</p><hr><h1>Slide 2</h1><p>More content</p>";
1217        let slides = parse_html(html).unwrap();
1218        assert_eq!(slides.len(), 2);
1219    }
1220
1221    #[test]
1222    fn test_entity_decoding() {
1223        let html = r#"
1224            <html><body>
1225                <h1>Test</h1>
1226                <p>AT&amp;T &lt;test&gt; &quot;quote&quot;</p>
1227            </body></html>
1228        "#;
1229        let slides = parse_html(html).unwrap();
1230        assert!(slides[0].content[0].contains("AT&T"));
1231        assert!(slides[0].content[0].contains("<test>"));
1232    }
1233
1234    #[test]
1235    fn test_img_placeholder() {
1236        let html = r#"
1237            <html><body>
1238                <h1>Images</h1>
1239                <img src="photo.jpg" alt="A photo">
1240            </body></html>
1241        "#;
1242        let slides = parse_html(html).unwrap();
1243        assert!(slides[0].content.iter().any(|c| c.contains("[Image: A photo]")));
1244    }
1245
1246    #[test]
1247    fn test_skip_script_style() {
1248        let html = r#"
1249            <html><body>
1250                <h1>Real Content</h1>
1251                <p>Visible text</p>
1252                <script>var x = "should not appear";</script>
1253                <style>.hidden { color: red; }</style>
1254            </body></html>
1255        "#;
1256        let slides = parse_html(html).unwrap();
1257        assert_eq!(slides.len(), 1);
1258        assert_eq!(slides[0].content.len(), 1);
1259        assert!(slides[0].content[0].contains("Visible"));
1260    }
1261
1262    #[test]
1263    fn test_no_h1_fallback() {
1264        let html = r#"<html><body><p>Just a paragraph.</p></body></html>"#;
1265        let slides = parse_html(html).unwrap();
1266        assert_eq!(slides.len(), 1);
1267    }
1268
1269    #[test]
1270    fn test_empty_input() {
1271        let result = parse_html("<html><body></body></html>");
1272        assert!(result.is_err());
1273    }
1274
1275    #[test]
1276    fn test_br_tag() {
1277        let html = r#"<html><body><h1>Title</h1><p>Line 1<br>Line 2</p></body></html>"#;
1278        let slides = parse_html(html).unwrap();
1279        assert!(!slides[0].content.is_empty());
1280    }
1281
1282    #[test]
1283    fn test_bold_italic() {
1284        let html = r#"
1285            <html><body>
1286                <h1>Formatting</h1>
1287                <p><strong>Bold</strong> and <em>italic</em> text</p>
1288            </body></html>
1289        "#;
1290        let slides = parse_html(html).unwrap();
1291        let c = &slides[0].content[0];
1292        assert!(c.contains("**Bold**"));
1293    }
1294
1295    #[test]
1296    fn test_complex_nested() {
1297        let html = r#"
1298            <html><body>
1299                <h1>Welcome</h1>
1300                <p>Introduction paragraph.</p>
1301                <h2>Section A</h2>
1302                <ul>
1303                    <li>First item</li>
1304                    <li>Second item</li>
1305                </ul>
1306                <h1>Details</h1>
1307                <table><tr><th>Col1</th><th>Col2</th></tr>
1308                       <tr><td>A</td><td>B</td></tr></table>
1309                <pre><code>let x = 1;</code></pre>
1310            </body></html>
1311        "#;
1312        let slides = parse_html(html).unwrap();
1313        assert_eq!(slides.len(), 2);
1314        assert_eq!(slides[0].title, "Welcome");
1315        assert!(!slides[1].code_blocks.is_empty());
1316        assert!(slides[1].table.is_some());
1317    }
1318
1319    #[test]
1320    fn test_html2ppt_options() {
1321        let options = HtmlParseOptions::new()
1322            .max_slides(3)
1323            .max_bullets(5)
1324            .include_images(false);
1325        assert_eq!(options.max_slides, 3);
1326        assert_eq!(options.max_bullets, 5);
1327        assert!(!options.include_images);
1328    }
1329
1330    #[test]
1331    fn test_html2ppt_struct() {
1332        let converter = Html2Ppt::new();
1333        let html = "<h1>Test</h1><p>Content</p>";
1334        let slides = converter.parse(html).unwrap();
1335        assert_eq!(slides.len(), 1);
1336    }
1337
1338    #[test]
1339    fn test_nested_elements() {
1340        let html = r#"
1341            <div><div><div><div><div>
1342                <h1>Deep Nesting</h1>
1343                <p>Still works</p>
1344            </div></div></div></div></div>
1345        "#;
1346        let slides = parse_html(html).unwrap();
1347        assert_eq!(slides[0].title, "Deep Nesting");
1348    }
1349
1350    #[test]
1351    fn test_link_with_href() {
1352        let html = r#"
1353            <html><body>
1354                <h1>Links</h1>
1355                <p>Visit <a href="https://example.com">Example</a> website</p>
1356            </body></html>
1357        "#;
1358        let slides = parse_html(html).unwrap();
1359        assert!(slides[0].content[0].contains("Example"));
1360    }
1361
1362    #[test]
1363    fn test_attrs_with_single_quotes() {
1364        let events = tokenize_html(r#"<img src='pic.jpg' alt='hello'>"#);
1365        assert_eq!(events.len(), 1);
1366        match &events[0] {
1367            HtmlEvent::OpenTag { name, attrs } => {
1368                assert_eq!(name, "img");
1369                assert_eq!(attrs.iter().find(|(k,_)| k == "src").map(|(_,v)| v.as_str()), Some("pic.jpg"));
1370                assert_eq!(attrs.iter().find(|(k,_)| k == "alt").map(|(_,v)| v.as_str()), Some("hello"));
1371            }
1372            _ => panic!("expected OpenTag"),
1373        }
1374    }
1375
1376    #[test]
1377    fn test_tokenizer_complex() {
1378        let events = tokenize_html(r#"<div class="main"><h1 id="title">Hello</h1></div>"#);
1379        assert_eq!(events.len(), 5);
1380        match &events[0] {
1381            HtmlEvent::OpenTag { name, attrs } => {
1382                assert_eq!(name, "div");
1383                assert_eq!(attrs[0].0, "class");
1384                assert_eq!(attrs[0].1, "main");
1385            }
1386            _ => panic!("expected OpenTag div"),
1387        }
1388    }
1389
1390    #[test]
1391    fn test_self_closing_void_tags() {
1392        let events = tokenize_html(r#"<br><hr><img src="x.jpg">"#);
1393        assert_eq!(events.len(), 3);
1394        for event in &events {
1395            match event {
1396                HtmlEvent::OpenTag { name, .. } => {
1397                    assert!(["br", "hr", "img"].contains(&name.as_str()));
1398                }
1399                _ => panic!("expected OpenTag for void elements"),
1400            }
1401        }
1402    }
1403
1404    #[test]
1405    fn test_comments_skipped() {
1406        let events = tokenize_html(r#"<h1>A</h1><!-- comment --><p>B</p>"#);
1407        // Events: OpenTag(h1), Text(A), CloseTag(h1), OpenTag(p), Text(B), CloseTag(p)
1408        assert_eq!(events.len(), 6);
1409        match &events[3] {
1410            HtmlEvent::OpenTag { name, .. } => assert_eq!(name, "p"),
1411            _ => panic!("expected p"),
1412        }
1413    }
1414
1415    #[test]
1416    fn test_doctype_skipped() {
1417        let events = tokenize_html("<!DOCTYPE html><h1>Title</h1>");
1418        assert_eq!(events.len(), 3);
1419        match &events[0] {
1420            HtmlEvent::OpenTag { name, .. } => assert_eq!(name, "h1"),
1421            _ => panic!("expected h1"),
1422        }
1423    }
1424
1425    #[test]
1426    fn test_multiple_attributes() {
1427        let events = tokenize_html(r#"<a href="https://x.com" class="link" id="main">text</a>"#);
1428        assert_eq!(events.len(), 3);
1429        match &events[0] {
1430            HtmlEvent::OpenTag { name, attrs } => {
1431                assert_eq!(name, "a");
1432                assert_eq!(attrs.len(), 3);
1433            }
1434            _ => panic!("expected OpenTag"),
1435        }
1436    }
1437
1438    // ========================================================================
1439    // CSS Style Parsing Tests
1440    // ========================================================================
1441
1442    #[test]
1443    fn test_parse_css_color_hex() {
1444        assert_eq!(parse_css_color("#ff0000"), Some("FF0000".to_string()));
1445        assert_eq!(parse_css_color("#FF0000"), Some("FF0000".to_string()));
1446        assert_eq!(parse_css_color("#f00"), Some("FF0000".to_string()));
1447        assert_eq!(parse_css_color("#abc"), Some("AABBCC".to_string()));
1448    }
1449
1450    #[test]
1451    fn test_parse_css_color_named() {
1452        assert_eq!(parse_css_color("red"), Some("FF0000".to_string()));
1453        assert_eq!(parse_css_color("blue"), Some("0000FF".to_string()));
1454        assert_eq!(parse_css_color("green"), Some("008000".to_string()));
1455        assert_eq!(parse_css_color("white"), Some("FFFFFF".to_string()));
1456        assert_eq!(parse_css_color("black"), Some("000000".to_string()));
1457    }
1458
1459    #[test]
1460    fn test_parse_css_color_rgb() {
1461        assert_eq!(parse_css_color("rgb(255,0,0)"), Some("FF0000".to_string()));
1462        assert_eq!(parse_css_color("rgb(0, 128, 0)"), Some("008000".to_string()));
1463        assert_eq!(parse_css_color("rgba(0, 0, 255, 0.5)"), Some("0000FF".to_string()));
1464    }
1465
1466    #[test]
1467    fn test_parse_css_color_invalid() {
1468        assert_eq!(parse_css_color("notacolor"), None);
1469        assert_eq!(parse_css_color("transparent"), None);
1470        assert_eq!(parse_css_color("#ggggg"), None);
1471    }
1472
1473    #[test]
1474    fn test_parse_font_size() {
1475        assert_eq!(parse_font_size("20px"), Some(15)); // 20/1.333 ≈ 15
1476        assert_eq!(parse_font_size("16px"), Some(12));
1477        assert_eq!(parse_font_size("18pt"), Some(18));
1478        assert_eq!(parse_font_size("12pt"), Some(12));
1479        assert_eq!(parse_font_size("44"), Some(44));
1480    }
1481
1482    #[test]
1483    fn test_is_font_weight_bold() {
1484        assert!(is_font_weight_bold("bold"));
1485        assert!(is_font_weight_bold("700"));
1486        assert!(is_font_weight_bold("800"));
1487        assert!(is_font_weight_bold("900"));
1488        assert!(is_font_weight_bold("bolder"));
1489        assert!(!is_font_weight_bold("normal"));
1490        assert!(!is_font_weight_bold("400"));
1491        assert!(!is_font_weight_bold("100"));
1492    }
1493
1494    #[test]
1495    fn test_is_font_style_italic() {
1496        assert!(is_font_style_italic("italic"));
1497        assert!(is_font_style_italic("oblique"));
1498        assert!(!is_font_style_italic("normal"));
1499    }
1500
1501    #[test]
1502    fn test_inline_style_parse_single() {
1503        let s = InlineStyle::parse("color: red");
1504        assert_eq!(s.color, Some("FF0000".to_string()));
1505        assert_eq!(s.background_color, None);
1506    }
1507
1508    #[test]
1509    fn test_inline_style_parse_multiple() {
1510        let s = InlineStyle::parse("color: #0000FF; font-size: 20px; font-weight: bold");
1511        assert_eq!(s.color, Some("0000FF".to_string()));
1512        assert_eq!(s.font_size, Some(15));
1513        assert_eq!(s.font_weight, Some("bold".to_string()));
1514    }
1515
1516    #[test]
1517    fn test_inline_style_parse_background() {
1518        let s = InlineStyle::parse("background-color: yellow");
1519        assert_eq!(s.background_color, Some("FFFF00".to_string()));
1520    }
1521
1522    #[test]
1523    fn test_inline_style_parse_text_decoration() {
1524        let s = InlineStyle::parse("text-decoration: underline");
1525        assert_eq!(s.text_decoration, Some("underline".to_string()));
1526        let s = InlineStyle::parse("text-decoration: line-through");
1527        assert_eq!(s.text_decoration, Some("line-through".to_string()));
1528    }
1529
1530    #[test]
1531    fn test_inline_style_parse_font_family() {
1532        let s = InlineStyle::parse("font-family: Arial");
1533        assert_eq!(s.font_family, Some("Arial".to_string()));
1534        let s = InlineStyle::parse("font-family: 'Times New Roman'");
1535        assert_eq!(s.font_family, Some("Times New Roman".to_string()));
1536    }
1537
1538    #[test]
1539    fn test_inline_style_merge_child_overrides() {
1540        let parent = InlineStyle {
1541            color: Some("FF0000".to_string()),
1542            font_size: Some(20),
1543            ..Default::default()
1544        };
1545        let child = InlineStyle {
1546            color: Some("0000FF".to_string()),
1547            ..Default::default()
1548        };
1549        let merged = parent.merge(&child);
1550        assert_eq!(merged.color, Some("0000FF".to_string())); // child overrides
1551        assert_eq!(merged.font_size, Some(20)); // parent preserved
1552    }
1553
1554    #[test]
1555    fn test_inline_style_merge_empty_child() {
1556        let parent = InlineStyle {
1557            color: Some("FF0000".to_string()),
1558            ..Default::default()
1559        };
1560        let child = InlineStyle::default();
1561        let merged = parent.merge(&child);
1562        assert_eq!(merged.color, Some("FF0000".to_string())); // parent preserved
1563    }
1564
1565    #[test]
1566    fn test_inline_style_merge_no_parent() {
1567        let parent = InlineStyle::default();
1568        let child = InlineStyle::parse("color: red; font-size: 18pt");
1569        let merged = parent.merge(&child);
1570        assert_eq!(merged.color, Some("FF0000".to_string()));
1571        assert_eq!(merged.font_size, Some(18));
1572    }
1573
1574    // ========================================================================
1575    // Style Propagation Tests (from HTML attributes to BulletFormat)
1576    // ========================================================================
1577
1578    #[test]
1579    fn test_paragraph_inline_color() {
1580        let html = r#"<h1>Test</h1><p style="color:red">Red text</p>"#;
1581        let slides = parse_html(html).unwrap();
1582        assert_eq!(slides[0].bullets.len(), 1);
1583        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1584        assert_eq!(fmt.color, Some("FF0000".to_string()));
1585    }
1586
1587    #[test]
1588    fn test_paragraph_inline_font_size() {
1589        let html = r#"<h1>Test</h1><p style="font-size:20px">Bigger text</p>"#;
1590        let slides = parse_html(html).unwrap();
1591        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1592        assert_eq!(fmt.font_size, Some(15));
1593    }
1594
1595    #[test]
1596    fn test_paragraph_inline_bold() {
1597        let html = r#"<h1>Test</h1><p style="font-weight:bold">Bold paragraph</p>"#;
1598        let slides = parse_html(html).unwrap();
1599        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1600        assert!(fmt.bold);
1601    }
1602
1603    #[test]
1604    fn test_paragraph_inline_italic() {
1605        let html = r#"<h1>Test</h1><p style="font-style:italic">Italic paragraph</p>"#;
1606        let slides = parse_html(html).unwrap();
1607        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1608        assert!(fmt.italic);
1609    }
1610
1611    #[test]
1612    fn test_paragraph_inline_underline() {
1613        let html = r#"<h1>Test</h1><p style="text-decoration:underline">Underlined</p>"#;
1614        let slides = parse_html(html).unwrap();
1615        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1616        assert!(fmt.underline);
1617    }
1618
1619    #[test]
1620    fn test_paragraph_inline_multiple_styles() {
1621        let html = r#"<h1>Test</h1><p style="color:blue; font-size:18pt; font-weight:bold">Styled</p>"#;
1622        let slides = parse_html(html).unwrap();
1623        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1624        assert_eq!(fmt.color, Some("0000FF".to_string()));
1625        assert_eq!(fmt.font_size, Some(18));
1626        assert!(fmt.bold);
1627    }
1628
1629    #[test]
1630    fn test_paragraph_no_style_no_format() {
1631        let html = "<h1>Test</h1><p>Plain text</p>";
1632        let slides = parse_html(html).unwrap();
1633        assert!(slides[0].bullets[0].format.is_none());
1634    }
1635
1636    #[test]
1637    fn test_h1_inline_color() {
1638        let html = r#"<h1 style="color:green">Green Title</h1>"#;
1639        let slides = parse_html(html).unwrap();
1640        assert_eq!(slides[0].title_color, Some("008000".to_string()));
1641    }
1642
1643    #[test]
1644    fn test_h1_inline_font_size() {
1645        let html = r#"<h1 style="font-size:36pt">Big Title</h1>"#;
1646        let slides = parse_html(html).unwrap();
1647        assert_eq!(slides[0].title_size, Some(36));
1648    }
1649
1650    #[test]
1651    fn test_h1_inline_bold_true() {
1652        let html = r#"<h1 style="font-weight:bold">Bold Title</h1>"#;
1653        let slides = parse_html(html).unwrap();
1654        assert!(slides[0].title_bold); // True because css bold=True; default is also true
1655    }
1656
1657    #[test]
1658    fn test_h1_inline_italic() {
1659        let html = r#"<h1 style="font-style:italic">Italic Title</h1>"#;
1660        let slides = parse_html(html).unwrap();
1661        assert!(slides[0].title_italic);
1662    }
1663
1664    #[test]
1665    fn test_h1_underline_from_style() {
1666        let html = r#"<h1 style="text-decoration:underline">Underlined Title</h1>"#;
1667        let slides = parse_html(html).unwrap();
1668        assert!(slides[0].title_underline);
1669    }
1670
1671    #[test]
1672    fn test_list_item_with_inline_style() {
1673        let html = r#"<h1>List</h1><ul><li style="color:red">Red item</li><li>Normal item</li></ul>"#;
1674        let slides = parse_html(html).unwrap();
1675        let fmt0 = slides[0].bullets[0].format.as_ref().expect("First item should have format");
1676        assert_eq!(fmt0.color, Some("FF0000".to_string()));
1677        assert!(slides[0].bullets[1].format.is_none()); // second item has no style
1678    }
1679
1680    #[test]
1681    fn test_nested_style_inheritance() {
1682        let html = r#"<div style="color:red"><p>Red text</p><p style="color:blue">Blue text</p></div>"#;
1683        let slides = parse_html(html).unwrap();
1684        // Both paragraphs ended up as bullets on the same slide (if first h1 existed or auto-title)
1685        assert_eq!(slides[0].bullets.len(), 2);
1686        let fmt0 = slides[0].bullets[0].format.as_ref().expect("First should have format");
1687        assert_eq!(fmt0.color, Some("FF0000".to_string())); // inherits red from div
1688        let fmt1 = slides[0].bullets[1].format.as_ref().expect("Second should have format");
1689        assert_eq!(fmt1.color, Some("0000FF".to_string())); // overrides to blue
1690    }
1691
1692    #[test]
1693    fn test_style_on_container_div() {
1694        let html = r#"<h1>Styled Container</h1><div style="color:purple"><p>Purple paragraph</p></div>"#;
1695        let slides = parse_html(html).unwrap();
1696        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1697        assert_eq!(fmt.color, Some("800080".to_string()));
1698    }
1699
1700    #[test]
1701    fn test_void_tag_br_does_not_affect_style() {
1702        let html = r#"<h1>Test</h1><p style="color:red">First<br style="color:blue">Second</p>"#;
1703        let slides = parse_html(html).unwrap();
1704        // The <br> should not push/pop style, so the paragraph should have color:red
1705        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1706        assert_eq!(fmt.color, Some("FF0000".to_string()));
1707    }
1708
1709    #[test]
1710    fn test_style_content_size_default() {
1711        let html = "<h1>Test</h1><p>Default size</p>";
1712        let slides = parse_html(html).unwrap();
1713        assert_eq!(slides[0].content_size, Some(28));
1714    }
1715
1716    #[test]
1717    fn test_background_color_as_highlight() {
1718        let html = r#"<h1>Test</h1><p style="background-color:yellow">Highlighted</p>"#;
1719        let slides = parse_html(html).unwrap();
1720        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1721        assert_eq!(fmt.highlight, Some("FFFF00".to_string()));
1722    }
1723}