diff --git a/crates/office2pdf/src/ir/style.rs b/crates/office2pdf/src/ir/style.rs index 3c2232af..2d6693bd 100644 --- a/crates/office2pdf/src/ir/style.rs +++ b/crates/office2pdf/src/ir/style.rs @@ -74,6 +74,8 @@ pub struct TextStyle { pub underline: Option, pub strikethrough: Option, pub color: Option, + /// Text highlight background color. + pub highlight: Option, /// Superscript or subscript vertical alignment. pub vertical_align: Option, /// All caps: render text in uppercase. diff --git a/crates/office2pdf/src/parser/docx.rs b/crates/office2pdf/src/parser/docx.rs index fd444588..d13b0cc8 100644 --- a/crates/office2pdf/src/parser/docx.rs +++ b/crates/office2pdf/src/parser/docx.rs @@ -171,6 +171,7 @@ fn merge_text_style(explicit: &TextStyle, style: Option<&ResolvedStyle>) -> Text font_size: style_text.font_size, color: style_text.color, font_family: style_text.font_family.clone(), + highlight: style_text.highlight, vertical_align: style_text.vertical_align, all_caps: style_text.all_caps, small_caps: style_text.small_caps, @@ -208,6 +209,9 @@ fn merge_text_style(explicit: &TextStyle, style: Option<&ResolvedStyle>) -> Text if explicit.font_family.is_some() { merged.font_family = explicit.font_family.clone(); } + if explicit.highlight.is_some() { + merged.highlight = explicit.highlight; + } if explicit.vertical_align.is_some() { merged.vertical_align = explicit.vertical_align; } @@ -2124,6 +2128,11 @@ fn extract_run_style(rp: &docx_rs::RunProperty) -> TextStyle { .and_then(|v| v.as_str()) .map(String::from) }), + highlight: rp.highlight.as_ref().and_then(|h| { + let json = serde_json::to_value(h).ok()?; + let name: &str = json.as_str()?; + resolve_highlight_color(name) + }), vertical_align, all_caps, // smallCaps is not exposed by docx-rs; set via SmallCapsContext XML scan @@ -2131,6 +2140,30 @@ fn extract_run_style(rp: &docx_rs::RunProperty) -> TextStyle { } } +/// Map OOXML named highlight colors to RGB values. +/// The 16 named colors are defined in the ECMA-376 spec (ST_HighlightColor). +fn resolve_highlight_color(name: &str) -> Option { + match name { + "yellow" => Some(Color::new(255, 255, 0)), + "green" => Some(Color::new(0, 255, 0)), + "cyan" => Some(Color::new(0, 255, 255)), + "magenta" => Some(Color::new(255, 0, 255)), + "blue" => Some(Color::new(0, 0, 255)), + "red" => Some(Color::new(255, 0, 0)), + "darkBlue" => Some(Color::new(0, 0, 128)), + "darkCyan" => Some(Color::new(0, 128, 128)), + "darkGreen" => Some(Color::new(0, 128, 0)), + "darkMagenta" => Some(Color::new(128, 0, 128)), + "darkRed" => Some(Color::new(128, 0, 0)), + "darkYellow" => Some(Color::new(128, 128, 0)), + "darkGray" => Some(Color::new(128, 128, 128)), + "lightGray" => Some(Color::new(192, 192, 192)), + "black" => Some(Color::new(0, 0, 0)), + "white" => Some(Color::new(255, 255, 255)), + _ => None, // "none" or unrecognized + } +} + /// Extract a boolean property (Bold, Italic) via serde. Returns None if absent. /// docx-rs serializes Bold/Italic directly as a boolean (e.g. `true`). fn extract_bool_prop(prop: &Option) -> Option { @@ -6105,4 +6138,73 @@ mod tests { "Second paragraph (English) should have no direction" ); } + + #[test] + fn test_resolve_highlight_color_named_colors() { + assert_eq!( + resolve_highlight_color("yellow"), + Some(Color::new(255, 255, 0)) + ); + assert_eq!( + resolve_highlight_color("green"), + Some(Color::new(0, 255, 0)) + ); + assert_eq!( + resolve_highlight_color("cyan"), + Some(Color::new(0, 255, 255)) + ); + assert_eq!(resolve_highlight_color("red"), Some(Color::new(255, 0, 0))); + assert_eq!( + resolve_highlight_color("darkBlue"), + Some(Color::new(0, 0, 128)) + ); + assert_eq!(resolve_highlight_color("black"), Some(Color::new(0, 0, 0))); + assert_eq!( + resolve_highlight_color("white"), + Some(Color::new(255, 255, 255)) + ); + assert_eq!(resolve_highlight_color("none"), None); + assert_eq!(resolve_highlight_color("unknown"), None); + } + + #[test] + fn test_highlight_parsing_from_docx() { + let para = docx_rs::Paragraph::new().add_run( + docx_rs::Run::new() + .add_text("Highlighted") + .highlight("yellow"), + ); + let data: Vec = build_docx_bytes(vec![para]); + let (doc, _) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let pages: Vec<&FlowPage> = doc + .pages + .iter() + .filter_map(|p| match p { + Page::Flow(fp) => Some(fp), + _ => None, + }) + .collect(); + let runs: Vec<&Run> = pages + .iter() + .flat_map(|p| &p.content) + .filter_map(|b| match b { + Block::Paragraph(p) => Some(&p.runs), + _ => None, + }) + .flatten() + .collect(); + let highlighted: Vec<&&Run> = runs + .iter() + .filter(|r| r.style.highlight.is_some()) + .collect(); + assert!( + !highlighted.is_empty(), + "Should have at least one run with highlight color" + ); + assert_eq!( + highlighted[0].style.highlight, + Some(Color::new(255, 255, 0)), + "Yellow highlight should map to (255, 255, 0)" + ); + } } diff --git a/crates/office2pdf/src/parser/xlsx.rs b/crates/office2pdf/src/parser/xlsx.rs index 903068b5..089b502b 100644 --- a/crates/office2pdf/src/parser/xlsx.rs +++ b/crates/office2pdf/src/parser/xlsx.rs @@ -101,6 +101,7 @@ fn extract_cell_text_style(cell: &umya_spreadsheet::Cell) -> TextStyle { underline, strikethrough, color, + highlight: None, vertical_align: None, all_caps: None, small_caps: None, diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index bd1cce9b..1c927ae3 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -1731,6 +1731,7 @@ fn generate_run(out: &mut String, run: &Run) { let needs_underline = matches!(style.underline, Some(true)); let needs_strike = matches!(style.strikethrough, Some(true)); let has_link = run.href.is_some(); + let needs_highlight = style.highlight.is_some(); let needs_super = matches!(style.vertical_align, Some(VerticalTextAlign::Superscript)); let needs_sub = matches!(style.vertical_align, Some(VerticalTextAlign::Subscript)); let needs_small_caps = matches!(style.small_caps, Some(true)); @@ -1748,6 +1749,11 @@ fn generate_run(out: &mut String, run: &Run) { let _ = write!(out, "#link(\"{href}\")["); } + // Wrap with highlight + if let Some(ref hl) = style.highlight { + let _ = write!(out, "#highlight(fill: rgb({}, {}, {}))[", hl.r, hl.g, hl.b); + } + // Wrap with decorations if needs_strike { out.push_str("#strike["); @@ -1808,6 +1814,9 @@ fn generate_run(out: &mut String, run: &Run) { if needs_strike { out.push(']'); } + if needs_highlight { + out.push(']'); + } if has_link { out.push(']'); } @@ -6996,4 +7005,51 @@ mod tests { "Superscript with bold should combine both. Got: {result}" ); } + + #[test] + fn test_generate_run_highlight_yellow() { + let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph { + style: ParagraphStyle::default(), + runs: vec![Run { + text: "Important".to_string(), + style: TextStyle { + highlight: Some(Color::new(255, 255, 0)), + ..TextStyle::default() + }, + href: None, + footnote: None, + }], + })])]); + let result = generate_typst(&doc).unwrap().source; + assert!( + result.contains("#highlight(fill: rgb(255, 255, 0))[Important]"), + "Highlight should use #highlight(fill: ...). Got: {result}" + ); + } + + #[test] + fn test_generate_run_highlight_with_bold() { + let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph { + style: ParagraphStyle::default(), + runs: vec![Run { + text: "Bold Highlight".to_string(), + style: TextStyle { + highlight: Some(Color::new(0, 255, 0)), + bold: Some(true), + ..TextStyle::default() + }, + href: None, + footnote: None, + }], + })])]); + let result = generate_typst(&doc).unwrap().source; + assert!( + result.contains("#highlight(fill: rgb(0, 255, 0))["), + "Should have highlight wrapper. Got: {result}" + ); + assert!( + result.contains("weight: \"bold\""), + "Should have bold text. Got: {result}" + ); + } }