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