From 2aca0692fc4ad1a9daa602eed5e5f1398e8a90af Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 10:49:21 -0400 Subject: [PATCH] Fix #526: keep text colour in Word, HTML and RTF export (#547) The export model (`pdfcraft_export::Block`/`Cell`) had no colour, so `export_pages` dropped the fill colour that `edit::TextBlock` already carries and every run came out black. Block and Cell now carry an sRGB colour, mapped from TextBlock.color. Word runs get ``, HTML paragraphs, headings and cells a `style="color:#RRGGBB"`, and RTF a colour table with `\cfN`. Black text keeps each format's default; NaN and out-of-range components clamp. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/compare.rs | 1 + crates/engine/src/tests.rs | 33 ++++++++++ crates/export/src/lib.rs | 121 +++++++++++++++++++++++++++++++---- parity/acrobat-features.toml | 12 ++-- 4 files changed, 147 insertions(+), 20 deletions(-) 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, Vec) { let b = &blocks[bi]; let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; - let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, span }; + let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span }; match &mut slots[slot] { Some(c) => { c.text.push(' '); @@ -220,7 +224,7 @@ fn tables(blocks: &[Block]) -> (Vec
, Vec) { } None => { column += 1; - materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, span: 1 }); + materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 }); } } } @@ -361,9 +365,10 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi if b.bold && lvl == 0 { t = format!("{t}"); } + let style = hex(b.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); match lvl { - 0 => s.push_str(&format!("

{t}

\n")), - l => s.push_str(&format!("{t}\n")), + 0 => s.push_str(&format!("{t}

\n")), + l => s.push_str(&format!("{t}\n")), } } Item::Table(t) => { @@ -379,7 +384,8 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi body = format!("{body}"); } let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - s.push_str(&format!("{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 { + let [r, g, b] = rgb8(c); + (r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}")) +} + /// A formatted text run (shared by paragraphs and table cells). -fn run_xml(text: &str, size: f64, bold: bool, italic: bool) -> String { +fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String { let mut rpr = String::new(); if bold { rpr.push_str(""); @@ -405,6 +423,9 @@ fn run_xml(text: &str, size: f64, bold: bool, italic: bool) -> String { if italic { rpr.push_str(""); } + if let Some(h) = hex(color) { + rpr.push_str(&format!("")); + } let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 }; rpr.push_str(&format!("")); format!("{rpr}{}", esc(text)) @@ -440,7 +461,7 @@ fn docx_table(t: &Table) -> String { let w: i64 = widths.iter().take(c.span.min(widths.len())).sum(); s.push_str(&format!( "{span}{}", - run_xml(&c.text, c.size, c.bold, c.italic) + run_xml(&c.text, c.size, c.bold, c.italic, c.color) )); } // Pad the grid so every row covers all columns (Word rejects short rows). @@ -468,7 +489,7 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { let mut media: Vec<(String, &Image)> = Vec::new(); // Word needs a paragraph between adjacent tables and after the last one in the body. let mut after_table = false; - let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic); + let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic, b.color); for it in items(pages) { match &it { Item::Para(b, lvl) => { @@ -586,8 +607,36 @@ fn rtf_text(s: &str) -> String { /// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks /// between pages (images are left out). pub fn rtf(pages: &[Page]) -> String { + let items = items(pages); + // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. + let mut colors: Vec<[u8; 3]> = Vec::new(); + let mut note = |c: [f64; 3]| { + let c = rgb8(c); + if c != [0, 0, 0] && !colors.contains(&c) { + colors.push(c); + } + }; + for it in &items { + match it { + Item::Para(b, _) => note(b.color), + Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), + Item::Img(_) | Item::PageBreak => {} + } + } + // `\cfN` for a colour (nothing for black, which keeps the default). + let cf = |c: [f64; 3]| { + let c = rgb8(c); + colors.iter().position(|e| *e == c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) + }; let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); - for it in items(pages) { + if !colors.is_empty() { + s.push_str("{\\colortbl;"); + for [r, g, b] in &colors { + s.push_str(&format!("\\red{r}\\green{g}\\blue{b};")); + } + s.push_str("}\n"); + } + for it in items { match it { Item::Para(b, _) => { let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); @@ -597,6 +646,7 @@ pub fn rtf(pages: &[Page]) -> String { if b.italic { fmt.push_str("\\i"); } + fmt.push_str(&cf(b.color)); s.push_str(&format!("{{\\pard{fmt} {}\\par}}\n", rtf_text(&b.text))); } Item::Table(t) => { @@ -622,6 +672,7 @@ pub fn rtf(pages: &[Page]) -> String { if c.italic { fmt.push_str("\\i"); } + fmt.push_str(&cf(c.color)); s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text))); } s.push_str("\\row\n"); @@ -640,7 +691,14 @@ mod tests { use super::*; fn page() -> Page { - let b = |t: &str, y: f64, size: f64, bold: bool| Block { text: t.into(), rect: [72.0, y, 500.0, y + size], size, bold, italic: false }; + let b = |t: &str, y: f64, size: f64, bold: bool| Block { + text: t.into(), + rect: [72.0, y, 500.0, y + size], + size, + bold, + italic: false, + color: [0.0; 3], + }; Page { width: 612.0, height: 792.0, @@ -712,6 +770,7 @@ mod tests { size: f64::NAN, bold: false, italic: false, + color: [0.0; 3], }); let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml"); assert!(xml.contains(">Total 4.50\tGBP"), "{xml}"); @@ -725,10 +784,35 @@ mod tests { assert!(xml.contains(""), "{xml}"); + assert!(xml.contains(""), "out-of-range values clamp: {xml}"); + // Black text keeps Word's default colour. + assert_eq!(xml.matches("Annual Report"), "{h}"); + assert!(h.contains("

"), "{h}"); + let r = rtf(&[p]); + assert!(r.contains("{\\colortbl;\\red13\\green59\\blue46;\\red0\\green255\\blue0;}"), "{r}"); + assert!(r.contains("\\cf1 Annual Report"), "{r}"); + // A table cell keeps its colour too. + let mut t = table_page(); + t.blocks[0].color = [1.0, 0.0, 0.0]; + let xml = part(&docx(&[t.clone()], "Grid"), "word/document.xml"); + assert!(xml.contains(""), "{xml}"); + assert!(html(&[t], "Grid").contains("style=\"color:#FF0000\"")); + } + #[test] fn rtf_escapes_and_sizes() { let mut p = page(); - p.blocks.push(Block { text: "Café {x}".into(), rect: [72.0, 100.0, 200.0, 110.0], size: 10.0, bold: false, italic: true }); + p.blocks.push(Block { text: "Café {x}".into(), rect: [72.0, 100.0, 200.0, 110.0], size: 10.0, bold: false, italic: true, color: [0.0; 3] }); let r = rtf(&[p]); assert!(r.starts_with("{\\rtf1") && r.ends_with('}')); assert!(r.contains("\\fs48\\b Annual Report")); @@ -737,7 +821,14 @@ mod tests { /// A 3-column table with a spanning header row and a hole in the last row. fn table_page() -> Page { - let cell = |t: &str, x: f64, y: f64, w: f64| Block { text: t.into(), rect: [x, y, x + w, y + 12.0], size: 11.0, bold: false, italic: false }; + let cell = |t: &str, x: f64, y: f64, w: f64| Block { + text: t.into(), + rect: [x, y, x + w, y + 12.0], + size: 11.0, + bold: false, + italic: false, + color: [0.0; 3], + }; Page { width: 612.0, height: 792.0, @@ -758,6 +849,7 @@ mod tests { size: 11.0, bold: false, italic: false, + color: [0.0; 3], }, ], images: Vec::new(), @@ -801,7 +893,7 @@ mod tests { .map(|i| { let (row, col) = (i / 2, i / 2 + i % 2); let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0); - Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false } + Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false, color: [0.0; 3] } }) .collect(); let started = std::time::Instant::now(); @@ -820,7 +912,7 @@ mod tests { .flat_map(|row| { (0..n).map(move |col| { let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0); - Block { text: (col + 1).to_string(), rect: [x, y, x + 8.0, y + 8.0], size: 8.0, bold: false, italic: false } + Block { text: (col + 1).to_string(), rect: [x, y, x + 8.0, y + 8.0], size: 8.0, bold: false, italic: false, color: [0.0; 3] } }) }) .collect(), @@ -840,6 +932,7 @@ mod tests { size: 11.0, bold: false, italic: false, + color: [0.0; 3], }; let p = Page { width: 612.0, diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c59badfa..f223feea 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6874,8 +6874,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, images inline, page breaks, and detected tables with spans/empty cells; checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: columns, fonts and colours, exact layout mode." +evidence = ["crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] +notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), images inline, page breaks, and detected tables with spans/empty cells; checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: columns, fonts, colour per run within a paragraph, exact layout mode." [[feature]] id = "create.export-rtf" @@ -6886,8 +6886,8 @@ milestone = "M10" status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Paragraphs with sizes, bold, italic, Unicode; page breaks. Images are left out." +evidence = ["crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] +notes = "Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; page breaks. Images are left out." [[feature]] id = "create.export-xlsx" @@ -6970,8 +6970,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Single HTML file with headings, paragraphs, inline images, and detected tables with spans/empty cells. Not yet: multi-page folder output, CSS from fonts." +evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] +notes = "Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text"