diff --git a/Cargo.lock b/Cargo.lock index cb1307a..899080b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,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" @@ -701,6 +707,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -839,6 +851,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1027,6 +1045,21 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "docx-rs" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70395eb132dcc1761533e62c54878a9deb2b637863ecb52e9c5f66148616398e" +dependencies = [ + "base64 0.22.1", + "image", + "serde", + "serde_json", + "thiserror 2.0.18", + "xml-rs", + "zip", +] + [[package]] name = "dpi" version = "0.1.2" @@ -1212,6 +1245,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.116", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -1600,6 +1653,16 @@ dependencies = [ "wasip3", ] +[[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" @@ -1766,6 +1829,17 @@ dependencies = [ "syn 2.0.116", ] +[[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" @@ -1952,7 +2026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2069,6 +2143,24 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.12", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2483,6 +2575,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.1" @@ -2498,7 +2600,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -3187,6 +3289,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 = "polling" version = "3.11.0" @@ -3329,6 +3444,21 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[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.38.4" @@ -4357,7 +4487,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -4710,6 +4840,20 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + [[package]] name = "time" version = "0.3.47" @@ -4995,7 +5139,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -5460,6 +5604,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -6046,6 +6196,7 @@ name = "writer-md" version = "0.1.0" dependencies = [ "comrak", + "docx-rs", "serde", "serde_json", "thiserror 2.0.18", @@ -6172,6 +6323,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "yaml-rust" version = "0.4.5" @@ -6339,12 +6496,54 @@ dependencies = [ "syn 2.0.116", ] +[[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 = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[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.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core 0.5.1", +] + [[package]] name = "zvariant" version = "5.9.2" diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index 319b27c..ebf72db 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] comrak = "0.50" +docx-rs = "0.4" serde = { version = "1", features = ["derive"] } thiserror = "2" diff --git a/crates/markdown/src/lib.rs b/crates/markdown/src/lib.rs index 8404ea3..2dad27b 100644 --- a/crates/markdown/src/lib.rs +++ b/crates/markdown/src/lib.rs @@ -3,7 +3,7 @@ use diagnostics::Diagnostics; use parser::MarkdownParser; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use transformer::MarkdownTransformer; +use transformer::{DocxTransformer, MarkdownTransformer}; mod diagnostics; mod parser; @@ -218,6 +218,17 @@ pub struct TextExportResult { pub word_count: usize, } +/// Result of rendering Markdown for DOCX export +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocxExportResult { + /// The DOCX file bytes + pub data: Vec, + /// Document title from metadata + pub title: Option, + /// Word count + pub word_count: usize, +} + /// A list item for PDF export #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PdfListItem { @@ -494,6 +505,30 @@ impl MarkdownEngine { Ok(TextExportResult { text: plain_text, title: metadata.title, word_count: metadata.word_count }) } + + /// Renders Markdown text to DOCX format + /// + /// Parses the markdown and transforms it into a DOCX byte buffer + /// using docx-rs, supporting headings, bold, italic, code font, + /// ordered/unordered lists, blockquotes, and code blocks. + pub fn render_for_docx(&self, text: &str, profile: MarkdownProfile) -> Result { + let arena = Arena::new(); + let options = profile.to_options(); + + let (body_text, front_matter) = if profile.supports_front_matter() { + MarkdownParser::extract_front_matter(text) + } else { + (text, FrontMatter::default()) + }; + + let root = parse_document(&arena, body_text, &options); + let metadata = MarkdownParser::build_metadata(root, body_text, front_matter); + + let data = DocxTransformer::transform_to_docx(root) + .map_err(|e| MarkdownError::ParseError(format!("DOCX generation failed: {}", e)))?; + + Ok(DocxExportResult { data, title: metadata.title, word_count: metadata.word_count }) + } } #[cfg(test)] @@ -1099,7 +1134,7 @@ mod tests { assert!(result.text.contains("- Item 1")); assert!(result.text.contains("- Item 2")); assert!(result.text.contains("- Item 3")); - assert!(!result.text.contains("* ")); // Should not have markdown list markers + assert!(!result.text.contains("* ")); } #[test] @@ -1133,7 +1168,7 @@ mod tests { assert!(result.text.contains("> This is a quote")); assert!(result.text.contains("> with multiple lines") || result.text.contains("with multiple lines")); - assert!(!result.text.contains("> This is a quote\n> ")); // Check that we don't have the raw markdown + assert!(!result.text.contains("> This is a quote\n> ")); } #[test] @@ -1326,4 +1361,71 @@ mod tests { assert!(!result.text.contains("`"), "Code markers should be stripped"); assert!(!result.text.contains("~~"), "Strikethrough markers should be stripped"); } + + #[test] + fn test_render_for_docx_basic() { + let engine = MarkdownEngine::new(); + let markdown = "# Hello World\n\nThis is a paragraph."; + let result = engine.render_for_docx(markdown, MarkdownProfile::GfmSafe).unwrap(); + + assert!(!result.data.is_empty()); + assert_eq!(&result.data[0..2], b"PK"); + assert_eq!(result.title, Some("Hello World".to_string())); + assert!(result.word_count > 0); + } + + #[test] + fn test_render_for_docx_with_front_matter() { + let engine = MarkdownEngine::new(); + let markdown = "---\ntitle: My Document\n---\n\n# Content\n\nBody text."; + let result = engine.render_for_docx(markdown, MarkdownProfile::Extended).unwrap(); + + assert_eq!(&result.data[0..2], b"PK"); + assert_eq!(result.title, Some("My Document".to_string())); + } + + #[test] + fn test_render_for_docx_empty() { + let engine = MarkdownEngine::new(); + let result = engine.render_for_docx("", MarkdownProfile::GfmSafe).unwrap(); + + assert_eq!(&result.data[0..2], b"PK"); + assert_eq!(result.title, None); + assert_eq!(result.word_count, 0); + } + + #[test] + fn test_render_for_docx_all_formatting() { + let engine = MarkdownEngine::new(); + let markdown = "\ +# Heading 1 + +## Heading 2 + +### Heading 3 + +Paragraph with **bold**, *italic*, `code`, and ~~strikethrough~~. + +1. Ordered item one +2. Ordered item two + +- Bullet one +- Bullet two + +> A blockquote + +``` +code block +``` + +--- +"; + let result = engine.render_for_docx(markdown, MarkdownProfile::GfmSafe).unwrap(); + + assert_eq!(&result.data[0..2], b"PK"); + assert!( + result.data.len() > 500, + "Mixed doc should produce a reasonably sized DOCX" + ); + } } diff --git a/crates/markdown/src/transformer.rs b/crates/markdown/src/transformer.rs index aae0b1e..a858be7 100644 --- a/crates/markdown/src/transformer.rs +++ b/crates/markdown/src/transformer.rs @@ -3,6 +3,10 @@ use comrak::nodes::NodeValue; pub struct MarkdownTransformer; +mod docx; + +pub use docx::DocxTransformer; + impl MarkdownTransformer { /// Extracts plain text content from a node and its children fn extract_text_content<'a>(node: &'a comrak::nodes::AstNode<'a>) -> String { @@ -243,7 +247,7 @@ impl MarkdownTransformer { for line in &lines[1..] { if !line.is_empty() { result.push_str(&indent); - result.push_str(" "); // Extra indent for continuation + result.push_str(" "); result.push_str(line); } result.push('\n'); diff --git a/crates/markdown/src/transformer/docx.rs b/crates/markdown/src/transformer/docx.rs new file mode 100644 index 0000000..e5ac98a --- /dev/null +++ b/crates/markdown/src/transformer/docx.rs @@ -0,0 +1,533 @@ +use comrak::nodes::{ListType, NodeValue}; +use docx_rs::*; +use std::io::Cursor; + +const ORDERED_ABSTRACT_NUM_ID: usize = 1; +const BULLET_ABSTRACT_NUM_ID: usize = 2; +const ORDERED_NUM_ID: usize = 1; +const BULLET_NUM_ID: usize = 2; + +/// Half-point H1 = 24pt +const HEADING1_SIZE: usize = 48; +/// Half-point H2 = 18pt +const HEADING2_SIZE: usize = 36; +/// Half-point H3 = 14pt +const HEADING3_SIZE: usize = 28; + +/// Indent for blockquotes in twips (720 = 0.5 inch) +const BLOCKQUOTE_INDENT: i32 = 720; + +/// Indent for list items in twips +const LIST_INDENT: i32 = 420; + +pub struct DocxTransformer; + +impl DocxTransformer { + /// Transforms a Comrak AST root node into DOCX bytes. + pub fn transform_to_docx<'a>(root: &'a comrak::nodes::AstNode<'a>) -> Result, DocxError> { + let mut doc = Docx::new(); + + doc = doc + .add_style( + Style::new("Heading1", StyleType::Paragraph) + .name("Heading 1") + .bold() + .size(HEADING1_SIZE), + ) + .add_style( + Style::new("Heading2", StyleType::Paragraph) + .name("Heading 2") + .bold() + .size(HEADING2_SIZE), + ) + .add_style( + Style::new("Heading3", StyleType::Paragraph) + .name("Heading 3") + .bold() + .size(HEADING3_SIZE), + ); + + doc = doc + .add_abstract_numbering( + AbstractNumbering::new(ORDERED_ABSTRACT_NUM_ID).add_level( + Level::new( + 0, + Start::new(1), + NumberFormat::new("decimal"), + LevelText::new("%1."), + LevelJc::new("left"), + ) + .indent( + Some(LIST_INDENT), + Some(SpecialIndentType::Hanging(LIST_INDENT)), + None, + None, + ), + ), + ) + .add_numbering(Numbering::new(ORDERED_NUM_ID, ORDERED_ABSTRACT_NUM_ID)) + .add_abstract_numbering( + AbstractNumbering::new(BULLET_ABSTRACT_NUM_ID).add_level( + Level::new( + 0, + Start::new(1), + NumberFormat::new("bullet"), + LevelText::new("\u{2022}"), + LevelJc::new("left"), + ) + .indent( + Some(LIST_INDENT), + Some(SpecialIndentType::Hanging(LIST_INDENT)), + None, + None, + ), + ), + ) + .add_numbering(Numbering::new(BULLET_NUM_ID, BULLET_ABSTRACT_NUM_ID)); + + let paragraphs = Self::collect_blocks(root); + for para in paragraphs { + doc = doc.add_paragraph(para); + } + + let mut buf = Cursor::new(Vec::new()); + doc.build().pack(&mut buf)?; + Ok(buf.into_inner()) + } + + /// Collects block-level elements from the AST into DOCX paragraphs. + fn collect_blocks<'a>(node: &'a comrak::nodes::AstNode<'a>) -> Vec { + let mut paragraphs = Vec::new(); + + for child in node.children() { + let value = child.data.borrow().value.clone(); + match value { + NodeValue::Document => { + paragraphs.extend(Self::collect_blocks(child)); + } + NodeValue::Heading(heading) => { + let runs = Self::collect_inline_runs(child); + let style_id = match heading.level { + 1 => "Heading1", + 2 => "Heading2", + 3 => "Heading3", + _ => "Heading3", + }; + let mut para = Paragraph::new().style(style_id); + for run in runs { + para = para.add_run(run); + } + paragraphs.push(para); + } + NodeValue::Paragraph => { + let runs = Self::collect_inline_runs(child); + if !runs.is_empty() { + let mut para = Paragraph::new(); + for run in runs { + para = para.add_run(run); + } + paragraphs.push(para); + } + } + NodeValue::CodeBlock(code_block) => { + let content = code_block.literal.trim_end_matches('\n'); + for line in content.split('\n') { + let run = Run::new() + .add_text(line) + .fonts( + RunFonts::new() + .ascii("Courier New") + .hi_ansi("Courier New") + .cs("Courier New"), + ) + .size(20); + paragraphs.push(Paragraph::new().add_run(run)); + } + } + NodeValue::List(list) => { + let ordered = list.list_type == ListType::Ordered; + paragraphs.extend(Self::collect_list_items(child, ordered)); + } + NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_) => { + paragraphs.extend(Self::collect_blockquote(child)); + } + NodeValue::ThematicBreak => { + paragraphs.push( + Paragraph::new() + .add_run(Run::new().add_text("───────────────────────────")) + .align(AlignmentType::Center), + ); + } + NodeValue::Table(_) => { + paragraphs.extend(Self::collect_table_as_paragraphs(child)); + } + _ => { + paragraphs.extend(Self::collect_blocks(child)); + } + } + } + + paragraphs + } + + /// Collects inline content from a node into a series of Runs with formatting. + fn collect_inline_runs<'a>(node: &'a comrak::nodes::AstNode<'a>) -> Vec { + Self::collect_inline_runs_inner(node, false, false, false) + } + + fn collect_inline_runs_inner<'a>( + node: &'a comrak::nodes::AstNode<'a>, bold: bool, italic: bool, code_font: bool, + ) -> Vec { + let mut runs = Vec::new(); + + for child in node.children() { + let value = child.data.borrow().value.clone(); + match value { + NodeValue::Text(ref t) => { + let mut run = Run::new().add_text(t.to_string()); + if bold { + run = run.bold(); + } + if italic { + run = run.italic(); + } + if code_font { + run = run.fonts( + RunFonts::new() + .ascii("Courier New") + .hi_ansi("Courier New") + .cs("Courier New"), + ); + } + runs.push(run); + } + NodeValue::Code(ref code) => { + let mut run = Run::new().add_text(&code.literal).fonts( + RunFonts::new() + .ascii("Courier New") + .hi_ansi("Courier New") + .cs("Courier New"), + ); + if bold { + run = run.bold(); + } + if italic { + run = run.italic(); + } + runs.push(run); + } + NodeValue::Strong => { + runs.extend(Self::collect_inline_runs_inner(child, true, italic, code_font)); + } + NodeValue::Emph => { + runs.extend(Self::collect_inline_runs_inner(child, bold, true, code_font)); + } + NodeValue::Strikethrough => { + let inner_runs = Self::collect_inline_runs_inner(child, bold, italic, code_font); + for run in inner_runs { + runs.push(run.strike()); + } + } + NodeValue::Link(ref link) => { + let link_text = Self::extract_text(child); + let display = if link_text.is_empty() { link.url.clone() } else { link_text }; + let mut run = Run::new().add_text(&display); + if bold { + run = run.bold(); + } + if italic { + run = run.italic(); + } + runs.push(run); + } + NodeValue::SoftBreak | NodeValue::LineBreak => { + runs.push(Run::new().add_text(" ")); + } + NodeValue::Superscript | NodeValue::Subscript => { + runs.extend(Self::collect_inline_runs_inner(child, bold, italic, code_font)); + } + _ => { + runs.extend(Self::collect_inline_runs_inner(child, bold, italic, code_font)); + } + } + } + + runs + } + + /// Collects list items into paragraphs with numbering. + fn collect_list_items<'a>(list_node: &'a comrak::nodes::AstNode<'a>, ordered: bool) -> Vec { + let mut paragraphs = Vec::new(); + let num_id = if ordered { ORDERED_NUM_ID } else { BULLET_NUM_ID }; + + for child in list_node.children() { + let value = child.data.borrow().value.clone(); + match value { + NodeValue::Item(_) => { + let mut first_para = true; + for item_child in child.children() { + let item_value = item_child.data.borrow().value.clone(); + match item_value { + NodeValue::Paragraph => { + let runs = Self::collect_inline_runs(item_child); + if !runs.is_empty() { + let mut para = Paragraph::new(); + if first_para { + para = para.numbering(NumberingId::new(num_id), IndentLevel::new(0)); + first_para = false; + } + for run in runs { + para = para.add_run(run); + } + paragraphs.push(para); + } + } + NodeValue::List(nested_list) => { + let nested_ordered = nested_list.list_type == ListType::Ordered; + paragraphs.extend(Self::collect_list_items(item_child, nested_ordered)); + } + _ => {} + } + } + } + NodeValue::TaskItem(ref task) => { + let checkbox = if task.symbol.is_some() { "\u{2611} " } else { "\u{2610} " }; + let mut runs = vec![Run::new().add_text(checkbox)]; + runs.extend(Self::collect_inline_runs(child)); + let mut para = Paragraph::new().numbering(NumberingId::new(num_id), IndentLevel::new(0)); + for run in runs { + para = para.add_run(run); + } + paragraphs.push(para); + } + _ => {} + } + } + + paragraphs + } + + /// Collects blockquote content into indented paragraphs. + fn collect_blockquote<'a>(node: &'a comrak::nodes::AstNode<'a>) -> Vec { + let mut paragraphs = Vec::new(); + + for child in node.children() { + let value = child.data.borrow().value.clone(); + match value { + NodeValue::Paragraph => { + let runs = Self::collect_inline_runs(child); + if !runs.is_empty() { + let mut para = Paragraph::new().indent(Some(BLOCKQUOTE_INDENT), None, None, None); + for run in runs { + para = para.add_run(run.italic()); + } + paragraphs.push(para); + } + } + NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_) => { + let inner = Self::collect_blockquote(child); + for para in inner { + paragraphs.push(para.indent(Some(BLOCKQUOTE_INDENT), None, None, None)); + } + } + _ => { + let inner = Self::collect_blocks(child); + for mut para in inner { + para = para.indent(Some(BLOCKQUOTE_INDENT), None, None, None); + paragraphs.push(para); + } + } + } + } + + paragraphs + } + + /// Renders a table as tab-separated paragraphs (simple fallback). + fn collect_table_as_paragraphs<'a>(node: &'a comrak::nodes::AstNode<'a>) -> Vec { + let mut paragraphs = Vec::new(); + + for row in node.children() { + if let NodeValue::TableRow(_) = &row.data.borrow().value { + let cells: Vec = row + .children() + .filter_map(|cell| { + if let NodeValue::TableCell = &cell.data.borrow().value { + Some(Self::extract_text(cell)) + } else { + None + } + }) + .collect(); + + if !cells.is_empty() { + let line = cells.join("\t"); + paragraphs.push(Paragraph::new().add_run(Run::new().add_text(&line))); + } + } + } + + paragraphs + } + + /// Extracts plain text from a node tree (no formatting). + fn extract_text<'a>(node: &'a comrak::nodes::AstNode<'a>) -> String { + let mut text = String::new(); + for child in node.children() { + let value = child.data.borrow().value.clone(); + match value { + NodeValue::Text(ref t) => text.push_str(t), + NodeValue::Code(ref code) => text.push_str(&code.literal), + NodeValue::SoftBreak | NodeValue::LineBreak => text.push(' '), + NodeValue::Link(ref link) => { + let inner = Self::extract_text(child); + if inner.is_empty() { + text.push_str(&link.url); + } else { + text.push_str(&inner); + } + } + _ => text.push_str(&Self::extract_text(child)), + } + } + text + } +} + +#[cfg(test)] +mod tests { + use super::*; + use comrak::{Arena, Options, parse_document}; + + fn parse_md(text: &str) -> Vec { + let arena = Arena::new(); + let mut opts = Options::default(); + opts.extension.strikethrough = true; + opts.extension.table = true; + opts.extension.tasklist = true; + let root = parse_document(&arena, text, &opts); + DocxTransformer::transform_to_docx(root).expect("DOCX generation should succeed") + } + + #[test] + fn test_empty_document() { + let bytes = parse_md(""); + assert!(bytes.len() > 4); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn test_heading_levels() { + let md = "# Heading 1\n\n## Heading 2\n\n### Heading 3\n"; + let bytes = parse_md(md); + assert!(bytes.len() > 100); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn test_paragraph_with_inline_formatting() { + let md = "This is **bold** and *italic* and `code` text.\n"; + let bytes = parse_md(md); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn test_ordered_list() { + let md = "1. First\n2. Second\n3. Third\n"; + let bytes = parse_md(md); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn test_unordered_list() { + let md = "- Apple\n- Banana\n- Cherry\n"; + let bytes = parse_md(md); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn test_blockquote() { + let md = "> This is a blockquote.\n"; + let bytes = parse_md(md); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn test_code_block() { + let md = "```rust\nfn main() {\n println!(\"hello\");\n}\n```\n"; + let bytes = parse_md(md); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn test_thematic_break() { + let md = "Before\n\n---\n\nAfter\n"; + let bytes = parse_md(md); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn test_strikethrough() { + let md = "This is ~~struck~~ text.\n"; + let bytes = parse_md(md); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn test_table() { + let md = "| A | B |\n|---|---|\n| 1 | 2 |\n"; + let bytes = parse_md(md); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn test_mixed_document() { + let md = "\ +# Title + +Some paragraph with **bold** and *italic*. + +## Section + +1. First item +2. Second item + +- Bullet one +- Bullet two + +> A blockquote + +``` +code block +``` + +--- + +Final paragraph. +"; + let bytes = parse_md(md); + assert_eq!(&bytes[0..2], b"PK"); + assert!(bytes.len() > 500); + } + + #[test] + fn test_nested_formatting() { + let md = "This is ***bold and italic*** text.\n"; + let bytes = parse_md(md); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn test_link() { + let md = "Visit [example](https://example.com) for more.\n"; + let bytes = parse_md(md); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn test_task_list() { + let md = "- [ ] Todo\n- [x] Done\n"; + let bytes = parse_md(md); + assert_eq!(&bytes[0..2], b"PK"); + } +} diff --git a/package.json b/package.json index 9e17a1a..23d31dd 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", - "test": "vitest", + "test": "vitest --reporter=dot", "test:run": "vitest run", "test:coverage": "vitest run --coverage", "lint": "oxlint", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1f3ede9..2fa4f9b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -12,7 +12,7 @@ use writer_core::{ LocationDescriptor, LocationId, SaveResult, SearchFilters, SearchHit, StyleCategorySettings, StyleMatch, StylePatternInput, StyleScanInput, }; -use writer_md::{MarkdownEngine, MarkdownProfile, PdfRenderResult, RenderResult, TextExportResult}; +use writer_md::{DocxExportResult, MarkdownEngine, MarkdownProfile, PdfRenderResult, RenderResult, TextExportResult}; use writer_store::{Store, StyleCheckSettings, UiLayoutSettings}; type CommandResponse = std::result::Result, AppError>; @@ -945,6 +945,48 @@ pub fn markdown_render_for_text( } } +/// Renders markdown text to DOCX format +/// +/// This command takes document text and returns DOCX bytes +/// generated via docx-rs with support for headings, bold, italic, +/// code font, ordered/unordered lists, blockquotes, and code blocks. +#[tauri::command] +pub fn markdown_render_for_docx( + _: State<'_, AppState>, location_id: i64, rel_path: String, text: String, profile: Option, +) -> CommandResponse { + let location_id = LocationId(location_id); + let rel_path = PathBuf::from(&rel_path); + + log::debug!( + "Rendering markdown for DOCX: location={:?}, path={:?}, profile={:?}, text_len={}", + location_id, + rel_path, + profile, + text.len() + ); + + let engine = MarkdownEngine::new(); + let profile = profile.unwrap_or(MarkdownProfile::Extended); + + match engine.render_for_docx(&text, profile) { + Ok(result) => { + log::debug!( + "Markdown rendered for DOCX successfully: data_len={}, word_count={}", + result.data.len(), + result.word_count + ); + Ok(CommandResult::ok(result)) + } + Err(e) => { + log::error!("Failed to render markdown for DOCX: {}", e); + Ok(CommandResult::err(AppError::new( + writer_core::ErrorCode::Parse, + format!("Failed to render markdown for DOCX: {}", e), + ))) + } + } +} + #[tauri::command] pub fn style_check_get(state: State<'_, AppState>) -> CommandResponse { log::debug!("Loading persisted style check settings"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dccd6fb..da567d4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -105,6 +105,7 @@ pub fn run() { cmd::markdown_render, cmd::markdown_render_for_pdf, cmd::markdown_render_for_text, + cmd::markdown_render_for_docx, cmd::ui_layout_get, cmd::ui_layout_set, cmd::session_get, diff --git a/src/__tests__/ExportDialog.test.tsx b/src/__tests__/ExportDialog.test.tsx index 5602699..d7e82c9 100644 --- a/src/__tests__/ExportDialog.test.tsx +++ b/src/__tests__/ExportDialog.test.tsx @@ -70,7 +70,7 @@ describe("PdfExportDialog", () => { useUiStore.getState().setPdfExportDialogOpen(true); renderExportDialog(); expect(screen.getByText("PDF")).toBeInTheDocument(); - expect(screen.getByText("DOC/DOCX")).toBeInTheDocument(); + expect(screen.getByText("DOCX")).toBeInTheDocument(); expect(screen.getByText("Plaintext")).toBeInTheDocument(); expect(screen.getByText("Page Size")).toBeInTheDocument(); expect(screen.getByText("Orientation")).toBeInTheDocument(); @@ -131,14 +131,14 @@ describe("PdfExportDialog", () => { expect(exportingButton).toBeDisabled(); }); - it("disables docx export format tab but enables plaintext tab", () => { + it("enables docx and plaintext export format tabs", () => { useUiStore.getState().setPdfExportDialogOpen(true); renderExportDialog(); - const docxTab = screen.getByRole("button", { name: "DOC/DOCX export tab" }); + const docxTab = screen.getByRole("button", { name: "DOCX export tab" }); const txtTab = screen.getByRole("button", { name: "Plaintext export tab" }); - expect(docxTab).toBeDisabled(); + expect(docxTab).toBeEnabled(); expect(txtTab).toBeEnabled(); expect(screen.getByText("Export PDF")).toBeEnabled(); }); diff --git a/src/__tests__/useDocxExport.test.tsx b/src/__tests__/useDocxExport.test.tsx new file mode 100644 index 0000000..bf9f3c0 --- /dev/null +++ b/src/__tests__/useDocxExport.test.tsx @@ -0,0 +1,120 @@ +import { useDocxExport } from "$hooks/useDocxExport"; +import { useAppStore } from "$state/stores/app"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeFile } from "@tauri-apps/plugin-fs"; +import * as logger from "@tauri-apps/plugin-log"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn() })); + +vi.mock("@tauri-apps/plugin-fs", () => ({ writeFile: vi.fn() })); + +vi.mock("$state/stores/toasts", () => ({ showSuccessToast: vi.fn(), showErrorToast: vi.fn() })); + +const docxRenderResult = { data: [80, 75, 3, 4, 0, 0, 0, 0], title: "Test Document", word_count: 10 }; + +describe(useDocxExport, () => { + beforeEach(() => { + vi.clearAllMocks(); + useAppStore.getState().resetDocxExport(); + }); + + it("exports DOCX successfully", async () => { + vi.mocked(save).mockResolvedValue("/tmp/output.docx"); + + const { result } = renderHook(() => useDocxExport()); + + let didExport = false; + await act(async () => { + didExport = await result.current(docxRenderResult); + }); + + expect(didExport).toBeTruthy(); + expect(save).toHaveBeenCalledWith({ + filters: [{ name: "Word Document", extensions: ["docx"] }], + defaultPath: "Test_Document.docx", + }); + expect(writeFile).toHaveBeenCalledOnce(); + expect(useAppStore.getState().isExportingDocx).toBeFalsy(); + expect(useAppStore.getState().docxExportError).toBeNull(); + }); + + it("returns false when user cancels save dialog", async () => { + vi.mocked(save).mockResolvedValue(null); + + const { result } = renderHook(() => useDocxExport()); + + let didExport = false; + await act(async () => { + didExport = await result.current(docxRenderResult); + }); + + expect(didExport).toBeFalsy(); + expect(save).toHaveBeenCalledOnce(); + expect(writeFile).not.toHaveBeenCalled(); + expect(vi.mocked(logger.info)).toHaveBeenCalledWith("DOCX export canceled before writing file"); + }); + + it("uses default filename when title is null", async () => { + vi.mocked(save).mockResolvedValue("/tmp/output.docx"); + + const { result } = renderHook(() => useDocxExport()); + + const resultWithoutTitle = { ...docxRenderResult, title: null }; + + await act(async () => { + await result.current(resultWithoutTitle); + }); + + expect(save).toHaveBeenCalledWith({ + filters: [{ name: "Word Document", extensions: ["docx"] }], + defaultPath: "document.docx", + }); + }); + + it("sanitizes filename allowing spaces, dashes, and dots", async () => { + vi.mocked(save).mockResolvedValue("/tmp/output.docx"); + + const { result } = renderHook(() => useDocxExport()); + + const resultWithSpecialChars = { ...docxRenderResult, title: "Version 1.1 - Final Draft" }; + + await act(async () => { + await result.current(resultWithSpecialChars); + }); + + expect(save).toHaveBeenCalledWith({ + filters: [{ name: "Word Document", extensions: ["docx"] }], + defaultPath: "Version_1.1_-_Final_Draft.docx", + }); + }); + + it("handles export errors and sets error state", async () => { + vi.mocked(save).mockRejectedValue(new Error("Disk full")); + + const { result } = renderHook(() => useDocxExport()); + + await act(async () => { + await expect(result.current(docxRenderResult)).rejects.toThrow("Disk full"); + }); + + expect(useAppStore.getState().isExportingDocx).toBeFalsy(); + expect(useAppStore.getState().docxExportError).toBe("Disk full"); + }); + + it("writes correct Uint8Array data", async () => { + vi.mocked(save).mockResolvedValue("/tmp/output.docx"); + + const { result } = renderHook(() => useDocxExport()); + + await act(async () => { + await result.current(docxRenderResult); + }); + + expect(writeFile).toHaveBeenCalledOnce(); + const writtenData = vi.mocked(writeFile).mock.calls[0][1] as Uint8Array; + expect(writtenData[0]).toBe(80); + expect(writtenData[1]).toBe(75); + }); +}); diff --git a/src/components/export/ExportDialog/ExportDialog.tsx b/src/components/export/ExportDialog/ExportDialog.tsx index 028c136..fb4a37f 100644 --- a/src/components/export/ExportDialog/ExportDialog.tsx +++ b/src/components/export/ExportDialog/ExportDialog.tsx @@ -3,10 +3,13 @@ import { Dialog } from "$components/Dialog"; import { PdfPreviewPanel } from "$components/export/preview/PdfPreview"; import { TextPreviewPanel } from "$components/export/preview/TextPreview"; import { FileTextIcon } from "$components/icons"; +import { useDocxExportUI } from "$hooks/useDocxExport"; import { useTextExportUI } from "$hooks/useTextExport"; import { useViewportTier } from "$hooks/useViewportTier"; import type { PdfExportOptions, PdfRenderResult } from "$pdf/types"; import { + useDocxExportActions, + useDocxExportState, usePdfDialogUiState, usePdfExportActions, usePdfExportState, @@ -32,8 +35,8 @@ type ExportFormatTab = { id: ExportFormat; label: string; disabled: boolean }; const EXPORT_FORMAT_TABS: ExportFormatTab[] = [{ id: "pdf", label: "PDF", disabled: false }, { id: "docx", - label: "DOC/DOCX", - disabled: true, + label: "DOCX", + disabled: false, }, { id: "txt", label: "Plaintext", disabled: false }]; type ExportFormatTabsProps = { @@ -164,6 +167,26 @@ function TextExportContent( ); } +type DocxExportContentProps = { handleDocxExport: () => Promise }; + +function DocxExportContent({ handleDocxExport }: DocxExportContentProps) { + const { docxExportError: error, isExportingDocx } = useDocxExportState(); + return ( + <> + +
+
+

Export your document as a Word-compatible DOCX file.

+

+ Headings, bold, italic, code, lists, and blockquotes are preserved. +

+
+
+ + + ); +} + function DocumentTitle({ title }: { title?: string }) { if (!title) { return null; @@ -223,12 +246,15 @@ export function ExportDialog({ onExport, previewResult, editorFontFamily, docume ); const { handleExportText, handleExportMarkdown } = useTextExportUI({ activeTab, text: documentText }); + const { handleExportDocx } = useDocxExportUI({ activeTab, text: documentText }); + const { resetDocxExport } = useDocxExportActions(); const handleCancel = useCallback(() => { setIsOpen(false); resetPdfExport(); resetTextExport(); - }, [resetPdfExport, resetTextExport, setIsOpen]); + resetDocxExport(); + }, [resetPdfExport, resetTextExport, resetDocxExport, setIsOpen]); const handlePdfExportClick = useCallback(async () => { await onExport(options); @@ -244,9 +270,15 @@ export function ExportDialog({ onExport, previewResult, editorFontFamily, docume setIsOpen(false); }, [handleExportMarkdown, setIsOpen]); + const handleDocxExportClick = useCallback(async () => { + await handleExportDocx(); + setIsOpen(false); + }, [handleExportDocx, setIsOpen]); + const compactPanel = useMemo(() => isCompact || viewportWidth < 1024, [isCompact, viewportWidth]); const showPreview = useMemo(() => !compactPanel && viewportWidth >= 1200, [compactPanel, viewportWidth]); const isPdfTabActive = useMemo(() => activeExportTabId === "pdf", [activeExportTabId]); + const isDocxTabActive = useMemo(() => activeExportTabId === "docx", [activeExportTabId]); const isTextTabActive = useMemo(() => activeExportTabId === "txt", [activeExportTabId]); const containerClasses = useMemo(() => { @@ -275,7 +307,8 @@ export function ExportDialog({ onExport, previewResult, editorFontFamily, docume setActiveExportTabId(nextTabId); resetPdfExport(); resetTextExport(); - }, [resetPdfExport, resetTextExport]); + resetDocxExport(); + }, [resetPdfExport, resetTextExport, resetDocxExport]); const pdfExportProps = useMemo(() => ({ showPreview, previewResult, options, editorFontFamily }), [ showPreview, @@ -298,6 +331,7 @@ export function ExportDialog({ onExport, previewResult, editorFontFamily, docume {isPdfTabActive && } + {isDocxTabActive && } {isTextTabActive && activeTab && ( Promise; + +export function useDocxExport(): ExportDocxFn { + const { startDocxExport, finishDocxExport, failDocxExport } = useDocxExportActions(); + + const exportDocx = useCallback(async (result: DocxExportResult) => { + startDocxExport(); + + try { + const uint8Array = new Uint8Array(result.data); + const defaultFileName = result.title ? sanitizeExportFilename(result.title) : "document.docx"; + const filePath = await save({ + filters: [{ name: "Word Document", extensions: ["docx"] }], + defaultPath: defaultFileName, + }); + + if (!filePath) { + logger.info("DOCX export canceled before writing file"); + finishDocxExport(); + return false; + } + + await writeFile(filePath, uint8Array); + showSuccessToast("DOCX exported successfully"); + + finishDocxExport(); + return true; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to export DOCX"; + failDocxExport(errorMessage); + showErrorToast(`Export failed: ${errorMessage}`); + throw err; + } + }, [failDocxExport, finishDocxExport, startDocxExport]); + + return exportDocx; +} + +type UseDocxExportUIArgs = { activeTab: Tab | null; text: string }; + +export function useDocxExportUI({ activeTab, text }: UseDocxExportUIArgs) { + const exportDocx = useDocxExport(); + + const handleExportDocx = useCallback(async () => { + if (!activeTab) { + logger.warn("Cannot export DOCX without an active document."); + return; + } + + const docRef = activeTab.docRef; + + try { + const renderResult = await new Promise((resolve, reject) => { + void runCmd(renderMarkdownForDocx(docRef.location_id, docRef.rel_path, text, void 0, resolve, reject)); + }); + + await exportDocx(renderResult); + } catch (error) { + logger.error(f("Failed to export DOCX", { error: error instanceof Error ? error.message : String(error) })); + } + }, [activeTab, exportDocx, text]); + + return { handleExportDocx }; +} diff --git a/src/ports/commands.ts b/src/ports/commands.ts index 81e3d93..d1d63b3 100644 --- a/src/ports/commands.ts +++ b/src/ports/commands.ts @@ -38,6 +38,7 @@ import type { GlobalCaptureValidateShortcutParams, LocParams, PersistedStyleCheckSettings, + RenderMarkdownForDocxParams, RenderMarkdownForPdfParams, RenderMarkdownForTextParams, RenderMarkdownParams, @@ -258,6 +259,28 @@ export function renderMarkdownForText( return invokeCmd("markdown_render_for_text", payload, onOk, onErr); } +export type DocxExportResult = { data: number[]; title: string | null; word_count: number }; + +export function renderMarkdownForDocx( + ...[locationId, relPath, text, profile, onOk, onErr]: RenderMarkdownForDocxParams +): Cmd { + const payload = toSafeMarkdownPayload(locationId, relPath, text, profile); + void info( + JSON.stringify({ + event: "renderMarkdownForDocx_payload", + raw: { + locationId: describeValueShape(locationId), + relPath: describeValueShape(relPath), + text: describeValueShape(text), + profile: describeValueShape(profile), + }, + normalized: { ...payload, text: `<${payload.text.length} chars>` }, + }), + ).catch(() => {}); + + return invokeCmd("markdown_render_for_docx", payload, onOk, onErr); +} + export function uiLayoutGet(...[onOk, onErr]: LocParams): Cmd { return invokeCmd("ui_layout_get", {}, onOk, onErr); } diff --git a/src/ports/types.ts b/src/ports/types.ts index dbe7d67..a192169 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -137,6 +137,7 @@ export type SearchParams = Parameters< export type RenderMarkdownParams = [...LocationPathTextParams, profile?: MarkdownProfile, ...LocParams]; export type RenderMarkdownForPdfParams = [...LocationPathTextParams, profile?: MarkdownProfile, ...LocParams]; export type RenderMarkdownForTextParams = [...LocationPathTextParams, profile?: MarkdownProfile, ...LocParams]; +export type RenderMarkdownForDocxParams = [...LocationPathTextParams, profile?: MarkdownProfile, ...LocParams]; export type UiLayoutSetParams = Parameters< (settings: UiLayoutSettings, onOk: SuccessCallback, onErr: ErrorCallback) => void diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 778c506..7d35597 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -1,5 +1,6 @@ import type { StyleCheckSettings } from "$types"; import { useShallow } from "zustand/react/shallow"; +import { useDocxExportStore } from "./stores/docx-export"; import { useLayoutStore } from "./stores/layout"; import { usePdfExportStore } from "./stores/pdf-export"; import { useSearchStore } from "./stores/search"; @@ -202,6 +203,21 @@ export const useTextExportActions = () => })), ); +export const useDocxExportState = () => + useDocxExportStore( + useShallow((state) => ({ isExportingDocx: state.isExportingDocx, docxExportError: state.docxExportError })), + ); + +export const useDocxExportActions = () => + useDocxExportStore( + useShallow((state) => ({ + startDocxExport: state.startDocxExport, + finishDocxExport: state.finishDocxExport, + failDocxExport: state.failDocxExport, + resetDocxExport: state.resetDocxExport, + })), + ); + export const useSearchState = () => useSearchStore( useShallow((state) => ({ diff --git a/src/state/stores/app.ts b/src/state/stores/app.ts index af914c1..c216f25 100644 --- a/src/state/stores/app.ts +++ b/src/state/stores/app.ts @@ -1,5 +1,6 @@ import type { AppStore } from "$state/types"; import { useMemo } from "react"; +import { resetDocxExportStore, useDocxExportStore } from "./docx-export"; import { resetLayoutStore, useLayoutStore } from "./layout"; import { resetPdfExportStore, usePdfExportStore } from "./pdf-export"; import { resetSearchStore, useSearchStore } from "./search"; @@ -16,6 +17,7 @@ function getMergedState(): AppStore { ...useTabsStore.getState(), ...usePdfExportStore.getState(), ...useTextExportStore.getState(), + ...useDocxExportStore.getState(), ...useSearchStore.getState(), ...useUiStore.getState(), } as AppStore; @@ -29,12 +31,14 @@ export function useAppStore(selector?: (state: AppStore) => T): AppStore | T const tabs = useTabsStore(); const pdfExport = usePdfExportStore(); const textExport = useTextExportStore(); + const docxExport = useDocxExportStore(); const search = useSearchStore(); const ui = useUiStore(); const state = useMemo( - () => ({ ...layout, ...workspace, ...tabs, ...pdfExport, ...textExport, ...search, ...ui }) as AppStore, - [layout, workspace, tabs, pdfExport, textExport, search, ui], + () => + ({ ...layout, ...workspace, ...tabs, ...pdfExport, ...textExport, ...docxExport, ...search, ...ui }) as AppStore, + [layout, workspace, tabs, pdfExport, textExport, docxExport, search, ui], ); if (!selector) { @@ -52,6 +56,7 @@ export function resetAppStore(): void { resetTabsStore(); resetPdfExportStore(); resetTextExportStore(); + resetDocxExportStore(); resetSearchStore(); resetUiStore(); resetShortcutsStore(); diff --git a/src/state/stores/docx-export.ts b/src/state/stores/docx-export.ts new file mode 100644 index 0000000..6c18773 --- /dev/null +++ b/src/state/stores/docx-export.ts @@ -0,0 +1,19 @@ +import type { DocxExportActions, DocxExportState } from "$state/types"; +import { create } from "zustand"; + +export type DocxExportStore = DocxExportState & DocxExportActions; + +export const getInitialDocxExportState = (): DocxExportState => ({ isExportingDocx: false, docxExportError: null }); + +export const useDocxExportStore = create()((set) => ({ + ...getInitialDocxExportState(), + + startDocxExport: () => set({ isExportingDocx: true, docxExportError: null }), + finishDocxExport: () => set({ isExportingDocx: false, docxExportError: null }), + failDocxExport: (message) => set({ isExportingDocx: false, docxExportError: message }), + resetDocxExport: () => set(getInitialDocxExportState()), +})); + +export function resetDocxExportStore(): void { + useDocxExportStore.setState(getInitialDocxExportState()); +} diff --git a/src/state/types.ts b/src/state/types.ts index c5f013f..0717bae 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -157,6 +157,15 @@ export type TextExportActions = { resetTextExport: () => void; }; +export type DocxExportState = { isExportingDocx: boolean; docxExportError: string | null }; + +export type DocxExportActions = { + startDocxExport: () => void; + finishDocxExport: () => void; + failDocxExport: (message: string) => void; + resetDocxExport: () => void; +}; + export type SearchState = { searchQuery: string; searchResults: SearchHit[]; @@ -211,6 +220,8 @@ export type AppStore = & PdfExportActions & TextExportState & TextExportActions + & DocxExportState + & DocxExportActions & SearchState & SearchActions & UiState