1use crate::generator::{CodeBlock, SlideContent};
42use crate::generator::slide_content::{BulletPoint, BulletTextFormat};
43
44#[derive(Clone, Debug)]
46pub struct HtmlParseOptions {
47 pub max_slides: usize,
49 pub max_bullets: usize,
51 pub include_code: bool,
53 pub include_tables: bool,
55 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
102pub fn parse_html(html: &str) -> Result<Vec<SlideContent>, String> {
104 Html2Ppt::with_options(HtmlParseOptions::default()).parse(html)
105}
106
107pub fn parse_html_with_options(html: &str, options: HtmlParseOptions) -> Result<Vec<SlideContent>, String> {
109 Html2Ppt::with_options(options).parse(html)
110}
111
112#[derive(Debug)]
118enum HtmlEvent {
119 OpenTag { name: String, attrs: Vec<(String, String)> },
120 CloseTag(String),
121 Text(String),
122}
123
124fn 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 let c = s[i..].chars().next().unwrap();
164 out.push(c);
165 i += c.len_utf8();
166 }
167 out
168}
169
170fn 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
225fn 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(), _ => 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
254fn 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
268fn is_font_weight_bold(value: &str) -> bool {
270 matches!(value.trim().to_lowercase().as_str(), "bold" | "bolder" | "700" | "800" | "900")
271}
272
273fn is_font_style_italic(value: &str) -> bool {
275 matches!(value.trim().to_lowercase().as_str(), "italic" | "oblique")
276}
277
278#[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 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 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 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
416const VOID_TAGS: &[&str] = &[
418 "area", "base", "br", "col", "embed", "hr", "img", "input",
419 "link", "meta", "param", "source", "track", "wbr",
420];
421
422fn 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 if i + 3 <= len && chars[i] == '!' && i + 1 < len && chars[i + 1] == '-' && i + 2 < len && chars[i + 2] == '-' {
438 i += 3;
440 while i + 2 < len && !(chars[i] == '-' && chars[i + 1] == '-' && chars[i + 2] == '>') {
441 i += 1;
442 }
443 i += 3; continue;
445 }
446
447 if chars[i] == '!' {
449 while i < len && chars[i] != '>' {
450 i += 1;
451 }
452 i += 1;
453 continue;
454 }
455
456 if chars[i] == '/' {
458 i += 1;
459 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; }
473 if !name.is_empty() {
474 events.push(HtmlEvent::CloseTag(name.to_lowercase()));
475 }
476 continue;
477 }
478
479 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 let mut attrs: Vec<(String, String)> = Vec::new();
493
494 while i < len && chars[i] != '>' {
495 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 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 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 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; }
536 } else {
537 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; }
551
552 if !tag_name.is_empty() {
553 events.push(HtmlEvent::OpenTag { name: tag_name, attrs });
554 }
555 } else {
556 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
572const 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 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 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 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 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 let label = if alt.is_empty() { src } else { alt };
736 self.add_paragraph(&format!("[Image: {}]", label));
737 }
738 }
739 }
740 }
741 "a" => {
742 if let Some(href) = attrs.iter().find(|(k, _)| k == "href").map(|(_, v)| v.as_str()) {
745 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 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 self.current_href = None;
846 }
847 _ => {}
848 }
849
850 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 fn load_image(&self, src: &str, _alt: &str) -> Option<crate::generator::Image> {
1016 use crate::generator::ImageBuilder;
1017 use std::path::Path;
1018
1019 if src.starts_with("http://") || src.starts_with("https://") {
1021 #[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 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 #[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
1068pub 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 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 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#[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&T <test> "quote"</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 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 #[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)); 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())); assert_eq!(merged.font_size, Some(20)); }
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())); }
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 #[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); }
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()); }
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 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())); let fmt1 = slides[0].bullets[1].format.as_ref().expect("Second should have format");
1659 assert_eq!(fmt1.color, Some("0000FF".to_string())); }
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 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}