//! Native OpenDocument Text (.odt) generation from markdown. //! //! An .odt file is a ZIP archive containing (at minimum): //! * `mimetype` - the media type, stored uncompressed and first //! * `META-INF/manifest.xml` - lists the archive members //! * `styles.xml` - named paragraph / text / list styles //! * `content.xml` - the document body //! //! We parse each markdown document with pulldown-cmark and emit ODF flow //! elements. Each source file becomes a chapter: a "Heading 1" with the chapter //! title, followed by the converted body (whose own headings are shifted down a //! level so they nest under the chapter heading). use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use std::io::Write; use std::path::Path; use zip::write::SimpleFileOptions; /// One chapter to export. pub struct Chapter { pub title: String, /// Optional caption shown beneath the chapter title (from the `# Slug:` line). pub slug: Option, pub markdown: String, } /// Escape text for use inside XML character data. fn esc(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), _ => out.push(c), } } out } /// Escape text for use inside an XML attribute value. fn esc_attr(s: &str) -> String { let mut out = esc(s); out = out.replace('"', """); out } /// Which block-level element is currently open in the output stream. #[derive(PartialEq, Clone, Copy)] enum Block { None, Para, Heading(u8), } struct Converter { out: String, block: Block, bold: bool, italic: bool, strike: bool, code: bool, link: Option, /// Stack of list markers: true = ordered, false = unordered. list_stack: Vec, /// True while inside a blockquote (affects paragraph style). quote_depth: u32, /// Buffer collecting the text of the current code block. code_block: Option, /// Heading level offset so document headings nest under the chapter title. heading_offset: u8, } impl Converter { fn new(heading_offset: u8) -> Self { Converter { out: String::new(), block: Block::None, bold: false, italic: false, strike: false, code: false, link: None, list_stack: Vec::new(), quote_depth: 0, code_block: None, heading_offset, } } fn para_style(&self) -> &'static str { if self.quote_depth > 0 { "Quotations" } else { "Text_20_body" } } /// Ensure a block-level element is open to receive inline content. fn ensure_inline_target(&mut self) { if self.block == Block::None { let style = self.para_style(); self.out .push_str(&format!("")); self.block = Block::Para; } } fn close_block(&mut self) { match self.block { Block::Para => self.out.push_str(""), Block::Heading(_) => self.out.push_str(""), Block::None => {} } self.block = Block::None; } /// Emit an inline text run with the currently active character formatting. fn write_text(&mut self, text: &str) { self.ensure_inline_target(); let escaped = esc(text); // Choose a text (character) style for the active emphasis flags. let span_style = if self.code { Some("Source_20_Text") } else { match (self.bold, self.italic, self.strike) { (true, true, _) => Some("T_bolditalic"), (true, false, false) => Some("T_bold"), (false, true, false) => Some("T_italic"), (_, _, true) => Some("T_strike"), _ => None, } }; let mut run = String::new(); if let Some(style) = span_style { run.push_str(&format!("")); run.push_str(&escaped); run.push_str(""); } else { run.push_str(&escaped); } if let Some(href) = &self.link { self.out.push_str(&format!( "{run}", esc_attr(href) )); } else { self.out.push_str(&run); } } fn start_list(&mut self, ordered: bool) { self.close_block(); let style = if ordered { "L_number" } else { "L_bullet" }; self.out .push_str(&format!("")); self.list_stack.push(ordered); } fn end_list(&mut self) { self.close_block(); self.out.push_str(""); self.list_stack.pop(); } fn flush_code_block(&mut self) { if let Some(buf) = self.code_block.take() { // Trailing newline from the fenced block is not a real line. let content = buf.strip_suffix('\n').unwrap_or(&buf); for line in content.split('\n') { self.out .push_str(""); self.write_preformatted_line(line); self.out.push_str(""); } } } /// Write a single code line, preserving runs of spaces and tabs. fn write_preformatted_line(&mut self, line: &str) { let mut chars = line.chars().peekable(); while let Some(c) = chars.next() { match c { ' ' => { let mut count = 1; while chars.peek() == Some(&' ') { chars.next(); count += 1; } if count == 1 { self.out.push(' '); } else { self.out .push_str(&format!("")); } } '\t' => self.out.push_str(""), '&' => self.out.push_str("&"), '<' => self.out.push_str("<"), '>' => self.out.push_str(">"), _ => self.out.push(c), } } } fn heading_number(&self, level: HeadingLevel) -> u8 { let base = match level { HeadingLevel::H1 => 1, HeadingLevel::H2 => 2, HeadingLevel::H3 => 3, HeadingLevel::H4 => 4, HeadingLevel::H5 => 5, HeadingLevel::H6 => 6, }; (base + self.heading_offset).min(10) } fn run(mut self, markdown: &str) -> String { let mut opts = Options::empty(); opts.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(markdown, opts); for event in parser { match event { Event::Start(tag) => self.start_tag(tag), Event::End(tag) => self.end_tag(tag), Event::Text(t) => { if let Some(buf) = self.code_block.as_mut() { buf.push_str(&t); } else { self.write_text(&t); } } Event::Code(t) => { self.code = true; self.write_text(&t); self.code = false; } Event::SoftBreak => { if self.code_block.is_some() { // handled via Text } else { self.write_text(" "); } } Event::HardBreak => { self.ensure_inline_target(); self.out.push_str(""); } Event::Rule => { self.close_block(); self.out .push_str(""); } Event::Html(_) | Event::InlineHtml(_) => { /* skip raw HTML */ } _ => {} } } self.close_block(); self.out } fn start_tag(&mut self, tag: Tag) { match tag { Tag::Paragraph => { // Lazily opened by the first inline content. self.close_block(); } Tag::Heading { level, .. } => { self.close_block(); let n = self.heading_number(level); self.out.push_str(&format!( "" )); self.block = Block::Heading(n); } Tag::BlockQuote(_) => { self.close_block(); self.quote_depth += 1; } Tag::CodeBlock(kind) => { self.close_block(); let _ = match kind { CodeBlockKind::Fenced(_) => (), CodeBlockKind::Indented => (), }; self.code_block = Some(String::new()); } Tag::List(first) => { self.start_list(first.is_some()); } Tag::Item => { self.out.push_str(""); } Tag::Emphasis => self.italic = true, Tag::Strong => self.bold = true, Tag::Strikethrough => self.strike = true, Tag::Link { dest_url, .. } => { self.link = Some(dest_url.to_string()); } Tag::Image { title, dest_url, .. } => { // We do not embed images; surface a readable placeholder. let label = if !title.is_empty() { title.to_string() } else { dest_url.to_string() }; self.write_text(&format!("[image: {label}]")); } _ => {} } } fn end_tag(&mut self, tag: TagEnd) { match tag { TagEnd::Paragraph => self.close_block(), TagEnd::Heading(_) => self.close_block(), TagEnd::BlockQuote(_) => { self.close_block(); if self.quote_depth > 0 { self.quote_depth -= 1; } } TagEnd::CodeBlock => self.flush_code_block(), TagEnd::List(_) => self.end_list(), TagEnd::Item => { self.close_block(); self.out.push_str(""); } TagEnd::Emphasis => self.italic = false, TagEnd::Strong => self.bold = false, TagEnd::Strikethrough => self.strike = false, TagEnd::Link => self.link = None, _ => {} } } } /// Convert one markdown document into an ODF body fragment, with headings shifted /// down by `heading_offset` levels. fn markdown_to_body(markdown: &str, heading_offset: u8) -> String { Converter::new(heading_offset).run(markdown) } const MIMETYPE: &str = "application/vnd.oasis.opendocument.text"; fn manifest_xml() -> String { r#" "# .to_string() } fn styles_xml() -> String { // Heading sizes for the seven levels we may emit (chapter = 1). let sizes = [18, 16, 14, 13, 12, 11, 11, 11, 11, 11]; let mut headings = String::new(); for (i, size) in sizes.iter().enumerate() { let n = i + 1; headings.push_str(&format!( "\ \ " )); } format!( r##" {headings} "## ) } fn content_xml(chapters: &[Chapter]) -> String { let mut body = String::new(); for (i, chapter) in chapters.iter().enumerate() { // Chapter heading (always level 1); the document's own headings are // shifted down by one so they nest beneath it. Every chapter after the // first starts on a new page via a heading style with a page break. let heading_style = if i == 0 { "Heading_20_1" } else { "Chapter_20_Break" }; body.push_str(&format!( "{}", esc(&chapter.title) )); if let Some(slug) = chapter.slug.as_deref().map(str::trim).filter(|s| !s.is_empty()) { // Italic at both the paragraph-style and character-run level so the // caption stays italic regardless of how a reader applies styles. body.push_str(&format!( "\ {}", esc(slug) )); } body.push_str(&markdown_to_body(&chapter.markdown, 1)); } format!( r#" {body} "# ) } /// Write the chapters to an .odt file at `out_path`. pub fn export(chapters: &[Chapter], out_path: &Path) -> std::io::Result<()> { let file = std::fs::File::create(out_path)?; let mut zip = zip::ZipWriter::new(file); // mimetype MUST be the first entry and stored without compression. let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); zip.start_file("mimetype", stored) .map_err(to_io)?; zip.write_all(MIMETYPE.as_bytes())?; let deflated = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); zip.add_directory("META-INF/", deflated).map_err(to_io)?; zip.start_file("META-INF/manifest.xml", deflated) .map_err(to_io)?; zip.write_all(manifest_xml().as_bytes())?; zip.start_file("styles.xml", deflated).map_err(to_io)?; zip.write_all(styles_xml().as_bytes())?; zip.start_file("content.xml", deflated).map_err(to_io)?; zip.write_all(content_xml(chapters).as_bytes())?; zip.finish().map_err(to_io)?; Ok(()) } fn to_io(e: zip::result::ZipError) -> std::io::Error { std::io::Error::new(std::io::ErrorKind::Other, e) } #[cfg(test)] mod tests { use super::*; #[test] fn body_covers_common_markdown() { let md = "\ Some **bold** and *italic* and ***both*** and `code` and ~~gone~~. ## A subheading - one - two - nested 1. first 2. second > a quote ``` let x = 1; indented(); ``` A [link](https://example.com) and a rule: --- Done."; let body = markdown_to_body(md, 1); assert!(body.contains("bold")); assert!(body.contains("italic")); assert!(body.contains("T_bolditalic")); assert!(body.contains("Source_20_Text")); assert!(body.contains("T_strike")); // Heading level 2 in source shifts to Heading_20_3 (offset 1). assert!(body.contains("text:style-name=\"Heading_20_3\"")); assert!(body.contains("")); assert!(body.contains("")); assert!(body.contains("Quotations")); assert!(body.contains("Preformatted_Text")); assert!(body.contains("xlink:href=\"https://example.com\"")); assert!(body.contains("Horizontal_Line")); // spaces in indented code preserved assert!(body.contains("")); } #[test] fn export_writes_valid_archive() { let chapters = vec![ Chapter { title: "Chapter One".to_string(), slug: Some("the opening".to_string()), markdown: "Hello **world**.\n\n## Scene\n\nText.".to_string(), }, Chapter { title: "Chapter Two".to_string(), slug: None, markdown: "- a\n- b\n".to_string(), }, ]; let dir = std::env::temp_dir(); let out = dir.join("md_manuscript_test.odt"); export(&chapters, &out).expect("export ok"); // Re-open the archive and check required members exist with mimetype first. let file = std::fs::File::open(&out).unwrap(); let mut zip = zip::ZipArchive::new(file).unwrap(); assert_eq!(zip.file_names().next().unwrap(), "mimetype"); let names: Vec = zip.file_names().map(|s| s.to_string()).collect(); for required in ["mimetype", "META-INF/manifest.xml", "styles.xml", "content.xml"] { assert!(names.iter().any(|n| n == required), "missing {required}"); } use std::io::Read; let mut content = String::new(); zip.by_name("content.xml") .unwrap() .read_to_string(&mut content) .unwrap(); assert!(content.contains(">Chapter One")); assert!(content.contains(">Chapter Two")); // The first chapter uses the plain heading; later chapters get a page break. assert!(content.contains( "Chapter One" )); assert!(content.contains( "Chapter Two" )); // The slug renders as an italic caption paragraph under the first chapter. assert!(content.contains( "\ the opening" )); // The page-break style is defined in styles.xml. let mut styles = String::new(); zip.by_name("styles.xml") .unwrap() .read_to_string(&mut styles) .unwrap(); assert!(styles.contains("style:name=\"Chapter_20_Break\"")); assert!(styles.contains("fo:break-before=\"page\"")); } }