From 70280a44d0a789d8d0e5355f4a6b287faaaf33d1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:40:46 -0400 Subject: [PATCH] M10: Export to Word (.docx), HTML and RTF (new export crate with a small zip writer): paragraphs, headings, bold/italic, images, page breaks; doc_export_office tool Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 + Cargo.toml | 1 + crates/automation/src/a11y.rs | 10 + crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 3 + crates/automation/tests/automation.rs | 16 ++ crates/engine/Cargo.toml | 1 + crates/engine/src/catalog.rs | 7 +- crates/engine/src/commands.rs | 3 + crates/engine/src/compare.rs | 77 ++++++ crates/engine/src/tests.rs | 18 ++ crates/export/Cargo.toml | 14 ++ crates/export/src/lib.rs | 341 ++++++++++++++++++++++++++ crates/export/src/zip.rs | 110 +++++++++ crates/ui-egui/src/commands.rs | 3 + crates/ui-egui/src/files.rs | 29 +++ crates/ui-egui/tests/commands.rs | 3 + crates/ui-egui/tests/compare.rs | 16 ++ parity/acrobat-features.toml | 30 +-- 19 files changed, 673 insertions(+), 18 deletions(-) create mode 100644 crates/export/Cargo.toml create mode 100644 crates/export/src/lib.rs create mode 100644 crates/export/src/zip.rs diff --git a/Cargo.lock b/Cargo.lock index ec6b765d..5456b956 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3861,6 +3861,7 @@ dependencies = [ "printcraft-cos", "printcraft-create", "printcraft-edit", + "printcraft-export", "printcraft-fonts", "printcraft-forms", "printcraft-js", @@ -3877,6 +3878,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "printcraft-export" +version = "0.1.0" +dependencies = [ + "flate2", +] + [[package]] name = "printcraft-filters" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9feabb9e..0123a7e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ printcraft-ocr = { path = "crates/ocr", version = "0.1.0" } printcraft-js = { path = "crates/js", version = "0.1.0" } printcraft-compare = { path = "crates/compare", version = "0.1.0" } printcraft-preflight = { path = "crates/preflight", version = "0.1.0" } +printcraft-export = { path = "crates/export", version = "0.1.0" } printcraft-edit = { path = "crates/edit", version = "0.1.0" } printcraft-create = { path = "crates/create", version = "0.1.0" } printcraft-forms = { path = "crates/forms", version = "0.1.0" } diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 74642e2c..383dbf10 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -259,6 +259,16 @@ impl Automation { Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns })) } + pub(crate) fn doc_export_office(&self, a: &Args) -> Result { + let path = self.resolve(a.str("path")?, true)?; + let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); + let format = printcraft_engine::compare::OfficeFormat::from_extension(&ext) + .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, html or rtf)")))?; + let bytes = self.doc(a)?.export_office(format); + write_atomic(&path, &bytes)?; + Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() })) + } + fn pdfa_level(&self, a: &Args) -> Result { let l = a.opt_str("level")?.unwrap_or("2b"); printcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)"))) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 70f1b06e..efc4a150 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -514,6 +514,7 @@ impl Automation { "doc_compare" => self.doc_compare(&a)?, "action_list" => self.action_list()?, "pdfa_verify" => self.pdfa_verify(&a)?, + "doc_export_office" => self.doc_export_office(&a)?, "pdfa_convert" => self.pdfa_convert(&a)?, "action_run" => self.action_run(&a)?, "doc_compare_report" => self.doc_compare_report(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 766a1685..55caa101 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -759,6 +759,9 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), + t("doc_export_office", "Export to Word, HTML or RTF", "Export a PDF ▸ Word (.docx), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: paragraphs in reading order, headings from larger text, bold and italic, images where they fall, a page break between pages.") + .cmd("export.docx") + .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.") .ro() .cmd("standards.pdfa") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 813ce1ab..bea28cee 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1484,3 +1484,19 @@ fn pdfa_through_tools() { assert!(r["issues"].as_array().unwrap().iter().all(|i| i["fixable"] == false), "{r}"); assert!(a.call("pdfa_verify", &json!({ "doc": doc, "level": "9z" })).is_err()); } + +#[test] +fn exporting_to_word_html_and_rtf() { + let dir = workdir("office"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for ext in ["docx", "html", "rtf"] { + let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("a.{ext}") })); + assert_eq!(r["format"], ext); + } + let html = std::fs::read_to_string(dir.join("a.html")).unwrap(); + assert!(html.contains("Page 1") && html.contains("Page 3") && html.matches("
").count() == 2, "{html}"); + assert!(std::fs::read(dir.join("a.docx")).unwrap().starts_with(b"PK")); + assert!(std::fs::read_to_string(dir.join("a.rtf")).unwrap().contains("Page 2")); + assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err()); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 7bbc7849..e104aa77 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -26,6 +26,7 @@ printcraft-ocr = { workspace = true } printcraft-js = { workspace = true } printcraft-compare = { workspace = true } printcraft-preflight = { workspace = true } +printcraft-export = { workspace = true } printcraft-fonts = { workspace = true } printcraft-create = { workspace = true } printcraft-model = { workspace = true } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 81a82ae7..da8100fe 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -65,16 +65,17 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ icon: "file-output", hue: BLUE, badge: None, - availability: Planned("M10"), + availability: Ready, sections: &[ToolSection { title: "Export to", items: &[ - item("Microsoft Word (.docx)", "file-text", "export.docx", Planned("M10")), + item("Microsoft Word (.docx)", "file-text", "export.docx", Ready), item("Spreadsheet (.xlsx)", "grid-3x3", "export.xlsx", Planned("M10")), item("Presentation (.pptx)", "presentation", "export.pptx", Planned("M10")), item("Image (PNG)", "image", "export.image", Ready), item("Export all images", "image", "export.all_images", Ready), - item("HTML web page", "file-symlink", "export.html", Planned("M10")), + item("HTML web page", "file-symlink", "export.html", Ready), + item("Rich Text Format (.rtf)", "file-text", "export.rtf", Ready), item("Text (plain)", "type", "export.text", Ready), item("PostScript / EPS", "file-down", "export.ps", Planned("M10")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 804d9085..8a03e5ac 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -255,6 +255,9 @@ pub const COMMANDS: &[CommandSpec] = &[ c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"), c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"), c("export.text", "Export to text…", FILE, None, Document, "type"), + c("export.docx", "Export to Word…", FILE, None, Document, "file-text"), + c("export.html", "Export to HTML…", FILE, None, Document, "file-symlink"), + c("export.rtf", "Export to RTF…", FILE, None, Document, "file-text"), c("app.preferences", "Preferences…", None, None, Nothing, "settings"), c("tools.js_console", "JavaScript console…", None, Some(Shortcut::cmd("J")), Document, "square-terminal"), c("tools.document_js", "Document JavaScripts…", None, None, Modification, "file-code"), diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index d6ccf7f1..88e565ee 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -122,3 +122,80 @@ impl Session { Ok(n) } } + +/// Export a PDF ▸ Word, HTML or RTF. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OfficeFormat { + Docx, + Html, + Rtf, +} + +impl OfficeFormat { + pub fn extension(self) -> &'static str { + match self { + OfficeFormat::Docx => "docx", + OfficeFormat::Html => "html", + OfficeFormat::Rtf => "rtf", + } + } + + pub fn from_extension(ext: &str) -> Option { + match ext.to_ascii_lowercase().as_str() { + "docx" => Some(OfficeFormat::Docx), + "html" | "htm" => Some(OfficeFormat::Html), + "rtf" => Some(OfficeFormat::Rtf), + _ => None, + } + } +} + +impl crate::Document { + /// The pages as paragraphs and images (for Word, HTML and RTF export). + pub fn export_pages(&self) -> Vec { + let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return Vec::new() }; + self.info + .pages + .iter() + .enumerate() + .map(|(i, info)| { + let blocks = printcraft_edit::text_blocks(cos, i) + .unwrap_or_default() + .into_iter() + .filter(|b| !b.text.trim().is_empty()) + .map(|b| { + let f = b.base_font.to_ascii_lowercase(); + printcraft_export::Block { + text: b.text, + rect: b.rect, + size: b.size, + bold: f.contains("bold") || f.contains("black") || f.contains("heavy"), + italic: f.contains("italic") || f.contains("oblique"), + } + }) + .collect(); + let images = self + .page_images(i) + .iter() + .enumerate() + .filter_map(|(k, im)| { + let (ext, bytes) = self.page_image_file(i, k).ok()?; + Some(printcraft_export::Image { ext: if ext == "jpg" { "jpg" } else { "png" }, bytes, rect: im.rect }) + }) + .collect(); + printcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images } + }) + .collect() + } + + /// The document as a Word, HTML or RTF file. + pub fn export_office(&self, format: OfficeFormat) -> Vec { + let pages = self.export_pages(); + let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string()); + match format { + OfficeFormat::Docx => printcraft_export::docx(&pages, &title), + OfficeFormat::Html => printcraft_export::html(&pages, &title).into_bytes(), + OfficeFormat::Rtf => printcraft_export::rtf(&pages).into_bytes(), + } + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9e75e365..b1583664 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1384,3 +1384,21 @@ fn actions_run_their_steps_on_files() { assert!(page_texts(&s2, id)[0].contains("DRAFT")); assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err()); } + +#[test] +fn exporting_office_files_keeps_images() { + let mut s = Session::new(); + let text = s.create_from_text("t", "Picture page").unwrap(); + let id = s.open("t.pdf", None, text, None).unwrap(); + let png = export::Exporter::new(s.get(id).unwrap()).png(0, 20.0).unwrap(); + let pic = s.create_from_images(&[("p.png".into(), png)]).unwrap(); + let pid = s.open("p.pdf", None, pic, None).unwrap(); + let d = s.get(pid).unwrap(); + assert_eq!(d.export_pages()[0].images.len(), 1); + let docx = d.export_office(compare::OfficeFormat::Docx); + assert!(docx.windows(16).any(|w| w == b"word/media/image")); + if let Ok(dir) = std::env::var("PRINTCRAFT_EXPORT_DIR") { + std::fs::write(format!("{dir}/pic.docx"), &docx).unwrap(); + } + assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("data:image/png;base64,")); +} diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml new file mode 100644 index 00000000..0d5d8bea --- /dev/null +++ b/crates/export/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "printcraft-export" +description = "Export a PDF to HTML, Word (.docx) and RTF from its paragraphs and images" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +flate2.workspace = true diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs new file mode 100644 index 00000000..c7a12022 --- /dev/null +++ b/crates/export/src/lib.rs @@ -0,0 +1,341 @@ +//! printcraft-export — Export a PDF ▸ Word, HTML, RTF (L4). +//! +//! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and +//! images (encoded bytes and box), in reading order. The writers turn that into a flowing +//! document: headings are the paragraphs set larger than the body text, and images sit where +//! they fall between paragraphs. + +mod zip; + +pub use zip::Zip; + +/// A paragraph of a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Block { + pub text: String, + /// [x0, y0, x1, y1] in user space (y up). + pub rect: [f64; 4], + /// Font size in points. + pub size: f64, + pub bold: bool, + pub italic: bool, +} + +/// An image of a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + /// "png" or "jpg". + pub ext: &'static str, + pub bytes: Vec, + pub rect: [f64; 4], +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Page { + pub width: f64, + pub height: f64, + pub blocks: Vec, + pub images: Vec, +} + +/// What goes into the output, in order. +enum Item<'a> { + Para(&'a Block, u8), + Img(&'a Image), + PageBreak, +} + +/// The body text size: the size most of the document's characters are set in. +fn body_size(pages: &[Page]) -> f64 { + let mut by: Vec<(i64, usize)> = Vec::new(); + for b in pages.iter().flat_map(|p| &p.blocks) { + let k = (b.size * 2.0).round() as i64; + match by.iter_mut().find(|e| e.0 == k) { + Some(e) => e.1 += b.text.len(), + None => by.push((k, b.text.len())), + } + } + by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) +} + +/// Heading level for a block: 1 for ≥ 1.6× the body size, 2 for ≥ 1.25×, 0 for body text. +fn level(b: &Block, body: f64) -> u8 { + let short = b.text.len() < 200; + if short && b.size >= body * 1.6 { + 1 + } else if short && (b.size >= body * 1.25 || (b.bold && b.size >= body && b.text.len() < 80 && !b.text.ends_with('.'))) { + 2 + } else { + 0 + } +} + +fn items(pages: &[Page]) -> Vec> { + let body = body_size(pages); + let mut out = Vec::new(); + for (i, p) in pages.iter().enumerate() { + if i > 0 { + out.push(Item::PageBreak); + } + // Blocks and images by their top edge, top to bottom. + let mut parts: Vec<(f64, f64, Item)> = p.blocks.iter().map(|b| (b.rect[3], b.rect[0], Item::Para(b, level(b, body)))).collect(); + parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); + parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); + out.extend(parts.into_iter().map(|x| x.2)); + } + out +} + +fn esc(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +fn base64(data: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + s.push(A[(n >> 18) as usize & 63] as char); + s.push(A[(n >> 12) as usize & 63] as char); + s.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); + s.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' }); + } + s +} + +/// One HTML file: headings and paragraphs, images inline, a rule between pages. +pub fn html(pages: &[Page], title: &str) -> String { + let mut s = format!( + "\n\n\n\n{}\n\n\n\n", + esc(title) + ); + for it in items(pages) { + match it { + Item::Para(b, lvl) => { + let mut t = esc(&b.text); + if b.italic { + t = format!("{t}"); + } + if b.bold && lvl == 0 { + t = format!("{t}"); + } + match lvl { + 0 => s.push_str(&format!("

