Merge pull request #7374 from keithcsqueensu/rtf-export-craft-rtf-793

Export: write RTF through craft-rtf (storytold/craft-libs#793)
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-15 09:24:10 -05:00
commit e098c5c85e
5 files changed
+162 -106

No files matched your search

Generated
+2 -2
View File
@@ -1202,7 +1202,7 @@ dependencies = [
[[package]]
name = "craft-codepage"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991"
source = "git+https://github.com/storytold/craft-libs?rev=f29e82f1a7a009e68312bd2e84adab72be23344d#f29e82f1a7a009e68312bd2e84adab72be23344d"
[[package]]
name = "craft-csv"
@@ -1364,7 +1364,7 @@ dependencies = [
[[package]]
name = "craft-rtf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991"
source = "git+https://github.com/storytold/craft-libs?rev=f29e82f1a7a009e68312bd2e84adab72be23344d#f29e82f1a7a009e68312bd2e84adab72be23344d"
dependencies = [
"craft-codepage",
"encoding_rs",
+3 -1
View File
@@ -35,7 +35,9 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e2dc681a3", features = ["egui"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e2dc681a3", features = ["cos", "crypt", "filters"] }
craft-rtf = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e2dc681a3" }
# The writer the RTF export maps onto (storytold/craft-libs#793), at storytold/craft-libs#1467:
# its font and colour lookups take constant time, as PdfCraft's own writer did.
craft-rtf = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f29e82f1a7a009e68312bd2e84adab72be23344d" }
craft-csv = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e2dc681a3" }
craft-xml = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e2dc681a3" }
craft-image-codecs = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e2dc681a3" }
+73 -94
View File
@@ -1,111 +1,90 @@
//! Rich Text Format through craft-rtf's writer: this module maps the pages onto its small model
//! (storytold/craft-libs#793).
use super::as_docx::rgb8;
use super::as_html::allowed;
use super::layout::{Block, Item, Page, count_lookup_steps, items};
use std::collections::HashMap;
use super::layout::{Item, Page, Table, items};
use craft_rtf::{Block, Cell, CharFmt, Document, PAGE_BREAK, Para, Run};
fn rtf_text(s: &str) -> String {
let cleaned: String = s.chars().filter(|c| allowed(*c)).collect();
craft_rtf::esc(&cleaned)
/// The text as RTF may hold it: the characters Word refuses are left out.
fn clean(s: &str) -> String {
s.chars().filter(|c| allowed(*c)).collect()
}
/// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks
/// between pages (images are left out).
/// A run's formatting: its size in half points, bold, italic and colour (none for black, which
/// is the default).
fn char_fmt(size: f64, bold: bool, italic: bool, color: [f64; 3]) -> CharFmt {
let color = Some(rgb8(color)).filter(|c| *c != [0, 0, 0]);
// A size that is not finite or below zero becomes 0, as the cast saturates.
CharFmt { bold, italic, half_points: Some((size * 2.0).round() as u32), color, ..CharFmt::default() }
}
/// A table's rows. Each cell ends at the right edge of the last grid column it spans, in twips.
fn table(t: &Table) -> craft_rtf::Table {
let mut edges = t.cols.clone();
edges.push(t.rect[2]);
let cellx: Vec<i32> = edges.iter().skip(1).map(|e| (e * 20.0).round() as i32).collect();
let rows = t
.rows
.iter()
.map(|row| {
let mut column = 0;
row.iter()
.map(|c| {
column += c.span.max(1);
let right = cellx.get(column.min(cellx.len()).saturating_sub(1)).copied().unwrap_or(0);
let run = Run { text: clean(&c.text), fmt: char_fmt(c.size, c.bold, c.italic, c.color) };
Cell { right, paras: vec![Para { runs: vec![run], ..Para::default() }] }
})
.collect()
})
.collect();
craft_rtf::Table { rows }
}
/// Keep a break outside tables, including when no text follows it on its page.
fn standalone_page_break(blocks: &mut Vec<Block>) {
let run = Run { text: PAGE_BREAK.to_string(), fmt: CharFmt::default() };
blocks.push(Block::Para(Para { runs: vec![run], ..Para::default() }));
}
/// Rich Text Format: paragraphs with their sizes, bold, italic and colours, 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 slot: HashMap<[u8; 3], usize> = HashMap::new();
let mut note = |c: [f64; 3]| {
let c = rgb8(c);
count_lookup_steps(1);
if c != [0, 0, 0] && !slot.contains_key(&c) {
slot.insert(c, colors.len());
colors.push(c);
}
};
for it in &items {
match it {
Item::Para(runs, _) => runs.iter().for_each(|r| note(r.block.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);
count_lookup_steps(1);
slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1))
};
let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n");
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 {
let mut blocks = Vec::new();
// A page break waits for what follows it: RTF's `\page` is a character in a paragraph, so it
// starts the next one, or stands alone before a table.
let mut page_break = false;
for it in items(pages) {
match it {
Item::Para(runs, _) => {
// The first run's formatting is the paragraph's; each later run is a group that
// turns on or off what differs from it.
let fmt = |b: &Block, later: bool| {
let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64);
if b.bold {
fmt.push_str("\\b");
} else if later {
fmt.push_str("\\b0");
}
if b.italic {
fmt.push_str("\\i");
} else if later {
fmt.push_str("\\i0");
}
let colour = cf(b.color);
fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour });
fmt
};
let mut runs = runs.iter();
let Some(first) = runs.next() else { continue };
s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text)));
for r in runs {
s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text)));
let mut runs: Vec<Run> = runs
.iter()
.map(|r| Run { text: clean(&r.text), fmt: char_fmt(r.block.size, r.block.bold, r.block.italic, r.block.color) })
.collect();
let Some(first) = runs.first_mut() else { continue };
if std::mem::take(&mut page_break) {
first.text.insert(0, PAGE_BREAK);
}
s.push_str("\\par}\n");
blocks.push(Block::Para(Para { runs, ..Para::default() }));
}
Item::Table(t) => {
let mut edges = t.cols.clone();
edges.push(t.rect[2]);
let cellx: Vec<i64> = edges.iter().skip(1).map(|e| (e * 20.0).round() as i64).collect();
for row in &t.rows {
s.push_str("\\trowd\\trgaph108");
// The row's cell boundaries come first: each cell ends at the right edge of
// the last grid column it spans.
let mut column = 0;
for c in row {
column += c.span.max(1);
if let Some(x) = cellx.get(column.min(cellx.len()).saturating_sub(1)) {
s.push_str(&format!("\\cellx{x}"));
}
}
for c in row {
let mut fmt = format!("\\intbl\\fs{}", (c.size * 2.0).round() as i64);
if c.bold {
fmt.push_str("\\b");
}
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");
if std::mem::take(&mut page_break) {
standalone_page_break(&mut blocks);
}
blocks.push(Block::Table(table(&t)));
}
Item::Img(_) => {}
Item::PageBreak => s.push_str("\\page\n"),
Item::PageBreak => {
if page_break {
standalone_page_break(&mut blocks);
}
page_break = true;
}
}
}
s.push('}');
s
if page_break {
standalone_page_break(&mut blocks);
}
craft_rtf::write(&Document { blocks, pictures_left_out: 0 }, "Helvetica")
}
+79 -8
View File
@@ -32,8 +32,10 @@ fn many_distinct_sizes_are_counted_in_constant_steps_each() {
assert!(steps <= 2 * 3000, "{steps} steps");
}
/// The step counter measures page-item and body-size lookups in the mapping; the last colour's
/// slot checks the output. craft-rtf separately tests colour-table lookup complexity.
#[test]
fn many_distinct_colours_take_constant_steps_each() {
fn many_distinct_colours_are_exported_with_bounded_page_mapping() {
let pages = [varied_page(3000)];
let mut out = String::new();
let steps = lookup_steps(|| out = rtf(&pages));
@@ -359,6 +361,19 @@ fn text_colour_survives_every_format() {
assert!(html(&[t], "Grid").contains("style=\"color:#FF0000\""));
}
/// A run read back from RTF: its text, bold, italic and size in half points.
type RtfRun = (String, bool, bool, Option<u32>);
/// The paragraphs RTF holds, read back, as their runs.
fn rtf_paras(r: &str) -> Vec<Vec<RtfRun>> {
let doc = craft_rtf::read(r.as_bytes()).unwrap();
doc.blocks
.iter()
.filter_map(|b| if let craft_rtf::Block::Para(p) = b { Some(p) } else { None })
.map(|p| p.runs.iter().map(|r| (r.text.clone(), r.fmt.bold, r.fmt.italic, r.fmt.half_points)).collect())
.collect()
}
#[test]
fn rtf_escapes_and_sizes() {
let mut p = page();
@@ -374,8 +389,50 @@ fn rtf_escapes_and_sizes() {
});
let r = rtf(&[p]);
assert!(r.starts_with("{\\rtf1") && r.ends_with('}'));
assert!(r.contains("\\fs48\\b Annual Report"));
assert!(r.contains("\\fs20\\i Caf\\u233? \\{x\\}"), "{r}");
assert!(r.contains("Caf\\u233? \\{x\\}"), "escaped: {r}");
let runs: Vec<_> = rtf_paras(&r).into_iter().flatten().collect();
assert!(runs.contains(&("Annual Report".into(), true, false, Some(48))), "{runs:?}");
assert!(runs.contains(&("Café {x}".into(), false, true, Some(20))), "{runs:?}");
}
/// A page break starts the next page's first paragraph, and stands in a paragraph of its own
/// before a table, rather than in the table's first cell.
#[test]
fn rtf_page_breaks_stay_out_of_tables() {
let r = rtf(&[page(), table_page(), page()]);
let back = craft_rtf::read(r.as_bytes()).unwrap();
let first_cell = back.blocks.iter().find_map(|b| match b {
craft_rtf::Block::Table(t) => t.rows.first().and_then(|row| row.first()).map(|c| c.paras.iter().map(|p| p.text()).collect::<String>()),
craft_rtf::Block::Para(_) => None,
});
assert_eq!(first_cell.as_deref(), Some("Meter"), "{r}");
let texts: Vec<String> = back.blocks.iter().map(|b| if let craft_rtf::Block::Para(p) = b { p.text() } else { "<table>".into() }).collect();
let table = texts.iter().position(|t| t == "<table>");
assert_eq!(table.and_then(|i| i.checked_sub(1)).and_then(|i| texts.get(i)).map(String::as_str), Some("\u{000C}"), "{texts:?}");
assert_eq!(texts.iter().filter(|t| t.starts_with('\u{000C}')).count(), 2, "one break before each later page: {texts:?}");
}
/// Empty and image-only pages still occupy a page when pictures are omitted from RTF.
#[test]
fn rtf_preserves_breaks_for_pages_without_text() {
let mut blank = page();
blank.blocks.clear();
blank.images.clear();
blank.rules.clear();
let mut image_only = page();
image_only.blocks.clear();
for pages in [
vec![blank.clone(), blank.clone(), page()],
vec![page(), blank.clone(), blank.clone()],
vec![page(), blank.clone(), blank.clone(), table_page()],
vec![blank.clone(), blank.clone(), blank],
vec![page(), image_only.clone(), image_only.clone(), page()],
vec![page(), image_only],
] {
let r = rtf(&pages);
let breaks = rtf_paras(&r).iter().flatten().map(|run| run.0.matches(craft_rtf::PAGE_BREAK).count()).sum::<usize>();
assert_eq!(breaks, pages.len() - 1, "{r}");
}
}
/// A 3-column table with a spanning header row and a hole in the last row.
@@ -444,9 +501,18 @@ fn grid_text_becomes_a_real_table_in_every_format() {
let r = rtf(std::slice::from_ref(&p));
assert!(r.contains("\\trowd"), "{r}");
assert_eq!(r.matches("\\row").count(), 4);
// Boundaries precede the cells; "Totals" spans two columns, so it ends at the third edge.
assert!(r.contains("\\trowd\\trgaph108\\cellx4640\\cellx7840\\cellx9920{"), "{r}");
assert!(r.contains("\\trowd\\trgaph108\\cellx7840{\\intbl\\fs22 Totals}\\cell\\row"), "{r}");
// Each cell ends at its last column's right edge; "Totals" spans two columns, so it ends at
// the second of the three.
let back = craft_rtf::read(r.as_bytes()).unwrap();
let rows: Vec<Vec<(i32, String)>> = back
.blocks
.iter()
.filter_map(|b| if let craft_rtf::Block::Table(t) = b { Some(t) } else { None })
.flat_map(|t| &t.rows)
.map(|row| row.iter().map(|c| (c.right, c.paras.iter().map(|p| p.text()).collect())).collect())
.collect();
assert_eq!(rows.first().map(|r| r.iter().map(|c| c.0).collect::<Vec<_>>()), Some(vec![4640, 7840, 9920]), "{rows:?}");
assert!(rows.contains(&vec![(7840, "Totals".to_string())]), "{rows:?}");
let pt = pptx(std::slice::from_ref(&p), "Bill");
assert!(pt.starts_with(b"PK"));
@@ -746,7 +812,12 @@ fn an_italic_word_stays_in_its_sentence() {
let h = html(std::slice::from_ref(&p), "Opinion");
assert!(h.contains("<p>Before <em>Chevron </em>after.</p>"), "{h}");
let r = rtf(std::slice::from_ref(&p));
assert!(r.contains("{\\pard\\fs24 Before {\\fs24\\b0\\i\\cf0 Chevron }{\\fs24\\b0\\i0\\cf0 after.}\\par}"), "{r}");
let size = Some(24);
assert_eq!(
rtf_paras(&r).first(),
Some(&vec![("Before ".into(), false, false, size), ("Chevron ".into(), false, true, size), ("after.".into(), false, false, size)]),
"{r}"
);
// The longest line is the slide's title; the sentence is one paragraph of the body.
let pxml = part(&pptx(std::slice::from_ref(&p), "Opinion"), "ppt/slides/slide1.xml");
assert!(
@@ -1125,7 +1196,7 @@ named in the schedule, <em>Blue Finch Studio</em>, who agree as follows to rent
"{h}"
);
let r = rtf(std::slice::from_ref(&p));
assert_eq!(r.matches("{\\pard").count(), 2, "{r}");
assert_eq!(rtf_paras(&r).len(), 2, "{r}");
let md = markdown(std::slice::from_ref(&p));
assert_eq!(md.trim().split("\n\n").count(), 2, "{md}");
assert!(md.contains("of 4 Quay Street, and the tenant named in the schedule, "), "{md}");
+5 -1
View File
@@ -502,6 +502,10 @@ mod tests {
app.reduce_file_size();
assert!(app.optimize_run.is_none() && app.progress_notice.is_none(), "nothing runs until the answer is in");
assert!(app.pickers.pending(), "the question is out");
assert!(!out.exists(), "no copy is written before the answer");
// The first answer is already queued; any second question would use this wrong path.
let unasked = dir.join("not asked.pdf");
app.pick_override = Some(vec![unasked.to_string_lossy().into_owned()]);
app.process_picked();
// A tiny optimization may finish during the initial poll, before the test observes it.
// Check the destination in either state; the saved copy is verified below in both cases.
@@ -511,7 +515,6 @@ mod tests {
assert!(out.is_file(), "a completed optimization saved the chosen destination");
}
// Nothing more is asked at the end: the copy is simply there.
app.pick_override = Some(vec![dir.join("not asked.pdf").to_string_lossy().into_owned()]);
let start = std::time::Instant::now();
while app.optimize_run.is_some() && start.elapsed() < std::time::Duration::from_secs(30) {
app.poll_optimize();
@@ -519,6 +522,7 @@ mod tests {
}
assert!(app.optimize_run.is_none(), "optimization finishes");
assert!(!app.pickers.pending(), "it doesn't ask again");
assert!(!unasked.exists(), "the queued destination is retained");
let written = std::fs::read(&out).unwrap_or_default();
let mut again = pdfcraft_engine::Session::new();
let id = again.open("notes (reduced).pdf", None, Arc::new(written), None).unwrap();