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