mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-13 23:17:40 +00:00
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:
1 parent
a593512700
commit
2aca0692fc
4 files changed
+147
-20
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user