fix: implement native DOCX export without pandoc dependency
Some checks failed
Test / rust-tests (push) Waiting to run
Test / frontend-typecheck (push) Waiting to run
Test / frontend-tests (push) Waiting to run
Auto Tag / auto-tag (push) Failing after 4s
Test / rust-fmt-check (push) Successful in 1m32s
Test / rust-clippy (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Shaun Arman 2026-04-03 12:53:36 -05:00
parent f738ee26ed
commit 945ff6c6a4
4 changed files with 218 additions and 37 deletions

185
src-tauri/Cargo.lock generated
View File

@ -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",
]

View File

@ -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"

View File

@ -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}")),
};

View File

@ -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::*;