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.
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
+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) {
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");
+28 -3
View File
@@ -389,6 +389,9 @@ fn styles_xml() -> String {
<style:text-properties fo:font-size="14pt" fo:font-weight="bold"/>
</style:style>
{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: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"/>
@@ -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!(
"<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)
));
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</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.
assert!(content.contains(
"<text:p text:style-name=\"Chapter_20_Caption\">\
<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\""));
}
}