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\"")); } }