From e94fa524cae30c9fa946fc3dd5ba21032256e67d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 15 Nov 2026 06:11:34 -0500 Subject: [PATCH] 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 {