Strip comments and header metadata on ODT export

Each file is now cleaned before conversion: HTML comments are removed
and everything above the configurable Draft marker (default
"### Rough Draft:") is dropped. A "# Title:" line becomes the chapter
heading and a "# Slug:" line becomes an italic caption beneath it.

- new preprocess module (strip_comments + header parse) with tests
- Chapter gains a slug rendered via a Chapter_20_Caption style, italic
  at both paragraph-style and character-run level
- draft_marker config setting + top-bar field
- export title priority: override > # Title: > first # heading > filename

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
landon
2026-07-25 19:01:45 -05:00
parent f473b63ee3
commit 507e0ab724
6 changed files with 279 additions and 9 deletions
+22
View File
@@ -19,6 +19,8 @@ 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,
}
@@ -387,6 +389,10 @@ fn styles_xml() -> String {
<style:text-properties fo:font-size="14pt" fo:font-weight="bold"/>
</style:style>
{headings}
<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"/>
@@ -448,6 +454,15 @@ fn content_xml(chapters: &[Chapter]) -> String {
"<text:h text:style-name=\"Heading_20_1\" 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));
}
@@ -549,10 +564,12 @@ Done.";
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(),
},
];
@@ -576,5 +593,10 @@ Done.";
.unwrap();
assert!(content.contains(">Chapter One</text:h>"));
assert!(content.contains(">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>"
));
}
}