diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index bfcf578b..7da14c82 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -170,6 +170,7 @@ impl crate::Document { size: b.size, bold: f.contains("bold") || f.contains("black") || f.contains("heavy"), italic: f.contains("italic") || f.contains("oblique"), + color: b.color, } }) .collect(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 308c5aed..e885a9bd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1651,6 +1651,39 @@ fn exporting_office_files_keeps_images() { assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("data:image/png;base64,")); } +#[test] +fn exporting_office_files_keeps_text_colour() { + // #526: a dark green heading came out black in Word. + let content = "BT /F1 24 Tf 0.05 0.23 0.18 rg 72 700 Td (Annual Report) Tj ET \ +BT /F1 11 Tf 0 g 72 650 Td (Black body text that is long enough to be the body size.) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new(); + let id = s.open("c.pdf", None, Arc::new(pdf), None).unwrap(); + let d = s.get(id).unwrap(); + let html = String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap(); + assert!(html.contains("style=\"color:#0D3B2E\">Annual Report"), "{html}"); + let blocks = &d.export_pages()[0].blocks; + assert!(blocks.iter().any(|b| b.text == "Annual Report" && b.color == [0.05, 0.23, 0.18]), "{blocks:?}"); +} + #[test] fn guard_turns_a_panic_into_an_error() { assert_eq!(guard(|| 7), Ok(7)); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index ba165b09..5e78640a 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -21,6 +21,8 @@ pub struct Block { pub size: f64, pub bold: bool, pub italic: bool, + /// Text fill colour as sRGB components in 0..=1 (black when unknown). + pub color: [f64; 3], } /// An image of a page. @@ -48,6 +50,8 @@ pub struct Cell { pub size: f64, pub bold: bool, pub italic: bool, + /// Text fill colour as sRGB components in 0..=1. + pub color: [f64; 3], pub span: usize, } @@ -199,7 +203,7 @@ fn tables(blocks: &[Block]) -> (Vec
| {body} | ")); + let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + s.push_str(&format!("{body} | ")); } s.push_str("\n"); } @@ -396,8 +402,20 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi s } +/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). +fn rgb8(c: [f64; 3]) -> [u8; 3] { + // The value is clamped to 0..=255 before the cast, so it cannot truncate. + c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }) +} + +/// The colour as `RRGGBB`, or `None` for black (the default every format already uses). +fn hex(c: [f64; 3]) -> Option