From 507e0ab724e0ee59310a4d07224f7be5a5a89291 Mon Sep 17 00:00:00 2001 From: landon Date: Sat, 25 Jul 2026 19:01:45 -0500 Subject: [PATCH] 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 --- README.md | 40 +++++++++-- src/app.rs | 36 ++++++++-- src/config.rs | 11 +++ src/main.rs | 1 + src/odt.rs | 22 ++++++ src/preprocess.rs | 178 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 src/preprocess.rs diff --git a/README.md b/README.md index 436c26c..18534c1 100644 --- a/README.md +++ b/README.md @@ -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 + +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 diff --git a/src/app.rs b/src/app.rs index f47424c..bb05fe6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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); }); diff --git a/src/config.rs b/src/config.rs index 30209c4..82209dd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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(), } } } diff --git a/src/main.rs b/src/main.rs index 841bdf7..89b3f1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod config; mod gitsync; mod odt; mod order; +mod preprocess; use eframe::egui; diff --git a/src/odt.rs b/src/odt.rs index 25b207f..613fac1 100644 --- a/src/odt.rs +++ b/src/odt.rs @@ -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, pub markdown: String, } @@ -387,6 +389,10 @@ fn styles_xml() -> String { {headings} + + + + @@ -448,6 +454,15 @@ fn content_xml(chapters: &[Chapter]) -> String { "{}", 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)); } @@ -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")); assert!(content.contains(">Chapter Two")); + // The slug renders as an italic caption paragraph under the first chapter. + assert!(content.contains( + "\ + the opening" + )); } } diff --git a/src/preprocess.rs b/src/preprocess.rs new file mode 100644 index 0000000..886a6e3 --- /dev/null +++ b/src/preprocess.rs @@ -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, + /// Value of the `# Slug:` line, if present and non-empty. + pub slug: Option, + /// 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 ``), +/// including comments that span multiple lines. An unterminated comment drops +/// everything from `") { + 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 after\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 \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")); + } +}