{t}

\n")), + l => s.push_str(&format!("{t}\n")), + } + } + Item::Img(im) => { + let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; + s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); + } + Item::PageBreak => s.push_str("
\n"), + } + } + s.push_str("\n\n"); + s +} + +/// A Word document (.docx, Office Open XML): Heading 1/2 and Normal paragraphs, images inline at +/// their size on the page, page breaks between pages. +pub fn docx(pages: &[Page], title: &str) -> Vec { + let mut body = String::new(); + let mut media: Vec<(String, &Image)> = Vec::new(); + let run = |b: &Block| { + let mut rpr = String::new(); + if b.bold { + rpr.push_str(""); + } + if b.italic { + rpr.push_str(""); + } + rpr.push_str(&format!("", (b.size * 2.0).round().clamp(2.0, 3276.0) as i64)); + format!("{rpr}{}", esc(&b.text)) + }; + for it in items(pages) { + match it { + Item::Para(b, lvl) => { + let style = match lvl { + 1 => "", + 2 => "", + _ => "", + }; + body.push_str(&format!("{style}{}", run(b))); + } + Item::Img(im) => { + let n = media.len() + 1; + let name = format!("image{n}.{}", im.ext); + // Size on the page, in EMU (12700 per point), at most the text width (6.5 in). + let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); + let k = (468.0 / w).min(1.0); + let (cx, cy) = ((w * k * 12700.0) as i64, (h * k * 12700.0) as i64); + body.push_str(&format!( + "\ +\ +\ +\ +\ +" + )); + media.push((name, im)); + } + Item::PageBreak => body.push_str(""), + } + } + // The first page's size and margins of 1 in. + let (pw, ph) = pages.first().map_or((612.0, 792.0), |p| (p.width, p.height)); + let doc = format!( + "\n\ +{body}\ +", + (pw * 20.0) as i64, + (ph * 20.0) as i64 + ); + let mut types = String::from( + "\n\ +\ +\ +\ +\ +", + ); + types.push_str(""); + let rels = "\n\ +\ +"; + let mut doc_rels = String::from( + "\n\ +", + ); + for (i, (name, _)) in media.iter().enumerate() { + doc_rels.push_str(&format!( + "", + i + 1 + )); + } + doc_rels.push_str(""); + let styles = "\n\ +\ +\ +\ +"; + let core = format!( + "\n{}", + esc(title) + ); + let mut z = Zip::default(); + z.add("[Content_Types].xml", types.as_bytes(), true); + z.add("_rels/.rels", rels.as_bytes(), true); + z.add("docProps/core.xml", core.as_bytes(), true); + z.add("word/document.xml", doc.as_bytes(), true); + z.add("word/styles.xml", styles.as_bytes(), true); + z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true); + for (name, im) in &media { + z.add(&format!("word/media/{name}"), &im.bytes, false); + } + z.finish() +} + +fn rtf_text(s: &str) -> String { + let mut o = String::new(); + for c in s.chars() { + match c { + '\\' | '{' | '}' => { + o.push('\\'); + o.push(c); + } + c if (c as u32) < 128 => o.push(c), + c => { + // \uN with a ? fallback; UTF-16 units as signed 16-bit numbers. + let mut buf = [0u16; 2]; + for u in c.encode_utf16(&mut buf) { + o.push_str(&format!("\\u{}?", *u as i16)); + } + } + } + } + o +} + +/// Rich Text Format: paragraphs with their sizes and bold/italic, page breaks between pages +/// (images are left out). +pub fn rtf(pages: &[Page]) -> String { + let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); + for it in items(pages) { + match it { + Item::Para(b, _) => { + let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); + if b.bold { + fmt.push_str("\\b"); + } + if b.italic { + fmt.push_str("\\i"); + } + s.push_str(&format!("{{\\pard{fmt} {}\\par}}\n", rtf_text(&b.text))); + } + Item::Img(_) => {} + Item::PageBreak => s.push_str("\\page\n"), + } + } + s.push('}'); + s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page() -> Page { + let b = |t: &str, y: f64, size: f64, bold: bool| Block { text: t.into(), rect: [72.0, y, 500.0, y + size], size, bold, italic: false }; + Page { + width: 612.0, + height: 792.0, + blocks: vec![ + b("Body text after the picture, which is long enough to be the main text size of the page.", 400.0, 11.0, false), + b("Annual Report", 700.0, 24.0, true), + b("Overview", 650.0, 14.0, true), + b("Some text & more of it so that eleven points is the body size here.", 620.0, 11.0, false), + ], + images: vec![Image { + ext: "png", + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(), + rect: [72.0, 450.0, 272.0, 600.0], + }], + } + } + + #[test] + fn html_has_headings_paragraphs_and_images_in_order() { + let h = html(&[page(), page()], "Report"); + let order: Vec = ["

