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