Page-break between chapters and numeric title fallback

Every chapter after the first now starts on a new page via a
Chapter_20_Break heading style (fo:break-before="page"); the first
chapter keeps the plain heading so there is no blank leading page.

Chapter titles now resolve as: manual override > "# Title:" header >
the chapter's 1-based position in the left pane (e.g. "3."). The old
"# heading" / filename fallbacks are replaced by this numbering, and the
same logic drives the chapter-title hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
landon
2026-07-26 14:51:21 -05:00
parent 7271e5e2ef
commit 6680c001bf
3 changed files with 81 additions and 52 deletions
+5 -4
View File
@@ -67,13 +67,14 @@ Terminal=false
to write to disk. Drag the `` handles to reorder. to write to disk. Drag the `` handles to reorder.
4. Set the **Chapter title** for the open file if you want to override the 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 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 blank falls back to a `# Title:` line in the header, and if there is none, to
(`# ...`), then the file name. Titles are saved to `titles.json` and sync with the chapter's **position in the left pane** followed by a period (`1.`, `2.`,
git. …). 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 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 native save dialog) and press **Export ODT** to produce the concatenated
document. Each file becomes a chapter headed by its title, optionally followed 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 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 words added (or removed) to it since the current session started. The count
+48 -45
View File
@@ -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) { fn export_odt(&mut self) {
self.save_current(); self.save_current();
let marker = self.config.draft_marker.clone(); let marker = self.config.draft_marker.clone();
let mut chapters = Vec::new(); 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(); let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
// Strip comments + the editorial header, and lift out any // Strip comments + the editorial header, and lift out any
// `# Title:` / `# Slug:` metadata. // `# Title:` / `# Slug:` metadata.
let header = crate::preprocess::parse(&raw, &marker); let header = crate::preprocess::parse(&raw, &marker);
// Title priority: manual override > `# Title:` metadata > the body's // Title priority: manual override > `# Title:` metadata > the chapter's
// leading `# heading` (consumed) > file name. When a title comes from // 1-based position (e.g. "3."). The body from `parse` is used as-is.
// metadata/override, the body is left intact; otherwise the leading let title = resolve_chapter_title(
// `# heading` is consumed into the title slot to avoid duplication. self.titles.get(name).map(String::as_str),
let (title, body) = if let Some(t) = &header.title { header.title.as_deref(),
(t.clone(), header.body.clone()) i,
} 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);
chapters.push(Chapter { chapters.push(Chapter {
title, title,
slug: header.slug.clone(), slug: header.slug.clone(),
markdown: body, markdown: header.body,
}); });
} }
let out = PathBuf::from(self.export_input.trim()); let out = PathBuf::from(self.export_input.trim());
@@ -645,7 +644,7 @@ impl App {
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Chapter title:"); ui.label("Chapter title:");
let auto = split_title(&self.buffer, &name).0; let auto = self.auto_title(idx, &self.buffer);
let resp = ui.add( let resp = ui.add(
egui::TextEdit::singleline(&mut self.title_input) egui::TextEdit::singleline(&mut self.title_input)
.hint_text(format!("auto: {auto}")) .hint_text(format!("auto: {auto}"))
@@ -815,32 +814,19 @@ fn render_preview(ui: &mut egui::Ui, markdown: &str) {
flush(ui, &mut line, &mut heading); flush(ui, &mut line, &mut heading);
} }
/// Extract the chapter title from a markdown document: use the first level-1 /// Resolve a chapter's title: a non-empty manual `override_title` wins, then the
/// heading if the document starts with one (and drop it from the body so it is /// `# Title:` header value, otherwise the chapter's 1-based position (e.g. "3.").
/// not duplicated), otherwise fall back to the file name stem. fn resolve_chapter_title(
fn split_title(markdown: &str, filename: &str) -> (String, String) { override_title: Option<&str>,
let stem = Path::new(filename) header_title: Option<&str>,
.file_stem() index: usize,
.and_then(|s| s.to_str()) ) -> String {
.unwrap_or(filename) override_title
.to_string(); .map(str::trim)
.filter(|t| !t.is_empty())
let lines: Vec<&str> = markdown.lines().collect(); .or_else(|| header_title.map(str::trim).filter(|t| !t.is_empty()))
let mut idx = 0; .map(|t| t.to_string())
while idx < lines.len() && lines[idx].trim().is_empty() { .unwrap_or_else(|| format!("{}.", index + 1))
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())
} }
/// Count words in a string: runs of non-whitespace separated by whitespace. /// 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); 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] #[test]
fn formats_thousands_separators() { fn formats_thousands_separators() {
assert_eq!(thousands(0), "0"); assert_eq!(thousands(0), "0");
+28 -3
View File
@@ -389,6 +389,9 @@ fn styles_xml() -> String {
<style:text-properties fo:font-size="14pt" fo:font-weight="bold"/> <style:text-properties fo:font-size="14pt" fo:font-weight="bold"/>
</style:style> </style:style>
{headings} {headings}
<style:style style:name="Chapter_20_Break" style:display-name="Chapter Break" style:family="paragraph" style:parent-style-name="Heading_20_1" style:next-style-name="Text_20_body" style:default-outline-level="1">
<style:paragraph-properties fo:break-before="page"/>
</style:style>
<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: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: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:text-properties fo:font-style="italic" fo:font-size="12pt" fo:color="#666666"/>
@@ -447,11 +450,17 @@ fn styles_xml() -> String {
fn content_xml(chapters: &[Chapter]) -> String { fn content_xml(chapters: &[Chapter]) -> String {
let mut body = String::new(); 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 // 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!( body.push_str(&format!(
"<text:h text:style-name=\"Heading_20_1\" text:outline-level=\"1\">{}</text:h>", "<text:h text:style-name=\"{heading_style}\" text:outline-level=\"1\">{}</text:h>",
esc(&chapter.title) esc(&chapter.title)
)); ));
if let Some(slug) = chapter.slug.as_deref().map(str::trim).filter(|s| !s.is_empty()) { if let Some(slug) = chapter.slug.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
@@ -593,10 +602,26 @@ Done.";
.unwrap(); .unwrap();
assert!(content.contains(">Chapter One</text:h>")); assert!(content.contains(">Chapter One</text:h>"));
assert!(content.contains(">Chapter Two</text:h>")); assert!(content.contains(">Chapter Two</text:h>"));
// The first chapter uses the plain heading; later chapters get a page break.
assert!(content.contains(
"<text:h text:style-name=\"Heading_20_1\" text:outline-level=\"1\">Chapter One</text:h>"
));
assert!(content.contains(
"<text:h text:style-name=\"Chapter_20_Break\" text:outline-level=\"1\">Chapter Two</text:h>"
));
// The slug renders as an italic caption paragraph under the first chapter. // The slug renders as an italic caption paragraph under the first chapter.
assert!(content.contains( assert!(content.contains(
"<text:p text:style-name=\"Chapter_20_Caption\">\ "<text:p text:style-name=\"Chapter_20_Caption\">\
<text:span text:style-name=\"T_italic\">the opening</text:span></text:p>" <text:span text:style-name=\"T_italic\">the opening</text:span></text:p>"
)); ));
// 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\""));
} }
} }