Annual Report

", "

Overview

", "

Some <body> text &", "Body text after"] + .iter() + .map(|x| h.find(x).unwrap_or_else(|| panic!("{x} missing in {h}"))) + .collect(); + assert!(order.windows(2).all(|w| w[0] < w[1]), "{order:?}"); + assert_eq!(h.matches("


").count(), 1); + assert_eq!(base64(b"Man"), "TWFu"); + assert_eq!(base64(b"Ma"), "TWE="); + } + + #[test] + fn docx_is_a_word_package() { + let d = docx(&[page()], "Report"); + assert!(d.starts_with(b"PK")); + let names: Vec<&str> = + ["[Content_Types].xml", "word/document.xml", "word/styles.xml", "word/_rels/document.xml.rels", "word/media/image1.png", "_rels/.rels"] + .to_vec(); + for n in names { + assert!(d.windows(n.len()).any(|w| w == n.as_bytes()), "{n}"); + } + } + + #[test] + fn rtf_escapes_and_sizes() { + let mut p = page(); + p.blocks.push(Block { text: "Café {x}".into(), rect: [72.0, 100.0, 200.0, 110.0], size: 10.0, bold: false, italic: true }); + 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}"); + } +} diff --git a/crates/export/src/zip.rs b/crates/export/src/zip.rs new file mode 100644 index 00000000..e4944402 --- /dev/null +++ b/crates/export/src/zip.rs @@ -0,0 +1,110 @@ +//! A minimal zip writer (deflate or store, no zip64) for Office files. + +use std::io::Write; + +fn crc32(data: &[u8]) -> u32 { + static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new(); + let table = TABLE.get_or_init(|| { + let mut t = [0u32; 256]; + for (i, e) in t.iter_mut().enumerate() { + let mut c = i as u32; + for _ in 0..8 { + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + *e = c; + } + t + }); + let mut c = 0xFFFF_FFFFu32; + for b in data { + c = table[((c ^ *b as u32) & 0xFF) as usize] ^ (c >> 8); + } + c ^ 0xFFFF_FFFF +} + +/// Builds a zip archive in memory. +#[derive(Default)] +pub struct Zip { + out: Vec, + central: Vec, + count: u16, +} + +impl Zip { + /// Add a file. Already-compressed data (images) is stored; the rest is deflated. + pub fn add(&mut self, name: &str, data: &[u8], compress: bool) { + let crc = crc32(data); + let (method, body) = if compress { + let mut e = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + let _ = e.write_all(data); + (8u16, e.finish().unwrap_or_default()) + } else { + (0u16, data.to_vec()) + }; + let offset = self.out.len() as u32; + let header = |sig: u32, central: bool| { + let mut h = Vec::new(); + h.extend(sig.to_le_bytes()); + if central { + h.extend(20u16.to_le_bytes()); // made by + } + h.extend(20u16.to_le_bytes()); // version needed + h.extend(0x0800u16.to_le_bytes()); // UTF-8 names + h.extend(method.to_le_bytes()); + h.extend([0u8; 4]); // time, date + h.extend(crc.to_le_bytes()); + h.extend((body.len() as u32).to_le_bytes()); + h.extend((data.len() as u32).to_le_bytes()); + h.extend((name.len() as u16).to_le_bytes()); + h.extend(0u16.to_le_bytes()); // extra + if central { + h.extend(0u16.to_le_bytes()); // comment + h.extend(0u16.to_le_bytes()); // disk + h.extend(0u16.to_le_bytes()); // internal attributes + h.extend(0u32.to_le_bytes()); // external attributes + h.extend(offset.to_le_bytes()); + } + h.extend(name.as_bytes()); + h + }; + self.out.extend(header(0x0403_4b50, false)); + self.out.extend(&body); + self.central.extend(header(0x0201_4b50, true)); + self.count += 1; + } + + pub fn finish(mut self) -> Vec { + let start = self.out.len() as u32; + let size = self.central.len() as u32; + self.out.append(&mut self.central); + self.out.extend(0x0605_4b50u32.to_le_bytes()); + self.out.extend([0u8; 4]); + self.out.extend(self.count.to_le_bytes()); + self.out.extend(self.count.to_le_bytes()); + self.out.extend(size.to_le_bytes()); + self.out.extend(start.to_le_bytes()); + self.out.extend(0u16.to_le_bytes()); + self.out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crc_matches_the_reference() { + assert_eq!(crc32(b"123456789"), 0xCBF4_3926); + } + + #[test] + fn archive_has_both_directories() { + let mut z = Zip::default(); + z.add("a.txt", b"hello hello hello", true); + z.add("b.png", b"\x89PNG", false); + let b = z.finish(); + assert!(b.starts_with(b"PK\x03\x04")); + assert_eq!(b.windows(4).filter(|w| *w == b"PK\x01\x02").count(), 2); + assert_eq!(&b[b.len() - 22..b.len() - 18], b"PK\x05\x06"); + } +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 80124ab4..07406b93 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -361,6 +361,9 @@ impl PrintCraftApp { } "form.export_data" => self.export_data_dialog(false, true), "form.merge_data" => self.merge_data_dialog(), + "export.docx" => self.export_office_dialog(printcraft_engine::compare::OfficeFormat::Docx), + "export.html" => self.export_office_dialog(printcraft_engine::compare::OfficeFormat::Html), + "export.rtf" => self.export_office_dialog(printcraft_engine::compare::OfficeFormat::Rtf), "form.prepare" => { self.left = crate::LeftPanel::Tool("form"); self.left_open = true; diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 9d3162e2..d46af123 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -391,6 +391,35 @@ impl PrintCraftApp { } /// Export all comments / form data: the format follows the file name's extension. + /// Export a PDF ▸ Word, HTML or RTF: ask where (`save_override` in tests), then write. + pub fn export_office_dialog(&mut self, format: printcraft_engine::compare::OfficeFormat) { + let Some((_, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); + let ext = format.extension(); + let bytes = doc.export_office(format); + #[cfg(not(target_arch = "wasm32"))] + { + let path = match self.save_override.clone() { + Some(p) => Some(std::path::PathBuf::from(p)), + None => rfd::FileDialog::new() + .set_title("Export") + .add_filter(ext.to_uppercase(), &[ext]) + .set_file_name(format!("{stem}.{ext}")) + .save_file(), + }; + let Some(path) = path else { return }; + match crate::editing::write_atomically(&path.to_string_lossy(), &bytes) { + Ok(()) => self.notify(format!("Exported to {}", path.display())), + Err(e) => self.notify(format!("Couldn't write {}: {e}", path.display())), + } + } + #[cfg(target_arch = "wasm32")] + if let Err(e) = crate::editing::download(&format!("{stem}.{ext}"), &bytes) { + self.notify(e); + } + } + /// Forms ▸ Merge data files into spreadsheet: choose data files (FDF, XFDF or filled-in PDF /// forms), then where to save the CSV. pub fn merge_data_dialog(&mut self) { diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 298b6357..a4f1bdde 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -56,6 +56,9 @@ const PICKERS: &[&str] = &[ "a11y.report", "ocr.recognize_batch", "form.merge_data", + "export.docx", + "export.html", + "export.rtf", ]; #[test] diff --git a/crates/ui-egui/tests/compare.rs b/crates/ui-egui/tests/compare.rs index cb75eb1b..e6ad6245 100644 --- a/crates/ui-egui/tests/compare.rs +++ b/crates/ui-egui/tests/compare.rs @@ -86,3 +86,19 @@ fn pdfa_dialog_verifies_and_converts() { let id = h.state().active_ids().unwrap().1; assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Save as PDF/A-2b")); } + +#[test] +fn export_to_word_html_and_rtf() { + let doc = Session::new().create_from_text("t", "Exported words").unwrap().to_vec(); + let dir = std::env::temp_dir().join(format!("printcraft-office-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut app = PrintCraftApp::new(); + app.open_bytes("e.pdf", None, doc).unwrap(); + for ext in ["docx", "html", "rtf"] { + let out = dir.join(format!("e.{ext}")); + app.save_override = Some(out.to_string_lossy().into_owned()); + assert!(app.execute(&format!("export.{ext}"))); + assert!(std::fs::metadata(&out).unwrap().len() > 40, "{ext}"); + } + assert!(std::fs::read_to_string(dir.join("e.html")).unwrap().contains("Exported words")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4a55909b..bda808f9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6823,11 +6823,11 @@ title = "Export to Word (.docx)" area = "J" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["export.docx"] +tools = ["doc_export_office"] +evidence = ["crates/export/src/lib.rs::docx_is_a_word_package", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] +notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, images inline, page breaks; checked with macOS textutil. Not yet: tables, columns, fonts and colours, exact layout mode." [[feature]] id = "create.export-rtf" @@ -6835,11 +6835,11 @@ title = "Export to RTF" area = "J" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["export.rtf"] +tools = ["doc_export_office"] +evidence = ["crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] +notes = "Paragraphs with sizes, bold, italic, Unicode; page breaks. Images are left out." [[feature]] id = "create.export-xlsx" @@ -6919,11 +6919,11 @@ title = "Export to HTML" area = "J" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["export.html"] +tools = ["doc_export_office"] +evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] +notes = "Single HTML file with headings, paragraphs and inline images. Not yet: multi-page folder output, tables, CSS from fonts." [[feature]] id = "create.export-text"