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