From ccd51c187b4a6b4b26a755e96c2be987b5f1be40 Mon Sep 17 00:00:00 2001 From: kiwi4boy Date: Sat, 14 Nov 2026 21:19:22 -0500 Subject: [PATCH 1/5] Export: write RTF through craft-rtf (storytold/craft-libs#793) `as_rtf.rs` maps the pages onto craft-rtf's small model and calls `craft_rtf::write`, as DesignCraft's RTF export does, instead of building the header, font and colour tables, runs and table rows itself. craft-rtf is pinned to storytold/craft-libs#1467, whose writer looks fonts and colours up in constant time, as PdfCraft's own writer did. Only craft-rtf moves; the lockfile changes for it and craft-codepage. The file holds the same text, sizes, bold, italic, colours and table cells. Each formatted run is now its own group, so the tests that pinned the old bytes read the RTF back with `craft_rtf::read` instead. They pass against the old writer too. A page that starts with a table put its `\page` inside the first cell, because nothing ended the paragraph before `\trowd`. The break now stands in a paragraph of its own (`rtf_page_breaks_stay_out_of_tables`, which fails against the old writer). --- Cargo.lock | 4 +- Cargo.toml | 4 +- crates/export/src/as_rtf.rs | 154 ++++++++++++++---------------------- crates/export/src/tests.rs | 63 +++++++++++++-- 4 files changed, 121 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7596fa0f5..22b36d4f4e 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=74495a579ad1247a149229d5b4185467aaa33f00#74495a579ad1247a149229d5b4185467aaa33f00" [[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=74495a579ad1247a149229d5b4185467aaa33f00#74495a579ad1247a149229d5b4185467aaa33f00" dependencies = [ "craft-codepage", "encoding_rs", diff --git a/Cargo.toml b/Cargo.toml index 33e770e9bf..b5a858339f 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 = "74495a579ad1247a149229d5b4185467aaa33f00" } 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..8405fe9e69 100644 --- a/crates/export/src/as_rtf.rs +++ b/crates/export/src/as_rtf.rs @@ -1,111 +1,77 @@ +//! 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 } +} + +/// 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) { + let run = Run { text: PAGE_BREAK.to_string(), fmt: CharFmt::default() }; + blocks.push(Block::Para(Para { runs: vec![run], ..Para::default() })); } + blocks.push(Block::Table(table(&t))); } Item::Img(_) => {} - Item::PageBreak => s.push_str("\\page\n"), + Item::PageBreak => page_break = true, } } - s.push('}'); - s + 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..90ca9abaa0 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -32,6 +32,9 @@ fn many_distinct_sizes_are_counted_in_constant_steps_each() { assert!(steps <= 2 * 3000, "{steps} steps"); } +/// The colour table is craft-rtf's now. Its writer keeps each lookup constant and counts them in +/// its own test (storytold/craft-libs#1466, `many_distinct_fonts_and_colours_take_constant_steps_each`); +/// this one still counts any lookup the mapping makes, and checks the last colour's slot. #[test] fn many_distinct_colours_take_constant_steps_each() { let pages = [varied_page(3000)]; @@ -359,6 +362,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 +390,27 @@ 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:?}"); } /// A 3-column table with a spanning header row and a hole in the last row. @@ -444,9 +479,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 +790,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 +1174,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}"); From a65719a622cb4bcd6d103dc742c6b5d48f655e79 Mon Sep 17 00:00:00 2001 From: kiwi4boy Date: Sat, 14 Nov 2026 21:25:37 -0500 Subject: [PATCH 2/5] Export: craft-rtf at storytold/craft-libs#1467's new head (14b2ebb7) The review on #1467 made Interned::insert one index lookup and author() allocation-free for a known author. Output unchanged. --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 22b36d4f4e..37a5b94597 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=74495a579ad1247a149229d5b4185467aaa33f00#74495a579ad1247a149229d5b4185467aaa33f00" +source = "git+https://github.com/storytold/craft-libs?rev=14b2ebb7c2dda180e026037395abbefe48109eb5#14b2ebb7c2dda180e026037395abbefe48109eb5" [[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=74495a579ad1247a149229d5b4185467aaa33f00#74495a579ad1247a149229d5b4185467aaa33f00" +source = "git+https://github.com/storytold/craft-libs?rev=14b2ebb7c2dda180e026037395abbefe48109eb5#14b2ebb7c2dda180e026037395abbefe48109eb5" dependencies = [ "craft-codepage", "encoding_rs", diff --git a/Cargo.toml b/Cargo.toml index b5a858339f..d4a3b1d0f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0. craft-pdf = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e2dc681a3", features = ["cos", "crypt", "filters"] } # 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 = "74495a579ad1247a149229d5b4185467aaa33f00" } +craft-rtf = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "14b2ebb7c2dda180e026037395abbefe48109eb5" } 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" } From e94fa524cae30c9fa946fc3dd5ba21032256e67d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 15 Nov 2026 06:11:34 -0500 Subject: [PATCH 3/5] Export: pin merged RTF writer and preserve empty-page breaks Use the merged craft-libs #1467 commit for craft-rtf and its codepage dependency, leaving other shared pins unchanged. Flush pending breaks before a second break and at end of input so blank and omitted-image pages retain every boundary. Add semantic readback coverage for those cases. --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- crates/export/src/as_rtf.rs | 19 ++++++++++++++++--- crates/export/src/tests.rs | 23 +++++++++++++++++++++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37a5b94597..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=14b2ebb7c2dda180e026037395abbefe48109eb5#14b2ebb7c2dda180e026037395abbefe48109eb5" +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=14b2ebb7c2dda180e026037395abbefe48109eb5#14b2ebb7c2dda180e026037395abbefe48109eb5" +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 d4a3b1d0f6..8bd2a5fb8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0. craft-pdf = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e2dc681a3", features = ["cos", "crypt", "filters"] } # 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 = "14b2ebb7c2dda180e026037395abbefe48109eb5" } +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 8405fe9e69..feea5306ee 100644 --- a/crates/export/src/as_rtf.rs +++ b/crates/export/src/as_rtf.rs @@ -42,6 +42,12 @@ fn table(t: &Table) -> craft_rtf::Table { 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 { @@ -64,14 +70,21 @@ pub fn rtf(pages: &[Page]) -> String { } Item::Table(t) => { if std::mem::take(&mut page_break) { - let run = Run { text: PAGE_BREAK.to_string(), fmt: CharFmt::default() }; - blocks.push(Block::Para(Para { runs: vec![run], ..Para::default() })); + standalone_page_break(&mut blocks); } blocks.push(Block::Table(table(&t))); } Item::Img(_) => {} - Item::PageBreak => page_break = true, + Item::PageBreak => { + if page_break { + standalone_page_break(&mut blocks); + } + page_break = true; + } } } + 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 90ca9abaa0..cb09123703 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -413,6 +413,29 @@ fn rtf_page_breaks_stay_out_of_tables() { 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. fn table_page() -> Page { let cell = |t: &str, x: f64, y: f64, w: f64| Block { From b7256c81bc8f0ed7b8aeb9acd517d0e9b9679c1a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 15 Nov 2026 07:23:35 -0500 Subject: [PATCH 4/5] test(pickers): avoid racing completed optimization --- crates/ui-egui/src/platform/pickers.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/platform/pickers.rs b/crates/ui-egui/src/platform/pickers.rs index bd3aa568dc..b6d2435b1a 100644 --- a/crates/ui-egui/src/platform/pickers.rs +++ b/crates/ui-egui/src/platform/pickers.rs @@ -497,21 +497,20 @@ mod tests { #[test] fn reduce_file_size_asks_where_to_save_before_it_compresses() { let (mut app, dir) = reducing("first"); + // Complete deterministically: a tiny job may finish before run_optimize returns. + app.run_inline = true; let out = dir.join("notes (reduced).pdf"); app.pick_override = Some(vec![out.to_string_lossy().into_owned()]); 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(); - let run = app.optimize_run.as_ref().expect("the answer starts it"); - assert_eq!(run.destination.as_deref(), out.to_str()); - // 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(); - std::thread::sleep(std::time::Duration::from_millis(5)); - } + assert!(app.optimize_run.is_none(), "the answer runs the inline job to completion"); + assert!(!unasked.exists(), "the queued destination is retained"); assert!(!app.pickers.pending(), "it doesn't ask again"); let written = std::fs::read(&out).unwrap_or_default(); let mut again = pdfcraft_engine::Session::new(); From fa8c473d23da03a84a78a33aacfba6163e9e5ec5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 15 Nov 2026 13:25:23 +0000 Subject: [PATCH 5/5] Clarify scope of RTF mapping complexity test --- crates/export/src/tests.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index cb09123703..4ee522a70f 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -32,11 +32,10 @@ fn many_distinct_sizes_are_counted_in_constant_steps_each() { assert!(steps <= 2 * 3000, "{steps} steps"); } -/// The colour table is craft-rtf's now. Its writer keeps each lookup constant and counts them in -/// its own test (storytold/craft-libs#1466, `many_distinct_fonts_and_colours_take_constant_steps_each`); -/// this one still counts any lookup the mapping makes, and checks the last colour's slot. +/// 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));