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