mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-15 15:22:54 +00:00
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:
5 files changed
+162
-106
No files matched your search
Generated
+2
-2
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user