From 945ff6c6a451ebf4a5261772c26d2156c5cbec51 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Fri, 3 Apr 2026 12:53:36 -0500 Subject: [PATCH] fix: implement native DOCX export without pandoc dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace pandoc-based DOCX export with native Rust implementation using docx-rs crate. DOCX export now works out of the box without requiring users to install external tools. Changes: - Added docx-rs dependency to Cargo.toml - Implemented export_docx() in exporter.rs - Removed pandoc subprocess calls from docs.rs - Uses same markdown parsing as PDF export - Handles titles, headings, and normal text with appropriate styling Tested: - Rust compilation ✓ - Rust formatting ✓ - TypeScript types ✓ Co-Authored-By: Claude Sonnet 4.5 --- src-tauri/Cargo.lock | 185 +++++++++++++++++++++++++++++++-- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/docs.rs | 35 +------ src-tauri/src/docs/exporter.rs | 34 ++++++ 4 files changed, 218 insertions(+), 37 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fd3f260c..03e3ab82 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -330,6 +330,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -504,6 +510,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -1047,6 +1059,21 @@ dependencies = [ "litrs", ] +[[package]] +name = "docx-rs" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed73cbf5e1c37baa23f4132569ac1187829f03922c206bd68fe109e3001a343d" +dependencies = [ + "base64 0.22.1", + "image", + "quick-xml 0.36.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "zip 0.6.6", +] + [[package]] name = "dom_query" version = "0.25.1" @@ -1228,6 +1255,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -1638,6 +1685,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -1816,6 +1873,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2068,7 +2136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2185,6 +2253,24 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2541,7 +2627,7 @@ dependencies = [ "tar", "ureq", "vcpkg", - "zip", + "zip 7.2.0", ] [[package]] @@ -2738,6 +2824,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.1" @@ -2753,7 +2849,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -3407,7 +3503,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3425,6 +3521,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -3593,6 +3702,28 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -5028,7 +5159,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -5315,6 +5446,7 @@ dependencies = [ "base64 0.22.1", "chrono", "dirs 5.0.1", + "docx-rs", "futures", "hex", "printpdf", @@ -5388,6 +5520,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -5763,7 +5909,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -7176,6 +7322,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zip" version = "7.2.0" @@ -7213,3 +7371,18 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b7fccaaf..cc23649e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ regex = "1" aho-corasick = "1" uuid = { version = "1", features = ["v7"] } printpdf = "0.7" +docx-rs = "0.4" sha2 = { version = "0.10", features = ["std"] } hex = "0.4" anyhow = "1" diff --git a/src-tauri/src/commands/docs.rs b/src-tauri/src/commands/docs.rs index ef5b845c..11e6dc18 100644 --- a/src-tauri/src/commands/docs.rs +++ b/src-tauri/src/commands/docs.rs @@ -202,37 +202,10 @@ pub async fn export_document( path.to_string_lossy().to_string() } "docx" => { - // DOCX export via pandoc - let md_path = base_dir.join(format!("{safe_title}_temp.md")); - let docx_path = base_dir.join(format!("{safe_title}.docx")); - - // Write markdown to temp file - std::fs::write(&md_path, &content_md) - .map_err(|e| format!("Failed to write temp markdown: {}", e))?; - - // Use pandoc to convert - let output = std::process::Command::new("pandoc") - .arg(md_path.to_str().unwrap()) - .arg("-o") - .arg(docx_path.to_str().unwrap()) - .arg("-f") - .arg("markdown") - .arg("-t") - .arg("docx") - .output() - .map_err(|e| format!("Failed to run pandoc (is it installed?): {}", e))?; - - // Clean up temp file - let _ = std::fs::remove_file(&md_path); - - if !output.status.success() { - return Err(format!( - "Pandoc conversion failed: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - - docx_path.to_string_lossy().to_string() + let path = base_dir.join(format!("{safe_title}.docx")); + exporter::export_docx(&content_md, &title, path.to_str().unwrap()) + .map_err(|e| e.to_string())?; + path.to_string_lossy().to_string() } _ => return Err(format!("Unsupported export format: {format}")), }; diff --git a/src-tauri/src/docs/exporter.rs b/src-tauri/src/docs/exporter.rs index 51170163..6b756aea 100644 --- a/src-tauri/src/docs/exporter.rs +++ b/src-tauri/src/docs/exporter.rs @@ -9,6 +9,40 @@ pub fn export_markdown(content_md: &str, output_path: &str) -> anyhow::Result<() Ok(()) } +pub fn export_docx(content_md: &str, title: &str, output_path: &str) -> anyhow::Result<()> { + use docx_rs::*; + + let path = Path::new(output_path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let lines = markdown_to_lines(content_md); + let mut docx = Docx::new(); + + // Add title + docx = docx.add_paragraph(Paragraph::new().add_run(Run::new().add_text(title).size(32).bold())); + + for line_info in &lines { + if line_info.text.is_empty() { + docx = docx.add_paragraph(Paragraph::new()); + continue; + } + + let run = match line_info.style { + LineStyle::Title => Run::new().add_text(&line_info.text).size(28).bold(), + LineStyle::Heading => Run::new().add_text(&line_info.text).size(22).bold(), + LineStyle::Normal => Run::new().add_text(&line_info.text).size(20), + }; + + docx = docx.add_paragraph(Paragraph::new().add_run(run)); + } + + let file = std::fs::File::create(path)?; + docx.build().pack(file)?; + Ok(()) +} + pub fn export_pdf(content_md: &str, title: &str, output_path: &str) -> anyhow::Result<()> { use printpdf::*;