diff --git a/README.md b/README.md
index bf56adc..7c376ac 100644
--- a/README.md
+++ b/README.md
@@ -67,13 +67,14 @@ 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 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.
+ blank falls back to a `# Title:` line in the header, and if there is none, to
+ the chapter's **position in the left pane** followed by a period (`1.`, `2.`,
+ …). Titles are saved to `titles.json` and sync with git.
5. Set the **Export** path (type it, or click **📂** to choose the `.odt` file in a
native save dialog) and press **Export ODT** to produce the concatenated
document. Each file becomes a chapter headed by its title, optionally followed
- by a caption from its `# Slug:` line.
+ by a caption from its `# Slug:` line. **Every chapter starts on a new page**
+ (the first chapter excepted, so there is no blank leading page).
The status bar shows a live **word count** for the current file, plus the net
words added (or removed) to it since the current session started. The count
diff --git a/src/app.rs b/src/app.rs
index 30f658b..074a7a3 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -294,36 +294,35 @@ impl App {
};
}
+ /// The title a chapter at `idx` would get without a manual override: its
+ /// `# Title:` header, or its 1-based position (e.g. "3."). Shown as the hint
+ /// in the chapter-title field.
+ fn auto_title(&self, idx: usize, markdown: &str) -> String {
+ let header = crate::preprocess::parse(markdown, &self.config.draft_marker);
+ resolve_chapter_title(None, header.title.as_deref(), idx)
+ }
+
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 {
+ for (i, name) in self.files.iter().enumerate() {
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(title);
+ // Title priority: manual override > `# Title:` metadata > the chapter's
+ // 1-based position (e.g. "3."). The body from `parse` is used as-is.
+ let title = resolve_chapter_title(
+ self.titles.get(name).map(String::as_str),
+ header.title.as_deref(),
+ i,
+ );
chapters.push(Chapter {
title,
slug: header.slug.clone(),
- markdown: body,
+ markdown: header.body,
});
}
let out = PathBuf::from(self.export_input.trim());
@@ -645,7 +644,7 @@ impl App {
});
ui.horizontal(|ui| {
ui.label("Chapter title:");
- let auto = split_title(&self.buffer, &name).0;
+ let auto = self.auto_title(idx, &self.buffer);
let resp = ui.add(
egui::TextEdit::singleline(&mut self.title_input)
.hint_text(format!("auto: {auto}"))
@@ -815,32 +814,19 @@ fn render_preview(ui: &mut egui::Ui, markdown: &str) {
flush(ui, &mut line, &mut heading);
}
-/// Extract the chapter title from a markdown document: use the first level-1
-/// heading if the document starts with one (and drop it from the body so it is
-/// not duplicated), otherwise fall back to the file name stem.
-fn split_title(markdown: &str, filename: &str) -> (String, String) {
- let stem = Path::new(filename)
- .file_stem()
- .and_then(|s| s.to_str())
- .unwrap_or(filename)
- .to_string();
-
- let lines: Vec<&str> = markdown.lines().collect();
- let mut idx = 0;
- while idx < lines.len() && lines[idx].trim().is_empty() {
- idx += 1;
- }
- if idx < lines.len() {
- let l = lines[idx].trim_start();
- if let Some(rest) = l.strip_prefix("# ") {
- let title = rest.trim().to_string();
- let mut body_lines: Vec<&str> = Vec::new();
- body_lines.extend_from_slice(&lines[..idx]);
- body_lines.extend_from_slice(&lines[idx + 1..]);
- return (title, body_lines.join("\n"));
- }
- }
- (stem, markdown.to_string())
+/// Resolve a chapter's title: a non-empty manual `override_title` wins, then the
+/// `# Title:` header value, otherwise the chapter's 1-based position (e.g. "3.").
+fn resolve_chapter_title(
+ override_title: Option<&str>,
+ header_title: Option<&str>,
+ index: usize,
+) -> String {
+ override_title
+ .map(str::trim)
+ .filter(|t| !t.is_empty())
+ .or_else(|| header_title.map(str::trim).filter(|t| !t.is_empty()))
+ .map(|t| t.to_string())
+ .unwrap_or_else(|| format!("{}.", index + 1))
}
/// Count words in a string: runs of non-whitespace separated by whitespace.
@@ -885,6 +871,23 @@ mod tests {
assert_eq!(count_words(" spread \n over\tlines "), 3);
}
+ #[test]
+ fn chapter_title_falls_back_to_position() {
+ // No override, no header title -> 1-based index with a period.
+ assert_eq!(resolve_chapter_title(None, None, 0), "1.");
+ assert_eq!(resolve_chapter_title(None, None, 2), "3.");
+ // Header title is used when present.
+ assert_eq!(resolve_chapter_title(None, Some("The Gate"), 4), "The Gate");
+ // Override wins over everything, even a header title.
+ assert_eq!(
+ resolve_chapter_title(Some("My Override"), Some("The Gate"), 4),
+ "My Override"
+ );
+ // Blank/whitespace override or header title are ignored.
+ assert_eq!(resolve_chapter_title(Some(" "), None, 1), "2.");
+ assert_eq!(resolve_chapter_title(Some(""), Some(" "), 6), "7.");
+ }
+
#[test]
fn formats_thousands_separators() {
assert_eq!(thousands(0), "0");
diff --git a/src/odt.rs b/src/odt.rs
index 613fac1..349889c 100644
--- a/src/odt.rs
+++ b/src/odt.rs
@@ -389,6 +389,9 @@ fn styles_xml() -> String {
{headings}
+
+
+
@@ -447,11 +450,17 @@ fn styles_xml() -> String {
fn content_xml(chapters: &[Chapter]) -> String {
let mut body = String::new();
- for chapter in chapters {
+ 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.
+ // 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!(
- "{}",
+ "{}",
esc(&chapter.title)
));
if let Some(slug) = chapter.slug.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
@@ -593,10 +602,26 @@ Done.";
.unwrap();
assert!(content.contains(">Chapter One"));
assert!(content.contains(">Chapter Two"));
+ // The first chapter uses the plain heading; later chapters get a page break.
+ 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"
));
+
+ // 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\""));
}
}