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 `<w:color w:val="RRGGBB"/>`, 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 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 authored and GitHub committed 2026-10-09 10:49:21 -04:00
1 parent a593512700
commit 2aca0692fc
4 files changed
+147 -20

No files matched your search

+1
View File
@@ -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();
+33
View File
@@ -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));
+107 -14
View File
@@ -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<Table>, Vec<bool>) {
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<Table>, Vec<bool>) {
}
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!("<strong>{t}</strong>");
}
let style = hex(b.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
match lvl {
0 => s.push_str(&format!("<p>{t}</p>\n")),
l => s.push_str(&format!("<h{l}>{t}</h{l}>\n")),
0 => s.push_str(&format!("<p{style}>{t}</p>\n")),
l => s.push_str(&format!("<h{l}{style}>{t}</h{l}>\n")),
}
}
Item::Table(t) => {
@@ -379,7 +384,8 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi
body = format!("<strong>{body}</strong>");
}
let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() };
s.push_str(&format!("<td{span}>{body}</td>"));
let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
s.push_str(&format!("<td{span}{style}>{body}</td>"));
}
s.push_str("</tr>\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<String> {
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("<w:b/>");
@@ -405,6 +423,9 @@ fn run_xml(text: &str, size: f64, bold: bool, italic: bool) -> String {
if italic {
rpr.push_str("<w:i/>");
}
if let Some(h) = hex(color) {
rpr.push_str(&format!("<w:color w:val=\"{h}\"/>"));
}
let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 };
rpr.push_str(&format!("<w:sz w:val=\"{half_points}\"/>"));
format!("<w:r><w:rPr>{rpr}</w:rPr><w:t xml:space=\"preserve\">{}</w:t></w:r>", 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!(
"<w:tc><w:tcPr><w:tcW w:w=\"{w}\" w:type=\"dxa\"/>{span}</w:tcPr><w:p>{}</w:p></w:tc>",
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<u8> {
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</w:t>"), "{xml}");
@@ -725,10 +784,35 @@ mod tests {
assert!(xml.contains("<w:pgMar w:top=\"500\" w:right=\"250\" w:bottom=\"500\" w:left=\"250\""), "{xml}");
}
#[test]
fn text_colour_survives_every_format() {
// #526: a dark green heading (0.05 0.23 0.18 rg) came out black in Word.
let mut p = page();
p.blocks[1].color = [0.05, 0.23, 0.18];
p.blocks[3].color = [f64::NAN, 2.0, -1.0];
let xml = part(&docx(&[p.clone()], "Colour"), "word/document.xml");
assert!(xml.contains("<w:color w:val=\"0D3B2E\"/>"), "{xml}");
assert!(xml.contains("<w:color w:val=\"00FF00\"/>"), "out-of-range values clamp: {xml}");
// Black text keeps Word's default colour.
assert_eq!(xml.matches("<w:color ").count(), 2, "{xml}");
let h = html(&[p.clone()], "Colour");
assert!(h.contains("<h1 style=\"color:#0D3B2E\">Annual Report</h1>"), "{h}");
assert!(h.contains("<p style=\"color:#00FF00\">"), "{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("<w:color w:val=\"FF0000\"/>"), "{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,
+6 -6
View File
@@ -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"