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, 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    fn parse(&mut self, events: &[HtmlEvent]) -> Result<Vec<SlideContent>, String> {
650        for event in events {
651            match event {
652                HtmlEvent::OpenTag { name, attrs } => {
653                    self.tag_stack.push(name.clone());
654                    self.handle_open_tag(name, attrs);
655                }
656                HtmlEvent::CloseTag(name) => {
657                    self.handle_close_tag(name);
658                    self.tag_stack.pop();
659                }
660                HtmlEvent::Text(text) => {
661                    self.handle_text(text);
662                }
663            }
664        }
665
666        self.finalize_current_slide();
667
668        if self.slides.is_empty() {
669            return Err("No slide content found in HTML".to_string());
670        }
671
672        // Trim to max_slides
673        if self.slides.len() > self.options.max_slides {
674            self.slides.truncate(self.options.max_slides);
675        }
676
677        Ok(std::mem::take(&mut self.slides))
678    }
679
680    fn is_inside_skip_tag(&self) -> bool {
681        self.tag_stack.iter().any(|t| SKIP_TAGS.contains(&t.as_str()))
682    }
683
684    fn handle_open_tag(&mut self, name: &str, attrs: &[(String, String)]) {
685        if self.is_inside_skip_tag() {
686            return;
687        }
688
689        // Push style for this element (inherits from parent). Skip void tags.
690        if !VOID_TAGS.contains(&name) {
691            let parent = self.style_stack.last().cloned().unwrap_or_default();
692            let style = if let Some(style_attr) = attrs.iter().find(|(k, _)| k == "style") {
693                parent.merge(&InlineStyle::parse(&style_attr.1))
694            } else {
695                parent
696            };
697            self.style_stack.push(style);
698        }
699
700        match name {
701            "h1" => {
702                self.flush_text_buffer();
703                self.finalize_current_slide();
704            }
705            "h2" | "h3" | "h4" | "h5" | "h6" => {
706                self.flush_text_buffer();
707            }
708            "p" | "div" | "article" | "section" | "main" | "li" => {}
709            "pre" => {
710                self.in_code = true;
711                self.code_content.clear();
712            }
713            "table" => {
714                self.in_table = true;
715                self.table_rows.clear();
716            }
717            "blockquote" => {
718                self.in_blockquote = true;
719                self.blockquote_text.clear();
720            }
721            "ul" | "ol" => {
722                self.in_list = true;
723                self.list_items.clear();
724            }
725            "strong" | "b" => {
726                self.text_buffer.push_str("**");
727            }
728            "em" | "i" => {
729                self.text_buffer.push('*');
730                self.italic = true;
731            }
732            "title" => {}
733            "img" => {
734                if self.options.include_images {
735                    let alt = attrs.iter().find(|(k, _)| k == "alt").map(|(_, v)| v.as_str()).unwrap_or("");
736                    let src = attrs.iter().find(|(k, _)| k == "src").map(|(_, v)| v.as_str()).unwrap_or("");
737
738                    if !src.is_empty() {
739                        // Try to download and embed the actual image
740                        if let Some(image) = self.load_image(src, alt) {
741                            if let Some(ref mut slide) = self.current_slide {
742                                slide.images.push(image);
743                            } else {
744                                let mut slide = SlideContent::new("Image");
745                                slide.images.push(image);
746                                self.current_slide = Some(slide);
747                            }
748                        } else {
749                            // Fallback to placeholder if image loading fails
750                            let label = if alt.is_empty() { src } else { alt };
751                            self.add_paragraph(&format!("[Image: {}]", label));
752                        }
753                    }
754                }
755            }
756            "a" => {
757                // Handle hyperlinks - just mark that we're inside a link
758                // The actual link text will be handled in text processing
759                if let Some(href) = attrs.iter().find(|(k, _)| k == "href").map(|(_, v)| v.as_str()) {
760                    // Store the current href for later use
761                    self.current_href = Some(href.to_string());
762                }
763            }
764            "br" => {
765                self.text_buffer.push('\n');
766            }
767            "hr" => {
768                self.flush_text_buffer();
769                self.finalize_current_slide();
770            }
771            _ => {}
772        }
773    }
774
775    fn handle_close_tag(&mut self, name: &str) {
776        if self.is_inside_skip_tag() {
777            return;
778        }
779
780        match name {
781            "h1" => {
782                let title = std::mem::take(&mut self.text_buffer).trim().to_string();
783                if self.presentation_title.is_none() && !title.is_empty() {
784                    self.presentation_title = Some(title.clone());
785                }
786                let slide_title = if title.is_empty() { "Slide".to_string() } else { title };
787                let mut slide = SlideContent::new(&slide_title);
788                // Apply title-level styles from active style
789                if let Some(ref s) = self.active_style() {
790                    if let Some(ref c) = s.color { slide = slide.title_color(c); }
791                    if let Some(sz) = s.font_size { slide = slide.title_size(sz); }
792                    if let Some(ref fw) = s.font_weight { if is_font_weight_bold(fw) { slide = slide.title_bold(true); } }
793                    if let Some(ref fs) = s.font_style { if is_font_style_italic(fs) { slide = slide.title_italic(true); } }
794                    if let Some(ref td) = s.text_decoration { if td.contains("underline") { slide = slide.title_underline(true); } }
795                }
796                self.current_slide = Some(slide);
797            }
798            "h2" | "h3" | "h4" | "h5" | "h6" => {
799                let text = std::mem::take(&mut self.text_buffer).trim().to_string();
800                if !text.is_empty() {
801                    self.add_formatted_text(&format!("**{}**", text));
802                }
803            }
804            "p" => {
805                let text = std::mem::take(&mut self.text_buffer).trim().to_string();
806                if !text.is_empty() {
807                    self.add_paragraph(&text);
808                }
809            }
810            "div" | "article" | "section" | "main" => {
811                let text = std::mem::take(&mut self.text_buffer).trim().to_string();
812                if !text.is_empty() {
813                    self.add_paragraph(&text);
814                }
815            }
816            "li" => {
817                let item = std::mem::take(&mut self.text_buffer).trim().to_string();
818                if !item.is_empty() {
819                    let item_style = self.active_style().and_then(|s| s.to_bullet_format());
820                    self.list_items.push((item, item_style));
821                }
822            }
823            "ul" | "ol" => {
824                self.flush_list_items();
825                self.in_list = false;
826            }
827            "pre" => {
828                self.in_code = false;
829                self.flush_code_block();
830            }
831            "table" => {
832                self.in_table = false;
833                self.flush_table();
834            }
835            "blockquote" => {
836                self.in_blockquote = false;
837                self.flush_blockquote();
838            }
839            "th" | "td" => {
840                let cell = std::mem::take(&mut self.current_cell).trim().to_string();
841                self.current_row.push(cell);
842            }
843            "tr" => {
844                if !self.current_row.is_empty() {
845                    self.table_rows.push(std::mem::take(&mut self.current_row));
846                    self.current_row = Vec::new();
847                }
848            }
849            "strong" | "b" => {
850                self.text_buffer.push_str("**");
851            }
852            "em" | "i" => {
853                self.text_buffer.push('*');
854                self.italic = false;
855            }
856            "a" => {
857                // When closing an anchor tag, we have the link text in the buffer
858                // and the href in current_href. For now, we'll just clear the href
859                // since PowerPoint hyperlink support requires more complex XML handling
860                self.current_href = None;
861            }
862            _ => {}
863        }
864
865        // Pop style stack for non-void tags (mirrors push in handle_open_tag)
866        if !VOID_TAGS.contains(&name) {
867            self.style_stack.pop();
868        }
869    }
870
871    fn handle_text(&mut self, text: &str) {
872        if self.is_inside_skip_tag() {
873            return;
874        }
875
876        if self.in_code {
877            self.code_content.push_str(text);
878        } else if self.in_table {
879            self.current_cell.push_str(text);
880        } else if self.in_blockquote {
881            self.blockquote_text.push_str(text);
882        } else if self.in_list {
883            self.text_buffer.push_str(text);
884        } else {
885            self.text_buffer.push_str(text);
886        }
887    }
888
889    fn add_formatted_text(&mut self, text: &str) {
890        let fmt = self.active_style().and_then(|s| s.to_bullet_format());
891        if let Some(ref mut slide) = self.current_slide {
892            let mut bp = BulletPoint::new(text).with_style(slide.bullet_style);
893            if let Some(ref f) = fmt {
894                bp = bp.with_format(f.clone());
895            }
896            slide.content.push(text.to_string());
897            slide.bullets.push(bp);
898        } else {
899            let mut slide = SlideContent::new("Slide");
900            let mut bp = BulletPoint::new(text).with_style(slide.bullet_style);
901            if let Some(ref f) = fmt {
902                bp = bp.with_format(f.clone());
903            }
904            slide.content.push(text.to_string());
905            slide.bullets.push(bp);
906            self.current_slide = Some(slide);
907        }
908    }
909
910    fn add_paragraph(&mut self, text: &str) {
911        let fmt = self.active_style().and_then(|s| s.to_bullet_format());
912        if let Some(ref mut slide) = self.current_slide {
913            if slide.content.len() < self.options.max_bullets {
914                let mut bp = BulletPoint::new(text).with_style(slide.bullet_style);
915                if let Some(ref f) = fmt {
916                    bp = bp.with_format(f.clone());
917                }
918                slide.content.push(text.to_string());
919                slide.bullets.push(bp);
920            }
921        } else {
922            let title = self.presentation_title.clone().unwrap_or_else(|| "Overview".to_string());
923            let mut slide = SlideContent::new(&title);
924            let mut bp = BulletPoint::new(text).with_style(slide.bullet_style);
925            if let Some(ref f) = fmt {
926                bp = bp.with_format(f.clone());
927            }
928            slide.content.push(text.to_string());
929            slide.bullets.push(bp);
930            self.current_slide = Some(slide);
931        }
932    }
933
934    fn flush_text_buffer(&mut self) {
935        let text = std::mem::take(&mut self.text_buffer);
936        let trimmed = text.trim().to_string();
937        if !trimmed.is_empty() {
938            self.add_paragraph(&trimmed);
939        }
940    }
941
942    fn flush_list_items(&mut self) {
943        let items = std::mem::take(&mut self.list_items);
944        if items.is_empty() {
945            return;
946        }
947
948        if let Some(ref mut slide) = self.current_slide {
949            for (item, item_style) in items {
950                if slide.content.len() < self.options.max_bullets {
951                    let mut bp = BulletPoint::new(&item).with_style(slide.bullet_style);
952                    if let Some(ref f) = item_style {
953                        bp = bp.with_format(f.clone());
954                    }
955                    slide.content.push(item);
956                    slide.bullets.push(bp);
957                }
958            }
959        } else {
960            let title = self.presentation_title.clone().unwrap_or_else(|| "Key Points".to_string());
961            let mut slide = SlideContent::new(&title);
962            for (item, item_style) in items {
963                if slide.content.len() < self.options.max_bullets {
964                    let mut bp = BulletPoint::new(&item).with_style(slide.bullet_style);
965                    if let Some(ref f) = item_style {
966                        bp = bp.with_format(f.clone());
967                    }
968                    slide.content.push(item);
969                    slide.bullets.push(bp);
970                }
971            }
972            self.current_slide = Some(slide);
973        }
974    }
975
976    fn flush_table(&mut self) {
977        if !self.options.include_tables || self.table_rows.is_empty() {
978            return;
979        }
980
981        let rows = std::mem::take(&mut self.table_rows);
982        let table = crate::generator::table::table_from_string_rows(rows, true);
983
984        if let Some(ref mut slide) = self.current_slide {
985            slide.table = Some(table);
986            slide.has_table = true;
987        } else {
988            let mut slide = SlideContent::new("Data Table");
989            slide.table = Some(table);
990            slide.has_table = true;
991            self.current_slide = Some(slide);
992        }
993    }
994
995    fn flush_code_block(&mut self) {
996        if !self.options.include_code || self.code_content.is_empty() {
997            return;
998        }
999
1000        let code = std::mem::take(&mut self.code_content);
1001        let code_block = CodeBlock::new(code.trim(), "text");
1002
1003        if let Some(ref mut slide) = self.current_slide {
1004            slide.code_blocks.push(code_block);
1005        } else {
1006            let mut slide = SlideContent::new("Code");
1007            slide.code_blocks.push(code_block);
1008            self.current_slide = Some(slide);
1009        }
1010    }
1011
1012    fn flush_blockquote(&mut self) {
1013        let text = std::mem::take(&mut self.blockquote_text).trim().to_string();
1014        if text.is_empty() {
1015            return;
1016        }
1017
1018        if let Some(ref mut slide) = self.current_slide {
1019            slide.notes = Some(text);
1020        }
1021    }
1022
1023    fn finalize_current_slide(&mut self) {
1024        self.flush_text_buffer();
1025        self.flush_list_items();
1026        if let Some(slide) = self.current_slide.take() {
1027            self.slides.push(slide);
1028        }
1029    }
1030
1031    /// Load an image from URL or local file path
1032    fn load_image(&self, src: &str, _alt: &str) -> Option<crate::generator::Image> {
1033        use crate::generator::ImageBuilder;
1034        use std::path::Path;
1035
1036        // Check if it's a URL
1037        if src.starts_with("http://") || src.starts_with("https://") {
1038            // Try to download the image
1039            #[cfg(feature = "web2ppt")]
1040            {
1041                if let Ok(bytes) = self.download_image(src) {
1042                    let img = ImageBuilder::auto(bytes)
1043                        .at(2000000, 2000000)
1044                        .size(5000000, 3000000)
1045                        .build();
1046                    return Some(img);
1047                }
1048            }
1049            None
1050        } else {
1051            // Try to load from local file path
1052            let path = Path::new(src);
1053            if path.exists() {
1054                if let Ok(bytes) = std::fs::read(path) {
1055                    let img = ImageBuilder::auto(bytes)
1056                        .at(2000000, 2000000)
1057                        .size(5000000, 3000000)
1058                        .build();
1059                    return Some(img);
1060                }
1061            }
1062            None
1063        }
1064    }
1065
1066    /// Download an image from a URL (requires web2ppt feature)
1067    #[cfg(feature = "web2ppt")]
1068    fn download_image(&self, url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
1069        use reqwest::blocking::Client;
1070        use std::time::Duration;
1071
1072        let client = Client::builder()
1073            .timeout(Duration::from_secs(30))
1074            .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
1075            .build()?;
1076
1077        let response = client.get(url).send()?;
1078        if response.status().is_success() {
1079            Ok(response.bytes()?.to_vec())
1080        } else {
1081            Err(format!("Failed to download image: {}", response.status()).into())
1082        }
1083    }
1084}
1085
1086// ---------------------------------------------------------------------------
1087// Public API
1088// ---------------------------------------------------------------------------
1089
1090/// HTML to PowerPoint converter
1091pub struct Html2Ppt {
1092    options: HtmlParseOptions,
1093}
1094
1095impl Html2Ppt {
1096    pub fn new() -> Self {
1097        Self::with_options(HtmlParseOptions::default())
1098    }
1099
1100    pub fn with_options(options: HtmlParseOptions) -> Self {
1101        Self { options }
1102    }
1103
1104    /// Parse an HTML string into slide content
1105    pub fn parse(&self, html: &str) -> Result<Vec<SlideContent>, String> {
1106        let events = tokenize_html(html);
1107        HtmlSlideParser::new(self.options.clone()).parse(&events)
1108    }
1109
1110    /// Parse HTML from a file path
1111    pub fn parse_file(&self, path: &str) -> Result<Vec<SlideContent>, String> {
1112        let html = std::fs::read_to_string(path)
1113            .map_err(|e| format!("Failed to read HTML file: {e}"))?;
1114        self.parse(&html)
1115    }
1116}
1117
1118impl Default for Html2Ppt {
1119    fn default() -> Self {
1120        Self::new()
1121    }
1122}
1123
1124// ---------------------------------------------------------------------------
1125// Tests
1126// ---------------------------------------------------------------------------
1127
1128#[cfg(test)]
1129mod tests {
1130    use super::*;
1131
1132    #[test]
1133    fn test_tokenize_basic() {
1134        let events = tokenize_html("<h1>Hello</h1>");
1135        assert_eq!(events.len(), 3);
1136        match &events[0] {
1137            HtmlEvent::OpenTag { name, .. } => assert_eq!(name, "h1"),
1138            _ => panic!("expected OpenTag"),
1139        }
1140        match &events[1] {
1141            HtmlEvent::Text(t) => assert_eq!(t.trim(), "Hello"),
1142            _ => panic!("expected Text"),
1143        }
1144        match &events[2] {
1145            HtmlEvent::CloseTag(n) => assert_eq!(n, "h1"),
1146            _ => panic!("expected CloseTag"),
1147        }
1148    }
1149
1150    #[test]
1151    fn test_simple_headings() {
1152        let html = "<h1>First Slide</h1><p>Some content</p><h1>Second Slide</h1>";
1153        let slides = parse_html(html).unwrap();
1154        assert_eq!(slides.len(), 2);
1155        assert_eq!(slides[0].title, "First Slide");
1156        assert_eq!(slides[1].title, "Second Slide");
1157        assert_eq!(slides[1].content.len(), 0);
1158    }
1159
1160    #[test]
1161    fn test_table() {
1162        let html = r#"
1163            <html><body>
1164                <h1>Data</h1>
1165                <table>
1166                    <tr><th>Name</th><th>Value</th></tr>
1167                    <tr><td>A</td><td>1</td></tr>
1168                    <tr><td>B</td><td>2</td></tr>
1169                </table>
1170            </body></html>
1171        "#;
1172        let slides = parse_html(html).unwrap();
1173        assert!(slides[0].table.is_some());
1174    }
1175
1176    #[test]
1177    fn test_code_block() {
1178        let html = r#"
1179            <html><body>
1180                <h1>Code Example</h1>
1181                <pre><code>fn main() { println!("hello"); }</code></pre>
1182            </body></html>
1183        "#;
1184        let slides = parse_html(html).unwrap();
1185        assert!(!slides[0].code_blocks.is_empty());
1186        assert!(slides[0].code_blocks[0].code.contains("fn main()"));
1187    }
1188
1189    #[test]
1190    fn test_blockquote_notes() {
1191        let html = r#"
1192            <html><body>
1193                <h1>Slide</h1>
1194                <p>Content</p>
1195                <blockquote>Speaker note here</blockquote>
1196            </body></html>
1197        "#;
1198        let slides = parse_html(html).unwrap();
1199        assert_eq!(slides[0].notes, Some("Speaker note here".to_string()));
1200    }
1201
1202    #[test]
1203    fn test_hr_slide_break() {
1204        let html = "<h1>Slide 1</h1><p>Content</p><hr><h1>Slide 2</h1><p>More content</p>";
1205        let slides = parse_html(html).unwrap();
1206        assert_eq!(slides.len(), 2);
1207    }
1208
1209    #[test]
1210    fn test_entity_decoding() {
1211        let html = r#"
1212            <html><body>
1213                <h1>Test</h1>
1214                <p>AT&amp;T &lt;test&gt; &quot;quote&quot;</p>
1215            </body></html>
1216        "#;
1217        let slides = parse_html(html).unwrap();
1218        assert!(slides[0].content[0].contains("AT&T"));
1219        assert!(slides[0].content[0].contains("<test>"));
1220    }
1221
1222    #[test]
1223    fn test_img_placeholder() {
1224        let html = r#"
1225            <html><body>
1226                <h1>Images</h1>
1227                <img src="photo.jpg" alt="A photo">
1228            </body></html>
1229        "#;
1230        let slides = parse_html(html).unwrap();
1231        assert!(slides[0].content.iter().any(|c| c.contains("[Image: A photo]")));
1232    }
1233
1234    #[test]
1235    fn test_skip_script_style() {
1236        let html = r#"
1237            <html><body>
1238                <h1>Real Content</h1>
1239                <p>Visible text</p>
1240                <script>var x = "should not appear";</script>
1241                <style>.hidden { color: red; }</style>
1242            </body></html>
1243        "#;
1244        let slides = parse_html(html).unwrap();
1245        assert_eq!(slides.len(), 1);
1246        assert_eq!(slides[0].content.len(), 1);
1247        assert!(slides[0].content[0].contains("Visible"));
1248    }
1249
1250    #[test]
1251    fn test_no_h1_fallback() {
1252        let html = r#"<html><body><p>Just a paragraph.</p></body></html>"#;
1253        let slides = parse_html(html).unwrap();
1254        assert_eq!(slides.len(), 1);
1255    }
1256
1257    #[test]
1258    fn test_empty_input() {
1259        let result = parse_html("<html><body></body></html>");
1260        assert!(result.is_err());
1261    }
1262
1263    #[test]
1264    fn test_br_tag() {
1265        let html = r#"<html><body><h1>Title</h1><p>Line 1<br>Line 2</p></body></html>"#;
1266        let slides = parse_html(html).unwrap();
1267        assert!(!slides[0].content.is_empty());
1268    }
1269
1270    #[test]
1271    fn test_bold_italic() {
1272        let html = r#"
1273            <html><body>
1274                <h1>Formatting</h1>
1275                <p><strong>Bold</strong> and <em>italic</em> text</p>
1276            </body></html>
1277        "#;
1278        let slides = parse_html(html).unwrap();
1279        let c = &slides[0].content[0];
1280        assert!(c.contains("**Bold**"));
1281    }
1282
1283    #[test]
1284    fn test_complex_nested() {
1285        let html = r#"
1286            <html><body>
1287                <h1>Welcome</h1>
1288                <p>Introduction paragraph.</p>
1289                <h2>Section A</h2>
1290                <ul>
1291                    <li>First item</li>
1292                    <li>Second item</li>
1293                </ul>
1294                <h1>Details</h1>
1295                <table><tr><th>Col1</th><th>Col2</th></tr>
1296                       <tr><td>A</td><td>B</td></tr></table>
1297                <pre><code>let x = 1;</code></pre>
1298            </body></html>
1299        "#;
1300        let slides = parse_html(html).unwrap();
1301        assert_eq!(slides.len(), 2);
1302        assert_eq!(slides[0].title, "Welcome");
1303        assert!(!slides[1].code_blocks.is_empty());
1304        assert!(slides[1].table.is_some());
1305    }
1306
1307    #[test]
1308    fn test_html2ppt_options() {
1309        let options = HtmlParseOptions::new()
1310            .max_slides(3)
1311            .max_bullets(5)
1312            .include_images(false);
1313        assert_eq!(options.max_slides, 3);
1314        assert_eq!(options.max_bullets, 5);
1315        assert!(!options.include_images);
1316    }
1317
1318    #[test]
1319    fn test_html2ppt_struct() {
1320        let converter = Html2Ppt::new();
1321        let html = "<h1>Test</h1><p>Content</p>";
1322        let slides = converter.parse(html).unwrap();
1323        assert_eq!(slides.len(), 1);
1324    }
1325
1326    #[test]
1327    fn test_nested_elements() {
1328        let html = r#"
1329            <div><div><div><div><div>
1330                <h1>Deep Nesting</h1>
1331                <p>Still works</p>
1332            </div></div></div></div></div>
1333        "#;
1334        let slides = parse_html(html).unwrap();
1335        assert_eq!(slides[0].title, "Deep Nesting");
1336    }
1337
1338    #[test]
1339    fn test_link_with_href() {
1340        let html = r#"
1341            <html><body>
1342                <h1>Links</h1>
1343                <p>Visit <a href="https://example.com">Example</a> website</p>
1344            </body></html>
1345        "#;
1346        let slides = parse_html(html).unwrap();
1347        assert!(slides[0].content[0].contains("Example"));
1348    }
1349
1350    #[test]
1351    fn test_attrs_with_single_quotes() {
1352        let events = tokenize_html(r#"<img src='pic.jpg' alt='hello'>"#);
1353        assert_eq!(events.len(), 1);
1354        match &events[0] {
1355            HtmlEvent::OpenTag { name, attrs } => {
1356                assert_eq!(name, "img");
1357                assert_eq!(attrs.iter().find(|(k,_)| k == "src").map(|(_,v)| v.as_str()), Some("pic.jpg"));
1358                assert_eq!(attrs.iter().find(|(k,_)| k == "alt").map(|(_,v)| v.as_str()), Some("hello"));
1359            }
1360            _ => panic!("expected OpenTag"),
1361        }
1362    }
1363
1364    #[test]
1365    fn test_tokenizer_complex() {
1366        let events = tokenize_html(r#"<div class="main"><h1 id="title">Hello</h1></div>"#);
1367        assert_eq!(events.len(), 5);
1368        match &events[0] {
1369            HtmlEvent::OpenTag { name, attrs } => {
1370                assert_eq!(name, "div");
1371                assert_eq!(attrs[0].0, "class");
1372                assert_eq!(attrs[0].1, "main");
1373            }
1374            _ => panic!("expected OpenTag div"),
1375        }
1376    }
1377
1378    #[test]
1379    fn test_self_closing_void_tags() {
1380        let events = tokenize_html(r#"<br><hr><img src="x.jpg">"#);
1381        assert_eq!(events.len(), 3);
1382        for event in &events {
1383            match event {
1384                HtmlEvent::OpenTag { name, .. } => {
1385                    assert!(["br", "hr", "img"].contains(&name.as_str()));
1386                }
1387                _ => panic!("expected OpenTag for void elements"),
1388            }
1389        }
1390    }
1391
1392    #[test]
1393    fn test_comments_skipped() {
1394        let events = tokenize_html(r#"<h1>A</h1><!-- comment --><p>B</p>"#);
1395        // Events: OpenTag(h1), Text(A), CloseTag(h1), OpenTag(p), Text(B), CloseTag(p)
1396        assert_eq!(events.len(), 6);
1397        match &events[3] {
1398            HtmlEvent::OpenTag { name, .. } => assert_eq!(name, "p"),
1399            _ => panic!("expected p"),
1400        }
1401    }
1402
1403    #[test]
1404    fn test_doctype_skipped() {
1405        let events = tokenize_html("<!DOCTYPE html><h1>Title</h1>");
1406        assert_eq!(events.len(), 3);
1407        match &events[0] {
1408            HtmlEvent::OpenTag { name, .. } => assert_eq!(name, "h1"),
1409            _ => panic!("expected h1"),
1410        }
1411    }
1412
1413    #[test]
1414    fn test_multiple_attributes() {
1415        let events = tokenize_html(r#"<a href="https://x.com" class="link" id="main">text</a>"#);
1416        assert_eq!(events.len(), 3);
1417        match &events[0] {
1418            HtmlEvent::OpenTag { name, attrs } => {
1419                assert_eq!(name, "a");
1420                assert_eq!(attrs.len(), 3);
1421            }
1422            _ => panic!("expected OpenTag"),
1423        }
1424    }
1425
1426    // ========================================================================
1427    // CSS Style Parsing Tests
1428    // ========================================================================
1429
1430    #[test]
1431    fn test_parse_css_color_hex() {
1432        assert_eq!(parse_css_color("#ff0000"), Some("FF0000".to_string()));
1433        assert_eq!(parse_css_color("#FF0000"), Some("FF0000".to_string()));
1434        assert_eq!(parse_css_color("#f00"), Some("FF0000".to_string()));
1435        assert_eq!(parse_css_color("#abc"), Some("AABBCC".to_string()));
1436    }
1437
1438    #[test]
1439    fn test_parse_css_color_named() {
1440        assert_eq!(parse_css_color("red"), Some("FF0000".to_string()));
1441        assert_eq!(parse_css_color("blue"), Some("0000FF".to_string()));
1442        assert_eq!(parse_css_color("green"), Some("008000".to_string()));
1443        assert_eq!(parse_css_color("white"), Some("FFFFFF".to_string()));
1444        assert_eq!(parse_css_color("black"), Some("000000".to_string()));
1445    }
1446
1447    #[test]
1448    fn test_parse_css_color_rgb() {
1449        assert_eq!(parse_css_color("rgb(255,0,0)"), Some("FF0000".to_string()));
1450        assert_eq!(parse_css_color("rgb(0, 128, 0)"), Some("008000".to_string()));
1451        assert_eq!(parse_css_color("rgba(0, 0, 255, 0.5)"), Some("0000FF".to_string()));
1452    }
1453
1454    #[test]
1455    fn test_parse_css_color_invalid() {
1456        assert_eq!(parse_css_color("notacolor"), None);
1457        assert_eq!(parse_css_color("transparent"), None);
1458        assert_eq!(parse_css_color("#ggggg"), None);
1459    }
1460
1461    #[test]
1462    fn test_parse_font_size() {
1463        assert_eq!(parse_font_size("20px"), Some(15)); // 20/1.333 ≈ 15
1464        assert_eq!(parse_font_size("16px"), Some(12));
1465        assert_eq!(parse_font_size("18pt"), Some(18));
1466        assert_eq!(parse_font_size("12pt"), Some(12));
1467        assert_eq!(parse_font_size("44"), Some(44));
1468    }
1469
1470    #[test]
1471    fn test_is_font_weight_bold() {
1472        assert!(is_font_weight_bold("bold"));
1473        assert!(is_font_weight_bold("700"));
1474        assert!(is_font_weight_bold("800"));
1475        assert!(is_font_weight_bold("900"));
1476        assert!(is_font_weight_bold("bolder"));
1477        assert!(!is_font_weight_bold("normal"));
1478        assert!(!is_font_weight_bold("400"));
1479        assert!(!is_font_weight_bold("100"));
1480    }
1481
1482    #[test]
1483    fn test_is_font_style_italic() {
1484        assert!(is_font_style_italic("italic"));
1485        assert!(is_font_style_italic("oblique"));
1486        assert!(!is_font_style_italic("normal"));
1487    }
1488
1489    #[test]
1490    fn test_inline_style_parse_single() {
1491        let s = InlineStyle::parse("color: red");
1492        assert_eq!(s.color, Some("FF0000".to_string()));
1493        assert_eq!(s.background_color, None);
1494    }
1495
1496    #[test]
1497    fn test_inline_style_parse_multiple() {
1498        let s = InlineStyle::parse("color: #0000FF; font-size: 20px; font-weight: bold");
1499        assert_eq!(s.color, Some("0000FF".to_string()));
1500        assert_eq!(s.font_size, Some(15));
1501        assert_eq!(s.font_weight, Some("bold".to_string()));
1502    }
1503
1504    #[test]
1505    fn test_inline_style_parse_background() {
1506        let s = InlineStyle::parse("background-color: yellow");
1507        assert_eq!(s.background_color, Some("FFFF00".to_string()));
1508    }
1509
1510    #[test]
1511    fn test_inline_style_parse_text_decoration() {
1512        let s = InlineStyle::parse("text-decoration: underline");
1513        assert_eq!(s.text_decoration, Some("underline".to_string()));
1514        let s = InlineStyle::parse("text-decoration: line-through");
1515        assert_eq!(s.text_decoration, Some("line-through".to_string()));
1516    }
1517
1518    #[test]
1519    fn test_inline_style_parse_font_family() {
1520        let s = InlineStyle::parse("font-family: Arial");
1521        assert_eq!(s.font_family, Some("Arial".to_string()));
1522        let s = InlineStyle::parse("font-family: 'Times New Roman'");
1523        assert_eq!(s.font_family, Some("Times New Roman".to_string()));
1524    }
1525
1526    #[test]
1527    fn test_inline_style_merge_child_overrides() {
1528        let parent = InlineStyle {
1529            color: Some("FF0000".to_string()),
1530            font_size: Some(20),
1531            ..Default::default()
1532        };
1533        let child = InlineStyle {
1534            color: Some("0000FF".to_string()),
1535            ..Default::default()
1536        };
1537        let merged = parent.merge(&child);
1538        assert_eq!(merged.color, Some("0000FF".to_string())); // child overrides
1539        assert_eq!(merged.font_size, Some(20)); // parent preserved
1540    }
1541
1542    #[test]
1543    fn test_inline_style_merge_empty_child() {
1544        let parent = InlineStyle {
1545            color: Some("FF0000".to_string()),
1546            ..Default::default()
1547        };
1548        let child = InlineStyle::default();
1549        let merged = parent.merge(&child);
1550        assert_eq!(merged.color, Some("FF0000".to_string())); // parent preserved
1551    }
1552
1553    #[test]
1554    fn test_inline_style_merge_no_parent() {
1555        let parent = InlineStyle::default();
1556        let child = InlineStyle::parse("color: red; font-size: 18pt");
1557        let merged = parent.merge(&child);
1558        assert_eq!(merged.color, Some("FF0000".to_string()));
1559        assert_eq!(merged.font_size, Some(18));
1560    }
1561
1562    // ========================================================================
1563    // Style Propagation Tests (from HTML attributes to BulletFormat)
1564    // ========================================================================
1565
1566    #[test]
1567    fn test_paragraph_inline_color() {
1568        let html = r#"<h1>Test</h1><p style="color:red">Red text</p>"#;
1569        let slides = parse_html(html).unwrap();
1570        assert_eq!(slides[0].bullets.len(), 1);
1571        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1572        assert_eq!(fmt.color, Some("FF0000".to_string()));
1573    }
1574
1575    #[test]
1576    fn test_paragraph_inline_font_size() {
1577        let html = r#"<h1>Test</h1><p style="font-size:20px">Bigger text</p>"#;
1578        let slides = parse_html(html).unwrap();
1579        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1580        assert_eq!(fmt.font_size, Some(15));
1581    }
1582
1583    #[test]
1584    fn test_paragraph_inline_bold() {
1585        let html = r#"<h1>Test</h1><p style="font-weight:bold">Bold paragraph</p>"#;
1586        let slides = parse_html(html).unwrap();
1587        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1588        assert!(fmt.bold);
1589    }
1590
1591    #[test]
1592    fn test_paragraph_inline_italic() {
1593        let html = r#"<h1>Test</h1><p style="font-style:italic">Italic paragraph</p>"#;
1594        let slides = parse_html(html).unwrap();
1595        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1596        assert!(fmt.italic);
1597    }
1598
1599    #[test]
1600    fn test_paragraph_inline_underline() {
1601        let html = r#"<h1>Test</h1><p style="text-decoration:underline">Underlined</p>"#;
1602        let slides = parse_html(html).unwrap();
1603        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1604        assert!(fmt.underline);
1605    }
1606
1607    #[test]
1608    fn test_paragraph_inline_multiple_styles() {
1609        let html = r#"<h1>Test</h1><p style="color:blue; font-size:18pt; font-weight:bold">Styled</p>"#;
1610        let slides = parse_html(html).unwrap();
1611        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1612        assert_eq!(fmt.color, Some("0000FF".to_string()));
1613        assert_eq!(fmt.font_size, Some(18));
1614        assert!(fmt.bold);
1615    }
1616
1617    #[test]
1618    fn test_paragraph_no_style_no_format() {
1619        let html = "<h1>Test</h1><p>Plain text</p>";
1620        let slides = parse_html(html).unwrap();
1621        assert!(slides[0].bullets[0].format.is_none());
1622    }
1623
1624    #[test]
1625    fn test_h1_inline_color() {
1626        let html = r#"<h1 style="color:green">Green Title</h1>"#;
1627        let slides = parse_html(html).unwrap();
1628        assert_eq!(slides[0].title_color, Some("008000".to_string()));
1629    }
1630
1631    #[test]
1632    fn test_h1_inline_font_size() {
1633        let html = r#"<h1 style="font-size:36pt">Big Title</h1>"#;
1634        let slides = parse_html(html).unwrap();
1635        assert_eq!(slides[0].title_size, Some(36));
1636    }
1637
1638    #[test]
1639    fn test_h1_inline_bold_true() {
1640        let html = r#"<h1 style="font-weight:bold">Bold Title</h1>"#;
1641        let slides = parse_html(html).unwrap();
1642        assert!(slides[0].title_bold); // True because css bold=True; default is also true
1643    }
1644
1645    #[test]
1646    fn test_h1_inline_italic() {
1647        let html = r#"<h1 style="font-style:italic">Italic Title</h1>"#;
1648        let slides = parse_html(html).unwrap();
1649        assert!(slides[0].title_italic);
1650    }
1651
1652    #[test]
1653    fn test_h1_underline_from_style() {
1654        let html = r#"<h1 style="text-decoration:underline">Underlined Title</h1>"#;
1655        let slides = parse_html(html).unwrap();
1656        assert!(slides[0].title_underline);
1657    }
1658
1659    #[test]
1660    fn test_list_item_with_inline_style() {
1661        let html = r#"<h1>List</h1><ul><li style="color:red">Red item</li><li>Normal item</li></ul>"#;
1662        let slides = parse_html(html).unwrap();
1663        let fmt0 = slides[0].bullets[0].format.as_ref().expect("First item should have format");
1664        assert_eq!(fmt0.color, Some("FF0000".to_string()));
1665        assert!(slides[0].bullets[1].format.is_none()); // second item has no style
1666    }
1667
1668    #[test]
1669    fn test_nested_style_inheritance() {
1670        let html = r#"<div style="color:red"><p>Red text</p><p style="color:blue">Blue text</p></div>"#;
1671        let slides = parse_html(html).unwrap();
1672        // Both paragraphs ended up as bullets on the same slide (if first h1 existed or auto-title)
1673        assert_eq!(slides[0].bullets.len(), 2);
1674        let fmt0 = slides[0].bullets[0].format.as_ref().expect("First should have format");
1675        assert_eq!(fmt0.color, Some("FF0000".to_string())); // inherits red from div
1676        let fmt1 = slides[0].bullets[1].format.as_ref().expect("Second should have format");
1677        assert_eq!(fmt1.color, Some("0000FF".to_string())); // overrides to blue
1678    }
1679
1680    #[test]
1681    fn test_style_on_container_div() {
1682        let html = r#"<h1>Styled Container</h1><div style="color:purple"><p>Purple paragraph</p></div>"#;
1683        let slides = parse_html(html).unwrap();
1684        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1685        assert_eq!(fmt.color, Some("800080".to_string()));
1686    }
1687
1688    #[test]
1689    fn test_void_tag_br_does_not_affect_style() {
1690        let html = r#"<h1>Test</h1><p style="color:red">First<br style="color:blue">Second</p>"#;
1691        let slides = parse_html(html).unwrap();
1692        // The <br> should not push/pop style, so the paragraph should have color:red
1693        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1694        assert_eq!(fmt.color, Some("FF0000".to_string()));
1695    }
1696
1697    #[test]
1698    fn test_style_content_size_default() {
1699        let html = "<h1>Test</h1><p>Default size</p>";
1700        let slides = parse_html(html).unwrap();
1701        assert_eq!(slides[0].content_size, Some(28));
1702    }
1703
1704    #[test]
1705    fn test_background_color_as_highlight() {
1706        let html = r#"<h1>Test</h1><p style="background-color:yellow">Highlighted</p>"#;
1707        let slides = parse_html(html).unwrap();
1708        let fmt = slides[0].bullets[0].format.as_ref().expect("Should have format");
1709        assert_eq!(fmt.highlight, Some("FFFF00".to_string()));
1710    }
1711}