diff --git a/Cargo.lock b/Cargo.lock index b7596fa0f5..a259e0fe5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 33e770e9bf..8bd2a5fb8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/export/src/as_rtf.rs b/crates/export/src/as_rtf.rs index 6e9e98d07e..feea5306ee 100644 --- a/crates/export/src/as_rtf.rs +++ b/crates/export/src/as_rtf.rs @@ -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 = 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) { + 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 = 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 = 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") } diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 88066fe84c..4ee522a70f 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -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); + +/// The paragraphs RTF holds, read back, as their runs. +fn rtf_paras(r: &str) -> Vec> { + 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::()), + craft_rtf::Block::Para(_) => None, + }); + assert_eq!(first_cell.as_deref(), Some("Meter"), "{r}"); + let texts: Vec = back.blocks.iter().map(|b| if let craft_rtf::Block::Para(p) = b { p.text() } else { "".into() }).collect(); + let table = texts.iter().position(|t| t == "
"); + 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::(); + 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> = 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::>()), 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("

Before Chevron after.

"), "{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, Blue Finch Studio, 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}"); diff --git a/crates/ui-egui/src/platform/pickers.rs b/crates/ui-egui/src/platform/pickers.rs index 35cb1285f6..9e5ed8ebba 100644 --- a/crates/ui-egui/src/platform/pickers.rs +++ b/crates/ui-egui/src/platform/pickers.rs @@ -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();