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
+36 -4
View File
@@ -66,11 +66,43 @@ Terminal=false
to write to disk. Drag the `` handles to reorder.
4. Set the **Chapter title** for the open file if you want to override the
auto-derived one (the field shows the automatic title as a hint). Leaving it
blank falls back to the file's first level-1 heading (`# ...`), or the file
name. Titles are saved to `titles.json` and sync with git.
blank falls back to a `# Title:` line, then the file's first level-1 heading
(`# ...`), then the file name. Titles are saved to `titles.json` and sync with
git.
5. Set the **Export** path and press **Export ODT** to produce the concatenated
document. Each file becomes a chapter headed by its (overridden or automatic)
title; the source's leading `# heading` is consumed as that title slot.
document. Each file becomes a chapter headed by its title, optionally followed
by a caption from its `# Slug:` line.
## Header stripping on export
Manuscript files often carry editorial notes and metadata above the prose that
should not appear in the finished document. On export, each file is cleaned up:
* **HTML comments** (`<!-- ... -->`, including multi-line ones) are removed.
* **The header block** — everything above the **Draft marker** line (set in the
top bar; default `### Rough Draft:`) — is dropped. From that header:
* `# Title:` becomes the chapter heading (unless a manual override is set), and
* `# Slug:` becomes an italic caption printed beneath the heading.
Any other header text is discarded. Files with no marker line are exported
whole, but stray `# Title:` / `# Slug:` lines are still lifted out. Clear the
Draft marker field to disable header splitting entirely.
For example, this source file:
```markdown
# Title: The Gate
# Slug: in which the door will not open
<!-- remember to foreshadow the key -->
outline: she arrives, she knocks, nothing
### Rough Draft:
The prose that actually gets exported begins here.
```
exports as a chapter titled **The Gate**, captioned *in which the door will not
open*, containing only the prose below the marker.
## Files the app writes
+31 -5
View File
@@ -276,21 +276,33 @@ impl App {
fn export_odt(&mut self) {
self.save_current();
let marker = self.config.draft_marker.clone();
let mut chapters = Vec::new();
for name in &self.files {
let markdown = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
// The leading `# heading` is always consumed as the chapter-title
// slot; a manual override, if set, replaces the derived title.
let (auto_title, body) = split_title(&markdown, name);
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
// Strip comments + the editorial header, and lift out any
// `# Title:` / `# Slug:` metadata.
let header = crate::preprocess::parse(&raw, &marker);
// Title priority: manual override > `# Title:` metadata > the body's
// leading `# heading` (consumed) > file name. When a title comes from
// metadata/override, the body is left intact; otherwise the leading
// `# heading` is consumed into the title slot to avoid duplication.
let (title, body) = if let Some(t) = &header.title {
(t.clone(), header.body.clone())
} else {
split_title(&header.body, name)
};
let title = self
.titles
.get(name)
.map(|t| t.trim())
.filter(|t| !t.is_empty())
.map(|t| t.to_string())
.unwrap_or(auto_title);
.unwrap_or(title);
chapters.push(Chapter {
title,
slug: header.slug.clone(),
markdown: body,
});
}
@@ -376,6 +388,20 @@ impl App {
{
self.config.save();
}
ui.separator();
ui.label("Draft marker:")
.on_hover_text(
"Text above this line in each file (its header) is dropped on export; \
# Title: / # Slug: lines become the chapter heading and caption. \
Leave blank to export files whole.",
);
let marker_resp = ui.add(
egui::TextEdit::singleline(&mut self.config.draft_marker)
.desired_width(140.0),
);
if marker_resp.lost_focus() {
self.config.save();
}
});
ui.add_space(4.0);
});
+11
View File
@@ -11,6 +11,16 @@ pub struct Config {
/// Whether to show the live preview pane.
#[serde(default)]
pub show_preview: bool,
/// Marker line separating a file's editorial header from its prose. Content
/// above it (except `# Title:` / `# Slug:` metadata) is dropped on export.
/// An empty value disables header splitting.
#[serde(default = "default_marker")]
pub draft_marker: String,
}
/// Default header/body separator, matching the manuscript drafting convention.
pub fn default_marker() -> String {
"### Rough Draft:".to_string()
}
impl Default for Config {
@@ -21,6 +31,7 @@ impl Default for Config {
workspace,
export_path,
show_preview: false,
draft_marker: default_marker(),
}
}
}
+1
View File
@@ -7,6 +7,7 @@ mod config;
mod gitsync;
mod odt;
mod order;
mod preprocess;
use eframe::egui;
+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>"
));
}
}
+178
View File
@@ -0,0 +1,178 @@
//! Markdown pre-processing applied at export time.
//!
//! Manuscript source files may carry an editorial "header" above the prose and
//! HTML comments inline. Neither belongs in the exported document, so before a
//! file is converted to ODT we:
//!
//! * strip HTML comments (`<!-- ... -->`, including multi-line ones), and
//! * split off the header: everything above a marker line (default
//! `### Rough Draft:`). From the header we pull out `# Title:` and
//! `# Slug:` metadata; the title becomes the chapter heading and the slug a
//! caption beneath it. All other header text is discarded.
//!
//! If a file has no marker line, nothing is treated as a header block, but any
//! stray `# Title:` / `# Slug:` lines are still lifted out and removed.
/// The parsed result of pre-processing one markdown document.
pub struct Header {
/// Value of the `# Title:` line, if present and non-empty.
pub title: Option<String>,
/// Value of the `# Slug:` line, if present and non-empty.
pub slug: Option<String>,
/// The prose body, with comments, the header block, and metadata lines removed.
pub body: String,
}
#[derive(Clone, Copy)]
enum Meta {
Title,
Slug,
}
/// Remove `<!-- ... -->` comments (matching the non-greedy `<!--[\s\S]*?-->`),
/// including comments that span multiple lines. An unterminated comment drops
/// everything from `<!--` onward.
pub fn strip_comments(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
match rest[start + 4..].find("-->") {
Some(end) => rest = &rest[start + 4 + end + 3..],
None => {
rest = "";
break;
}
}
}
out.push_str(rest);
out
}
/// If `line` is a `# Title:` / `# Slug:` metadata line, return which one and its
/// value. Matches a single leading `#` followed by whitespace, case-insensitively.
fn meta_line(line: &str) -> Option<(Meta, String)> {
let after_hash = line.trim_start().strip_prefix('#')?;
if !after_hash.starts_with(char::is_whitespace) {
return None;
}
let rest = after_hash.trim_start();
let lower = rest.to_ascii_lowercase();
for (key, meta) in [("title:", Meta::Title), ("slug:", Meta::Slug)] {
if lower.starts_with(key) {
return Some((meta, rest[key.len()..].trim().to_string()));
}
}
None
}
/// Pre-process one markdown document, splitting header metadata from the body.
/// `marker` is the header/body separator line (e.g. `### Rough Draft:`); an empty
/// marker disables header-block splitting.
pub fn parse(markdown: &str, marker: &str) -> Header {
let cleaned = strip_comments(markdown);
let lines: Vec<&str> = cleaned.lines().collect();
let marker_norm = marker.trim().to_ascii_lowercase();
let marker_idx = if marker_norm.is_empty() {
None
} else {
lines
.iter()
.position(|l| l.trim().to_ascii_lowercase().starts_with(&marker_norm))
};
let mut title = None;
let mut slug = None;
let mut set = |meta: Meta, value: String| match meta {
Meta::Title => title = Some(value),
Meta::Slug => slug = Some(value),
};
let body_lines: Vec<&str> = match marker_idx {
Some(idx) => {
// Header is everything before the marker; parse metadata from it.
for l in &lines[..idx] {
if let Some((m, v)) = meta_line(l) {
set(m, v);
}
}
// Body is everything after the marker, minus any stray metadata lines.
lines[idx + 1..]
.iter()
.copied()
.filter(|l| meta_line(l).is_none())
.collect()
}
None => {
// No marker: keep all prose, but lift out metadata lines anywhere.
let mut kept = Vec::new();
for l in &lines {
if let Some((m, v)) = meta_line(l) {
set(m, v);
} else {
kept.push(*l);
}
}
kept
}
};
Header {
title: title.filter(|s| !s.is_empty()),
slug: slug.filter(|s| !s.is_empty()),
body: body_lines.join("\n").trim().to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const MARKER: &str = "### Rough Draft:";
#[test]
fn strips_inline_and_multiline_comments() {
let s = "before <!-- note --> after\n<!--\nblock\ncomment\n-->\nend";
let out = strip_comments(s);
assert!(!out.contains("note"));
assert!(!out.contains("block"));
assert!(out.contains("before after"));
assert!(out.contains("end"));
}
#[test]
fn unterminated_comment_drops_tail() {
let out = strip_comments("keep <!-- oops never closed");
assert_eq!(out, "keep ");
}
#[test]
fn splits_header_at_marker() {
let md = "# Title: The Gate\n# Slug: an opening\n<!-- outline -->\nnotes to self\n### Rough Draft:\n\nThe prose begins here.\n\n## A scene";
let h = parse(md, MARKER);
assert_eq!(h.title.as_deref(), Some("The Gate"));
assert_eq!(h.slug.as_deref(), Some("an opening"));
assert!(h.body.starts_with("The prose begins here."));
assert!(h.body.contains("## A scene"));
assert!(!h.body.contains("notes to self"));
assert!(!h.body.contains("outline"));
assert!(!h.body.contains("Title:"));
}
#[test]
fn without_marker_keeps_body_but_lifts_metadata() {
let md = "# Title: Solo\n\nJust prose, no marker.";
let h = parse(md, MARKER);
assert_eq!(h.title.as_deref(), Some("Solo"));
assert_eq!(h.slug, None);
assert_eq!(h.body, "Just prose, no marker.");
}
#[test]
fn real_heading_is_not_mistaken_for_metadata() {
let md = "### Rough Draft:\n\n# Chapter One\n\nText.";
let h = parse(md, MARKER);
assert!(h.body.contains("# Chapter One"));
}
}