Files
md-manuscript/src/odt.rs
T
landon 6680c001bf Page-break between chapters and numeric title fallback
Every chapter after the first now starts on a new page via a
Chapter_20_Break heading style (fo:break-before="page"); the first
chapter keeps the plain heading so there is no blank leading page.

Chapter titles now resolve as: manual override > "# Title:" header >
the chapter's 1-based position in the left pane (e.g. "3."). The old
"# heading" / filename fallbacks are replaced by this numbering, and the
same logic drives the chapter-title hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 14:51:21 -05:00

628 lines
25 KiB
Rust

//! 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<String>,
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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
_ => 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('"', "&quot;");
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<String>,
/// Stack of list markers: true = ordered, false = unordered.
list_stack: Vec<bool>,
/// True while inside a blockquote (affects paragraph style).
quote_depth: u32,
/// Buffer collecting the text of the current code block.
code_block: Option<String>,
/// 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!("<text:p text:style-name=\"{style}\">"));
self.block = Block::Para;
}
}
fn close_block(&mut self) {
match self.block {
Block::Para => self.out.push_str("</text:p>"),
Block::Heading(_) => self.out.push_str("</text:h>"),
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!("<text:span text:style-name=\"{style}\">"));
run.push_str(&escaped);
run.push_str("</text:span>");
} else {
run.push_str(&escaped);
}
if let Some(href) = &self.link {
self.out.push_str(&format!(
"<text:a xlink:type=\"simple\" xlink:href=\"{}\">{run}</text:a>",
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!("<text:list text:style-name=\"{style}\">"));
self.list_stack.push(ordered);
}
fn end_list(&mut self) {
self.close_block();
self.out.push_str("</text:list>");
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("<text:p text:style-name=\"Preformatted_Text\">");
self.write_preformatted_line(line);
self.out.push_str("</text:p>");
}
}
}
/// 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!("<text:s text:c=\"{count}\"/>"));
}
}
'\t' => self.out.push_str("<text:tab/>"),
'&' => self.out.push_str("&amp;"),
'<' => self.out.push_str("&lt;"),
'>' => self.out.push_str("&gt;"),
_ => 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("<text:line-break/>");
}
Event::Rule => {
self.close_block();
self.out
.push_str("<text:p text:style-name=\"Horizontal_Line\"></text:p>");
}
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!(
"<text:h text:style-name=\"Heading_20_{n}\" text:outline-level=\"{n}\">"
));
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("<text:list-item>");
}
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("</text:list-item>");
}
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#"<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
</manifest:manifest>"#
.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!(
"<style:style style:name=\"Heading_20_{n}\" style:display-name=\"Heading {n}\" \
style:family=\"paragraph\" style:parent-style-name=\"Heading\" \
style:next-style-name=\"Text_20_body\" style:default-outline-level=\"{n}\">\
<style:text-properties fo:font-size=\"{size}pt\" fo:font-weight=\"bold\"/>\
</style:style>"
));
}
format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.2">
<office:styles>
<style:default-style style:family="paragraph">
<style:paragraph-properties fo:hyphenation-ladder-count="no-limit"/>
<style:text-properties fo:font-size="11pt" fo:language="en" fo:country="US"/>
</style:default-style>
<style:style style:name="Standard" style:family="paragraph" style:class="text"/>
<style:style style:name="Text_20_body" style:display-name="Text body" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:margin-top="0in" fo:margin-bottom="0.1in"/>
</style:style>
<style:style style:name="Heading" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:margin-top="0.2in" fo:margin-bottom="0.08in" fo:keep-with-next="always"/>
<style:text-properties fo:font-size="14pt" fo:font-weight="bold"/>
</style:style>
{headings}
<style:style style:name="Chapter_20_Break" style:display-name="Chapter Break" style:family="paragraph" style:parent-style-name="Heading_20_1" style:next-style-name="Text_20_body" style:default-outline-level="1">
<style:paragraph-properties fo:break-before="page"/>
</style:style>
<style:style style:name="Chapter_20_Caption" style:display-name="Chapter Caption" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:margin-top="0in" fo:margin-bottom="0.2in"/>
<style:text-properties fo:font-style="italic" fo:font-size="12pt" fo:color="#666666"/>
</style:style>
<style:style style:name="Quotations" style:family="paragraph" style:parent-style-name="Standard" style:class="html">
<style:paragraph-properties fo:margin-left="0.4in" fo:margin-right="0.4in" fo:margin-top="0.05in" fo:margin-bottom="0.05in"/>
<style:text-properties fo:font-style="italic"/>
</style:style>
<style:style style:name="Preformatted_Text" style:display-name="Preformatted Text" style:family="paragraph" style:parent-style-name="Standard" style:class="html">
<style:paragraph-properties fo:margin-top="0in" fo:margin-bottom="0in"/>
<style:text-properties style:font-name="Liberation Mono" fo:font-family="'Liberation Mono'" style:font-family-generic="modit" fo:font-size="10pt"/>
</style:style>
<style:style style:name="Horizontal_Line" style:display-name="Horizontal Line" style:family="paragraph" style:parent-style-name="Standard" style:class="html">
<style:paragraph-properties fo:margin-top="0.05in" fo:margin-bottom="0.1in" fo:border-bottom="0.5pt solid #808080" fo:padding="0in" style:join-border="false"/>
</style:style>
<style:style style:name="T_bold" style:family="text">
<style:text-properties fo:font-weight="bold"/>
</style:style>
<style:style style:name="T_italic" style:family="text">
<style:text-properties fo:font-style="italic"/>
</style:style>
<style:style style:name="T_bolditalic" style:family="text">
<style:text-properties fo:font-weight="bold" fo:font-style="italic"/>
</style:style>
<style:style style:name="T_strike" style:family="text">
<style:text-properties style:text-line-through-style="solid" style:text-line-through-type="single"/>
</style:style>
<style:style style:name="Source_20_Text" style:display-name="Source Text" style:family="text">
<style:text-properties style:font-name="Liberation Mono" fo:font-family="'Liberation Mono'" style:font-family-generic="modit" fo:font-size="10pt"/>
</style:style>
<style:style style:name="Internet_20_Link" style:display-name="Internet Link" style:family="text">
<style:text-properties fo:color="#0563c1" style:text-underline-style="solid" style:text-underline-width="auto" style:text-underline-color="font-color"/>
</style:style>
<text:list-style style:name="L_bullet">
<text:list-level-style-bullet text:level="1" text:bullet-char="&#8226;"><style:list-level-properties text:space-before="0.25in" text:min-label-width="0.25in"/></text:list-level-style-bullet>
<text:list-level-style-bullet text:level="2" text:bullet-char="&#9702;"><style:list-level-properties text:space-before="0.5in" text:min-label-width="0.25in"/></text:list-level-style-bullet>
<text:list-level-style-bullet text:level="3" text:bullet-char="&#9642;"><style:list-level-properties text:space-before="0.75in" text:min-label-width="0.25in"/></text:list-level-style-bullet>
</text:list-style>
<text:list-style style:name="L_number">
<text:list-level-style-number text:level="1" style:num-format="1" text:num-suffix="."><style:list-level-properties text:space-before="0.25in" text:min-label-width="0.25in"/></text:list-level-style-number>
<text:list-level-style-number text:level="2" style:num-format="a" text:num-suffix="."><style:list-level-properties text:space-before="0.5in" text:min-label-width="0.25in"/></text:list-level-style-number>
<text:list-level-style-number text:level="3" style:num-format="i" text:num-suffix="."><style:list-level-properties text:space-before="0.75in" text:min-label-width="0.25in"/></text:list-level-style-number>
</text:list-style>
</office:styles>
<office:automatic-styles>
<style:page-layout style:name="pm1">
<style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in"/>
</style:page-layout>
</office:automatic-styles>
<office:master-styles>
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
</office:master-styles>
</office:document-styles>"##
)
}
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!(
"<text:h text:style-name=\"{heading_style}\" text:outline-level=\"1\">{}</text:h>",
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!(
"<text:p text:style-name=\"Chapter_20_Caption\">\
<text:span text:style-name=\"T_italic\">{}</text:span></text:p>",
esc(slug)
));
}
body.push_str(&markdown_to_body(&chapter.markdown, 1));
}
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.2">
<office:body>
<office:text>
{body}
</office:text>
</office:body>
</office:document-content>"#
)
}
/// 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("<text:span text:style-name=\"T_bold\">bold</text:span>"));
assert!(body.contains("<text:span text:style-name=\"T_italic\">italic</text:span>"));
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("<text:list text:style-name=\"L_bullet\">"));
assert!(body.contains("<text:list text:style-name=\"L_number\">"));
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("<text:s text:c=\"4\"/>"));
}
#[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<String> = 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</text:h>"));
assert!(content.contains(">Chapter Two</text:h>"));
// The first chapter uses the plain heading; later chapters get a page break.
assert!(content.contains(
"<text:h text:style-name=\"Heading_20_1\" text:outline-level=\"1\">Chapter One</text:h>"
));
assert!(content.contains(
"<text:h text:style-name=\"Chapter_20_Break\" text:outline-level=\"1\">Chapter Two</text:h>"
));
// The slug renders as an italic caption paragraph under the first chapter.
assert!(content.contains(
"<text:p text:style-name=\"Chapter_20_Caption\">\
<text:span text:style-name=\"T_italic\">the opening</text:span></text:p>"
));
// 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\""));
}
}