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:
@@ -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
@@ -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,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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ mod config;
|
||||
mod gitsync;
|
||||
mod odt;
|
||||
mod order;
|
||||
mod preprocess;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
|
||||
+22
@@ -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>"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user