Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/office2pdf/src/ir/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ pub struct TextStyle {
pub underline: Option<bool>,
pub strikethrough: Option<bool>,
pub color: Option<Color>,
/// Text highlight background color.
pub highlight: Option<Color>,
/// Superscript or subscript vertical alignment.
pub vertical_align: Option<VerticalTextAlign>,
/// All caps: render text in uppercase.
Expand Down
102 changes: 102 additions & 0 deletions crates/office2pdf/src/parser/docx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -2124,13 +2128,42 @@ 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
small_caps: None,
}
}

/// 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<Color> {
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<T: serde::Serialize>(prop: &Option<T>) -> Option<bool> {
Expand Down Expand Up @@ -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<u8> = 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)"
);
}
}
1 change: 1 addition & 0 deletions crates/office2pdf/src/parser/xlsx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions crates/office2pdf/src/render/typst_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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[");
Expand Down Expand Up @@ -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(']');
}
Expand Down Expand Up @@ -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}"
);
}
}