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:
@@ -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
@@ -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
@@ -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\""));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user