Add title pages, standard manuscript format and collapsible panels

Five changes to the export and the window chrome:

* Manuscript details gains a contact field and a "Begin exports with a
  title page" option: the title, the author beneath it, and the contact
  details beneath that, laid out line for line as typed. A chapters +
  master export puts the title page on the master, not on each chapter.

* "Standard manuscript format" lays an export out the way an agent or an
  editor expects a submission: 12pt Courier, double-spaced, half-inch
  first-line indents, a Surname / Title / page header on every page but
  the title page, chapters opening a third of the way down, and `---`
  rendered as the conventional centred `#` scene break. The title page
  becomes the submission kind, with contact top-left and an approximate
  word count top-right. Off by default; margins were already the
  standard 1in on US Letter and are unchanged either way.

* A folder button beside Export ODT opens the project folder in the file
  manager -- the enclosing git work tree, including one still awaiting
  confirmation, since showing a folder is a smaller question than
  choosing which repository to commit to.

* The toolbar and the file list each collapse, from the pair of buttons
  at the right of the menu bar or from the View menu, and Ctrl+D folds
  both away together for distraction-free writing. Both choices persist.

* The editor sizes to its viewport instead of a fixed 30 rows, so the
  height a folded panel gives back reaches the page rather than leaving
  grey space below the text box that did not even take focus.

Two traps worth recording. ODF's style:master-page-name is inherited,
and any paragraph style carrying one forces a page break before every
paragraph that uses it -- a four-line contact block came out as four
pages until Contact_20_Line stopped inheriting from Contact_20_Block;
a test now pins which styles may carry one. And the status bar shares
a function with the toolbar, so guarding that function's top rather
than the top panel alone silently took the word counts away with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYSGPDwkSzhm4qLqjCbqxU
This commit is contained in:
2026-08-25 17:58:59 -05:00
parent 66d3f9817e
commit f35397b49b
9 changed files with 1038 additions and 58 deletions
+61 -5
View File
@@ -230,6 +230,29 @@ so a defaulted title in `part-2/` continues counting from `part-1/`.
A manuscript project is more than its chapters. These read the rest of it. A manuscript project is more than its chapters. These read the rest of it.
### Distraction-free writing (Ctrl+D)
**Ctrl+D** folds away everything that is not the page — the toolbar and the file
list together — leaving the menu bar, the editor and the status line. Press it
again to bring both back. It is also **View ▸ 🖹 Distraction-free**.
The two panels toggle separately as well, from the pair of buttons at the right
of the menu bar or from **View ▸ Toolbar** and **View ▸ File list**:
| | Hides | Gives back |
|---|---|---|
| **⬆** / **⬇** | the Workspace, Export, Grammar and Spelling rows | about an inch of height |
| **⬅** / **➡** | the file list down the left | about 260px of width |
Both choices are remembered between runs, so the window opens the way you left
it. The toggles live in the menu bar rather than in the panels they hide, since
a control that hides a panel cannot sit inside it, and the menu bar is the one
strip that never goes away. The status bar is unaffected — the word counts stay
visible while you write.
The editor grows into whatever the panels give back rather than staying a fixed
height, so folding them is worth real writing space and not just grey margin.
### Hiding the header while you write ### Hiding the header while you write
A scene card's fields sit at the top of the same file as its prose, and once A scene card's fields sit at the top of the same file as its prose, and once
@@ -306,9 +329,42 @@ the **whole manuscript's total**. Reference files are not counted.
### Manuscript details (Settings ▸ Manuscript details…) ### Manuscript details (Settings ▸ Manuscript details…)
Title and author, written into exported `.odt` files as their document Title, author and contact details. The title and author are written into
properties — what a word processor shows under File ▸ Properties. A blank title exported `.odt` files as their document properties — what a word processor shows
uses the project folder's name. Exports previously carried no metadata at all. under File ▸ Properties. A blank title uses the project folder's name. Exports
previously carried no metadata at all.
**Standard manuscript format** lays the export out the way an agent or an editor
expects a submission to arrive, which is not a matter of taste: a monospaced,
double-spaced page is a predictable ~250 words, and the margins leave somewhere
for their notes to go. Turning it on gives you
- 12pt Courier (readers substitute a metric-compatible font where it is absent),
double-spaced, with half-inch first-line indents and no blank line between
paragraphs — a blank line would read as a scene break;
- `Surname / Title / page` flush right on every page but the title page, with
the numbering starting at 1 on the first page of text;
- chapters opening about a third of the way down their page;
- `---` in your markdown rendered as the conventional centred `#` scene break;
- a submission title page: contact details in the top-left corner, an
approximate word count opposite them in the top-right, and the title a third
of the way down with the byline beneath it.
Margins are 1 inch on all four sides of US Letter either way — that part was
already standard before the option existed.
**Begin exports with a title page** puts a page of its own at the front of every
export: the title, the author beneath it, and the contact details beneath that,
with the first chapter starting on the page after (in standard manuscript
format it takes the submission layout described above instead). The contact box
is free-form
and laid out line for line as typed, so an address, an email and a phone number
each land where you put them — including the blank line you leave between them.
The option is off by default, and an export with nothing to put on the page
skips it rather than opening on a blank sheet.
For a chapters-plus-master export the title page goes on the master, not on each
chapter file.
### Exporting chapter files (File ▸ Export chapters + master) ### Exporting chapter files (File ▸ Export chapters + master)
@@ -316,8 +372,8 @@ Writes one `.odt` per chapter into a `chapters/` folder, plus an `.odm` master
document that links them — the shape the template's `Full Text.odm` implies. document that links them — the shape the template's `Full Text.odm` implies.
**The per-chapter files are the reliable part**: they are ordinary documents and **The per-chapter files are the reliable part**: they are ordinary documents and
open anywhere. The master is a shell with a title page and one linked section open anywhere. The master is a shell with the title, the author (or the full
per chapter; LibreOffice does not follow those links when it opens the file, so title page, if you asked for one) and one linked section per chapter; LibreOffice does not follow those links when it opens the file, so
treat it as a starting point to relink rather than as the assembled book. For a treat it as a starting point to relink rather than as the assembled book. For a
single finished document, use **Export ODT**. single finished document, use **Export ODT**.
+10 -1
View File
@@ -82,11 +82,20 @@ impl App {
self.apply_field_completion(ui.ctx(), &field); self.apply_field_completion(ui.ctx(), &field);
} }
// Fill the viewport rather than a fixed number of rows, so the
// height handed back by a folded toolbar or file list reaches
// the page instead of leaving grey space under the editor.
// Measured against the *unzoomed* row height because that is
// what `desired_rows` is multiplied by, whatever the layouter
// then draws at.
let row_h = ui.text_style_height(&egui::TextStyle::Monospace).max(1.0);
let rows = (ui.available_height() / row_h).floor().max(10.0) as usize;
let output = egui::TextEdit::multiline(&mut self.buffer) let output = egui::TextEdit::multiline(&mut self.buffer)
.id(egui::Id::new(EDITOR_ID)) .id(egui::Id::new(EDITOR_ID))
.code_editor() .code_editor()
.desired_width(f32::INFINITY) .desired_width(f32::INFINITY)
.desired_rows(30) .desired_rows(rows)
.layouter(&mut layouter) .layouter(&mut layouter)
.show(ui); .show(ui);
if output.response.changed() { if output.response.changed() {
+6 -1
View File
@@ -105,7 +105,12 @@ pub(super) fn build_rows(files: &[String], collapsed: &HashSet<String>) -> Vec<R
} }
impl App { impl App {
/// The file list down the left-hand side.
///
/// Collapses through `show_animated`, which gives the width back to the
/// editor rather than leaving an empty column.
pub(super) fn left_pane(&mut self, ctx: &egui::Context) { pub(super) fn left_pane(&mut self, ctx: &egui::Context) {
let show_files = self.config.show_file_panel;
egui::SidePanel::left("files") egui::SidePanel::left("files")
.resizable(true) .resizable(true)
.default_width(260.0) .default_width(260.0)
@@ -113,7 +118,7 @@ impl App {
// that asks for more width than there is would otherwise push it // that asks for more width than there is would otherwise push it
// wider every frame. // wider every frame.
.width_range(160.0..=460.0) .width_range(160.0..=460.0)
.show(ctx, |ui| { .show_animated(ctx, show_files, |ui| {
// The default theme renders unselected list rows fairly dim; bump // The default theme renders unselected list rows fairly dim; bump
// the widget text colours so file names stay legible (especially in // the widget text colours so file names stay legible (especially in
// dark mode) without affecting the rest of the app. // dark mode) without affecting the rest of the app.
+5
View File
@@ -443,6 +443,11 @@ impl eframe::App for App {
self.save_current(); self.save_current();
} }
// Ctrl+D folds the panels away for distraction-free writing, and back.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::D)) {
self.toggle_distraction_free();
}
// Ctrl+F opens find (search focused); Ctrl+H opens it focused on replace. // Ctrl+F opens find (search focused); Ctrl+H opens it focused on replace.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F)) { if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F)) {
self.open_find(true); self.open_find(true);
+43 -4
View File
@@ -280,7 +280,8 @@ impl App {
} }
} }
/// Title and author written into exported documents. /// Title, author and contact details written into exported documents, plus
/// whether an export opens with a title page carrying them.
pub(super) fn manuscript_settings_window(&mut self, ctx: &egui::Context) { pub(super) fn manuscript_settings_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_manuscript_settings; let mut open = self.show_manuscript_settings;
let mut close = false; let mut close = false;
@@ -320,12 +321,50 @@ impl App {
); );
save_now |= r.lost_focus(); save_now |= r.lost_focus();
ui.end_row(); ui.end_row();
ui.label("Contact:");
let r = ui
.add(
egui::TextEdit::multiline(&mut self.config.manuscript_contact)
.hint_text("Address, email, phone — a line each")
.desired_width(240.0)
.desired_rows(4),
)
.on_hover_text(
"Shown under your name on the title page, laid out \
line for line as typed",
);
save_now |= r.lost_focus();
ui.end_row();
}); });
ui.separator();
let r = ui
.checkbox(
&mut self.config.manuscript_title_page,
"Begin exports with a title page",
)
.on_hover_text(
"A page of its own carrying the title, the author and the \
contact details, with the first chapter starting after it",
);
save_now |= r.changed();
let r = ui
.checkbox(
&mut self.config.manuscript_standard_format,
"Standard manuscript format",
)
.on_hover_text(
"What an agent or editor expects a submission in: 12pt \
Courier, double-spaced, half-inch paragraph indents, a \
Surname / Title / page header, chapters opening a third \
of the way down, and # for a scene break",
);
save_now |= r.changed();
ui.label( ui.label(
egui::RichText::new( egui::RichText::new(
"Written into exported .odt files as their document \ "The title and author are also written into exported .odt \
properties, which is what a word processor shows under \ files as their document properties, which is what a word \
File Properties.", processor shows under File Properties.",
) )
.small() .small()
.weak(), .weak(),
+88 -1
View File
@@ -6,8 +6,15 @@ use super::*;
impl App { impl App {
// ---- UI ---------------------------------------------------------------- // ---- UI ----------------------------------------------------------------
/// The toolbar — the Workspace, Export, Grammar and Spelling rows — and the
/// status bar along the bottom, which is rendered here too.
///
/// The toolbar collapses through `show_animated`, which reserves no height
/// when hidden and slides rather than snapping. Note that it guards only the
/// top panel: the status bar shares this function and must keep drawing.
pub(super) fn top_bar(&mut self, ctx: &egui::Context) { pub(super) fn top_bar(&mut self, ctx: &egui::Context) {
egui::TopBottomPanel::top("top").show(ctx, |ui| { let show_toolbar = self.config.show_toolbar;
egui::TopBottomPanel::top("top").show_animated(ctx, show_toolbar, |ui| {
ui.add_space(4.0); ui.add_space(4.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Workspace:"); ui.label("Workspace:");
@@ -56,6 +63,16 @@ impl App {
if ui.button("Export ODT").clicked() { if ui.button("Export ODT").clicked() {
self.export_odt(); self.export_odt();
} }
// Resolved first: the hover text borrows the path, and the click
// handler needs `self` mutably.
let project = self.project_root().display().to_string();
if ui
.button("📁")
.on_hover_text(format!("Open the project folder {project}"))
.clicked()
{
self.open_project_folder();
}
ui.separator(); ui.separator();
if ui if ui
.checkbox(&mut self.config.show_preview, "Preview") .checkbox(&mut self.config.show_preview, "Preview")
@@ -310,6 +327,35 @@ impl App {
} }
}); });
ui.menu_button("View", |ui| { ui.menu_button("View", |ui| {
if ui
.checkbox(&mut self.config.show_toolbar, "Toolbar")
.on_hover_text(
"The Workspace, Export, Grammar and Spelling rows \
under the menu bar",
)
.clicked()
{
self.config.save();
}
if ui
.checkbox(&mut self.config.show_file_panel, "File list")
.on_hover_text("The file tree down the left-hand side")
.clicked()
{
self.config.save();
}
if ui
.button("🖹 Distraction-free Ctrl+D")
.on_hover_text(
"Fold away the toolbar and the file list together, \
leaving nothing but the page. Again to bring them back.",
)
.clicked()
{
ui.close_menu();
self.toggle_distraction_free();
}
ui.separator();
if ui if ui
.checkbox(&mut self.config.show_preview, "Preview pane") .checkbox(&mut self.config.show_preview, "Preview pane")
.clicked() .clicked()
@@ -375,6 +421,30 @@ impl App {
self.show_cheatsheet = true; self.show_cheatsheet = true;
} }
}); });
// Right-aligned, and in the menu bar rather than the toolbar:
// a control that hides the toolbar cannot live inside it.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (icon, hint) = if self.config.show_toolbar {
("", "Hide the toolbar (Workspace, Export, Grammar, Spelling)")
} else {
("", "Show the toolbar (Workspace, Export, Grammar, Spelling)")
};
if ui.button(icon).on_hover_text(hint).clicked() {
self.config.show_toolbar = !self.config.show_toolbar;
self.config.save();
}
// Added second, so in a right-to-left layout it sits to the
// left of the toolbar toggle -- the side its panel is on.
let (icon, hint) = if self.config.show_file_panel {
("", "Hide the file list")
} else {
("", "Show the file list")
};
if ui.button(icon).on_hover_text(hint).clicked() {
self.config.show_file_panel = !self.config.show_file_panel;
self.config.save();
}
});
}); });
}); });
} }
@@ -535,6 +605,23 @@ impl App {
/// It exists so a hidden header can never be mistaken for a file that has /// It exists so a hidden header can never be mistaken for a file that has
/// none: the editor is showing less than the file holds, and that has to be /// none: the editor is showing less than the file holds, and that has to be
/// visible without opening a menu. /// visible without opening a menu.
/// Fold away everything that is not the page — the toolbar and the file
/// list — and put both back on the next invocation.
///
/// One command in and out, rather than a mode with a state of its own: if
/// any chrome is showing, hide it all; otherwise restore it all.
pub(super) fn toggle_distraction_free(&mut self) {
let any_chrome = self.config.show_toolbar || self.config.show_file_panel;
self.config.show_toolbar = !any_chrome;
self.config.show_file_panel = !any_chrome;
self.config.save();
self.status = if any_chrome {
"Distraction-free — Ctrl+D to bring the panels back".to_string()
} else {
"Panels restored".to_string()
};
}
pub(super) fn header_toggle(&mut self, ui: &mut egui::Ui) { pub(super) fn header_toggle(&mut self, ui: &mut egui::Ui) {
let collapsed = self.header_stash.is_some(); let collapsed = self.header_stash.is_some();
// Nothing to say about a file with no marker to fold at. // Nothing to say about a file with no marker to fold at.
+103 -2
View File
@@ -9,6 +9,55 @@ impl App {
&self.config.workspace &self.config.workspace
} }
/// The project folder: the root holding the characters, the outline and the
/// reference material alongside the draft folder.
///
/// In project mode the workspace already *is* that root. Otherwise the
/// workspace is a folder of chapters one level down, and the enclosing git
/// work tree is the project around it — including one still awaiting the
/// user's confirmation, since which folder to show in a file manager is a
/// smaller question than which repository to commit to.
///
/// Falls back to the workspace when there is no repository, rather than
/// guessing at a parent: an unversioned folder of chapters has no project
/// around it that we can point to with any confidence. Reads only cached
/// state, because the toolbar asks for this every frame.
pub(super) fn project_root(&self) -> &Path {
project_root_of(
&self.config.workspace,
self.manuscript_dir.is_some(),
self.repo_root.as_deref(),
self.pending_repo.as_deref(),
)
}
/// Show the project folder in the desktop's file manager.
pub(super) fn open_project_folder(&mut self) {
let path = self.project_root().to_path_buf();
if !path.is_dir() {
self.status = format!("No such folder: {}", path.display());
return;
}
// xdg-open picks whichever file manager the session provides, the same
// desktop-portal assumption the native file dialogs already make.
let spawned = std::process::Command::new("xdg-open")
.arg(&path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
match spawned {
Ok(mut child) => {
// xdg-open hands off to the file manager and exits immediately.
// Reap it off-thread so each click does not leave a zombie behind.
std::thread::spawn(move || {
let _ = child.wait();
});
self.status = format!("Opened {}", path.display());
}
Err(e) => self.status = format!("Could not open {}: {e}", path.display()),
}
}
/// (Re)load the file list for the current workspace, creating the directory /// (Re)load the file list for the current workspace, creating the directory
/// if needed, and refresh git status. /// if needed, and refresh git status.
pub(super) fn open_workspace(&mut self) { pub(super) fn open_workspace(&mut self) {
@@ -656,8 +705,9 @@ impl App {
Some((goal, count_words(&header.body))) Some((goal, count_words(&header.body)))
} }
/// Document properties for an export: the configured title and author, with /// Document properties for an export: the configured title, author and
/// the title falling back to the project folder's own name. /// contact details, with the title falling back to the project folder's own
/// name.
pub(super) fn doc_meta(&self, chapters: &[Chapter]) -> odt::DocMeta { pub(super) fn doc_meta(&self, chapters: &[Chapter]) -> odt::DocMeta {
let title = match self.config.manuscript_title.trim() { let title = match self.config.manuscript_title.trim() {
"" => self "" => self
@@ -671,6 +721,9 @@ impl App {
odt::DocMeta { odt::DocMeta {
title, title,
author: self.config.manuscript_author.trim().to_string(), author: self.config.manuscript_author.trim().to_string(),
contact: self.config.manuscript_contact.clone(),
title_page: self.config.manuscript_title_page,
standard_format: self.config.manuscript_standard_format,
subject: String::new(), subject: String::new(),
keywords: String::new(), keywords: String::new(),
word_count: chapters.iter().map(|c| count_words(&c.markdown)).sum(), word_count: chapters.iter().map(|c| count_words(&c.markdown)).sum(),
@@ -956,6 +1009,22 @@ impl App {
/// open whatever sorts first across the whole project — a scratch-pad note, /// open whatever sorts first across the whole project — a scratch-pad note,
/// typically — and in a file the user cannot see in the tree. Reference files /// typically — and in a file the user cannot see in the tree. Reference files
/// are still the fallback, for a project whose manuscript folder is empty. /// are still the fallback, for a project whose manuscript folder is empty.
/// Which folder to treat as the project root, from state the app already holds.
///
/// Split out from [`App::project_root`] so the choice can be exercised without
/// standing up a whole `App`.
fn project_root_of<'a>(
workspace: &'a Path,
in_project_mode: bool,
repo_root: Option<&'a Path>,
pending_repo: Option<&'a Path>,
) -> &'a Path {
if in_project_mode {
return workspace;
}
repo_root.or(pending_repo).unwrap_or(workspace)
}
pub(super) fn first_listed(files: &[String], manuscript_dir: Option<&str>) -> Option<usize> { pub(super) fn first_listed(files: &[String], manuscript_dir: Option<&str>) -> Option<usize> {
let in_book = |name: &String| match manuscript_dir { let in_book = |name: &String| match manuscript_dir {
Some(dir) => is_within(name, dir), Some(dir) => is_within(name, dir),
@@ -1075,6 +1144,38 @@ pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &s
mod tests { mod tests {
use super::*; use super::*;
/// The folder the "open the project folder" button points at, across the
/// shapes a workspace can take.
#[test]
fn the_project_root_is_the_folder_the_manuscript_sits_in() {
let ws = Path::new("/books/My Book/06-First Draft");
let project = Path::new("/books/My Book");
// Project mode: the workspace already is the project root, so an
// enclosing repository must not pull the answer above it.
assert_eq!(
project_root_of(project, true, Some(Path::new("/books")), None),
project
);
// The draft folder opened on its own, inside an adopted repository.
assert_eq!(project_root_of(ws, false, Some(project), None), project);
// The same, while the repository is still awaiting confirmation:
// showing a folder is a smaller question than committing to it.
assert_eq!(project_root_of(ws, false, None, Some(project)), project);
// An adopted repository wins over a stale pending one.
assert_eq!(
project_root_of(ws, false, Some(project), Some(Path::new("/books"))),
project
);
// A plain, unversioned folder of chapters has no project around it, so
// the workspace stands in rather than a guessed-at parent.
assert_eq!(project_root_of(ws, false, None, None), ws);
}
#[test] #[test]
fn untitled_name_fills_the_first_free_slot() { fn untitled_name_fills_the_first_free_slot() {
assert_eq!(next_untitled_name(|_| false), "untitled-1.md"); assert_eq!(next_untitled_name(|_| false), "untitled-1.md");
+35
View File
@@ -11,6 +11,15 @@ pub struct Config {
/// Whether to show the live preview pane. /// Whether to show the live preview pane.
#[serde(default)] #[serde(default)]
pub show_preview: bool, pub show_preview: bool,
/// Whether the toolbar under the menu bar — the Workspace, Export, Grammar
/// and Spelling rows — is shown. Hiding it hands four rows of height back to
/// the editor; the menu bar keeps a toggle so it can be brought back.
#[serde(default = "default_show_toolbar")]
pub show_toolbar: bool,
/// Whether the left-hand file list is shown. Hiding it, with the toolbar,
/// leaves nothing on screen but the page.
#[serde(default = "default_show_toolbar")]
pub show_file_panel: bool,
/// Whether the editor hides the editorial header, showing the prose alone. /// Whether the editor hides the editorial header, showing the prose alone.
#[serde(default)] #[serde(default)]
pub collapse_header: bool, pub collapse_header: bool,
@@ -127,6 +136,27 @@ pub struct Config {
/// Author written into exported documents. /// Author written into exported documents.
#[serde(default)] #[serde(default)]
pub manuscript_author: String, pub manuscript_author: String,
/// How to reach the author: whatever belongs under their name on a title
/// page — an address, an email, a phone number, an agent. Written a line
/// per line, so the layout typed here is the layout that is exported.
#[serde(default)]
pub manuscript_contact: String,
/// Whether an export opens with a title page carrying the title, the author
/// and their contact details.
#[serde(default)]
pub manuscript_title_page: bool,
/// Whether exports are laid out in standard manuscript format — 12pt
/// Courier, double-spaced, with a running header — which is the shape an
/// agent or editor expects a submission in.
#[serde(default)]
pub manuscript_standard_format: bool,
}
/// The toolbar and the file list are what the window opens with; hiding either
/// is a deliberate act, and a config written before the options existed should
/// not start hidden.
pub fn default_show_toolbar() -> bool {
true
} }
/// Default cookiecutter template for **File ▸ New project…**. /// Default cookiecutter template for **File ▸ New project…**.
@@ -237,6 +267,8 @@ impl Default for Config {
workspace, workspace,
export_path, export_path,
show_preview: false, show_preview: false,
show_toolbar: default_show_toolbar(),
show_file_panel: default_show_toolbar(),
collapse_header: false, collapse_header: false,
show_reference_files: false, show_reference_files: false,
draft_marker: default_marker(), draft_marker: default_marker(),
@@ -267,6 +299,9 @@ impl Default for Config {
archive_folder: default_archive_folder(), archive_folder: default_archive_folder(),
manuscript_title: String::new(), manuscript_title: String::new(),
manuscript_author: String::new(), manuscript_author: String::new(),
manuscript_contact: String::new(),
manuscript_title_page: false,
manuscript_standard_format: false,
} }
} }
} }
+687 -44
View File
@@ -69,11 +69,15 @@ struct Converter {
code_block: Option<String>, code_block: Option<String>,
/// Heading level offset so document headings nest under the chapter title. /// Heading level offset so document headings nest under the chapter title.
heading_offset: u8, heading_offset: u8,
/// In standard manuscript format a `---` is a scene break — a centred `#` —
/// rather than a drawn rule.
standard_format: bool,
} }
impl Converter { impl Converter {
fn new(heading_offset: u8) -> Self { fn new(heading_offset: u8, standard_format: bool) -> Self {
Converter { Converter {
standard_format,
out: String::new(), out: String::new(),
block: Block::None, block: Block::None,
bold: false, bold: false,
@@ -252,8 +256,16 @@ impl Converter {
} }
Event::Rule => { Event::Rule => {
self.close_block(); self.close_block();
self.out // A rule between paragraphs of prose is a scene break. Drawn
.push_str("<text:p text:style-name=\"Horizontal_Line\"></text:p>"); // as a line normally; as the conventional centred hash when
// the manuscript is dressed for submission.
if self.standard_format {
self.out
.push_str("<text:p text:style-name=\"Scene_20_Break\">#</text:p>");
} else {
self.out
.push_str("<text:p text:style-name=\"Horizontal_Line\"></text:p>");
}
} }
Event::Html(_) | Event::InlineHtml(_) => { /* skip raw HTML */ } Event::Html(_) | Event::InlineHtml(_) => { /* skip raw HTML */ }
_ => {} _ => {}
@@ -341,8 +353,8 @@ impl Converter {
/// Convert one markdown document into an ODF body fragment, with headings shifted /// Convert one markdown document into an ODF body fragment, with headings shifted
/// down by `heading_offset` levels. /// down by `heading_offset` levels.
fn markdown_to_body(markdown: &str, heading_offset: u8) -> String { fn markdown_to_body(markdown: &str, heading_offset: u8, standard_format: bool) -> String {
Converter::new(heading_offset).run(markdown) Converter::new(heading_offset, standard_format).run(markdown)
} }
const MIMETYPE: &str = "application/vnd.oasis.opendocument.text"; const MIMETYPE: &str = "application/vnd.oasis.opendocument.text";
@@ -355,6 +367,17 @@ pub struct DocMeta {
pub author: String, pub author: String,
pub subject: String, pub subject: String,
pub keywords: String, pub keywords: String,
/// How to reach the author, shown on the title page beneath their name.
/// Free-form and written a line per line, so an address, an email and a
/// phone number each land where they were typed.
pub contact: String,
/// Whether the export opens with a title page carrying the title, the
/// author and their contact details.
pub title_page: bool,
/// Whether to lay the manuscript out in standard manuscript format: the
/// shape an agent or an editor expects a submission to arrive in. See
/// [`styles_xml`] for what that means concretely.
pub standard_format: bool,
/// Statistics a reader expects to be filled in. /// Statistics a reader expects to be filled in.
pub word_count: usize, pub word_count: usize,
pub chapter_count: usize, pub chapter_count: usize,
@@ -400,9 +423,56 @@ fn manifest_xml() -> String {
.to_string() .to_string()
} }
fn styles_xml() -> String { /// The author's surname, for the running header — the last whitespace-separated
// Heading sizes for the seven levels we may emit (chapter = 1). /// word of the author field, which is right for "Lane Wight" and for a single
let sizes = [18, 16, 14, 13, 12, 11, 11, 11, 11, 11]; /// name alike. A pen name with a suffix ("Lane Wight Jr.") is the author's own
/// business; they can set the field to whatever they want to see.
fn surname(author: &str) -> &str {
author.split_whitespace().next_back().unwrap_or("")
}
/// The word count as a submission states it: approximate, and rounded so it
/// reads as an estimate rather than a claim. Novels round to the nearest
/// thousand, shorter work to the nearest hundred.
fn approx_word_count(words: usize) -> String {
let step = if words >= 10_000 { 1_000 } else { 100 };
let rounded = ((words + step / 2) / step) * step;
let rounded = rounded.max(step);
// Thousands separators, since "About 90000 words" reads as a typo.
let digits = rounded.to_string();
let mut grouped = String::new();
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i) % 3 == 0 {
grouped.push(',');
}
grouped.push(c);
}
format!("About {grouped} words")
}
/// Named styles for the document.
///
/// Two layouts live here. The ordinary one is a readable book: proportional
/// text, sized headings, space between paragraphs.
///
/// Standard manuscript format ([`DocMeta::standard_format`]) is the other, and
/// is not a matter of taste — it is the shape an agent or editor expects, so
/// that a page is a predictable quantity of words and their notes have somewhere
/// to go. It means: 12pt Courier, double-spaced, half-inch first-line indents
/// with no blank line between paragraphs, a `Surname / Title / page` header on
/// every page but the title page, chapters opening a third of the way down, and
/// `#` for a scene break.
fn styles_xml(meta: &DocMeta) -> String {
let std_fmt = meta.standard_format;
// Heading sizes for the levels we may emit (chapter = 1). Standard format
// has no typographic hierarchy at all: everything is the same 12pt Courier.
let sizes: [u32; 10] = if std_fmt {
[12; 10]
} else {
[18, 16, 14, 13, 12, 11, 11, 11, 11, 11]
};
let weight = if std_fmt { "normal" } else { "bold" };
let mut headings = String::new(); let mut headings = String::new();
for (i, size) in sizes.iter().enumerate() { for (i, size) in sizes.iter().enumerate() {
let n = i + 1; let n = i + 1;
@@ -410,34 +480,204 @@ fn styles_xml() -> String {
"<style:style style:name=\"Heading_20_{n}\" style:display-name=\"Heading {n}\" \ "<style:style style:name=\"Heading_20_{n}\" style:display-name=\"Heading {n}\" \
style:family=\"paragraph\" style:parent-style-name=\"Heading\" \ style:family=\"paragraph\" style:parent-style-name=\"Heading\" \
style:next-style-name=\"Text_20_body\" style:default-outline-level=\"{n}\">\ style:next-style-name=\"Text_20_body\" style:default-outline-level=\"{n}\">\
<style:text-properties fo:font-size=\"{size}pt\" fo:font-weight=\"bold\"/>\ <style:text-properties fo:font-size=\"{size}pt\" fo:font-weight=\"{weight}\"/>\
</style:style>" </style:style>"
)); ));
} }
// The document's base text. Courier is the tradition, and the point of it is
// that it is monospaced: a page is then a reliable ~250 words.
let default_text = if std_fmt {
"<style:text-properties style:font-name=\"Courier New\" \
fo:font-family=\"'Courier New'\" style:font-family-generic=\"modern\" \
style:font-pitch=\"fixed\" fo:font-size=\"12pt\" fo:language=\"en\" fo:country=\"US\"/>"
} else {
"<style:text-properties fo:font-size=\"11pt\" fo:language=\"en\" fo:country=\"US\"/>"
};
// Double-spaced, indented, and with no space between paragraphs: the blank
// line that separates paragraphs in a book would read as a scene break here.
let body_para = if std_fmt {
"<style:paragraph-properties fo:margin-top=\"0in\" fo:margin-bottom=\"0in\" \
fo:text-indent=\"0.5in\" fo:line-height=\"200%\"/>"
} else {
"<style:paragraph-properties fo:margin-top=\"0in\" fo:margin-bottom=\"0.1in\"/>"
};
let heading_para = if std_fmt {
"<style:paragraph-properties fo:margin-top=\"0in\" fo:margin-bottom=\"0in\" \
fo:text-indent=\"0in\" fo:line-height=\"200%\" fo:text-align=\"center\" \
fo:keep-with-next=\"always\"/>\
<style:text-properties fo:font-size=\"12pt\" fo:font-weight=\"normal\"/>"
} else {
"<style:paragraph-properties fo:margin-top=\"0.2in\" fo:margin-bottom=\"0.08in\" \
fo:keep-with-next=\"always\"/>\
<style:text-properties fo:font-size=\"14pt\" fo:font-weight=\"bold\"/>"
};
// The slug caption is editorial, not part of the format; in standard format
// it is at least made to look like the rest of the manuscript.
let caption = if std_fmt {
"<style:paragraph-properties fo:margin-top=\"0in\" fo:margin-bottom=\"0in\" \
fo:text-indent=\"0in\" fo:line-height=\"200%\" fo:text-align=\"center\"/>\
<style:text-properties fo:font-size=\"12pt\"/>"
} else {
"<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\"/>"
};
// A chapter opens a third of the way down the page. The drop is the *bottom*
// margin of an empty paragraph rather than the heading's top margin, because
// a top margin at the head of a page is collapsed away by every word
// processor worth the name.
// Standard format puts the title page on a master of its own so the running
// header does not appear on it, and restarts the numbering at the first
// chapter. Without a title page the ordinary master is the first one and
// therefore already the default.
let (page_layouts, master_pages) = if std_fmt {
(
// 0.5in to the header, 0.5in from header to text: text lands at 1in,
// which is the margin that matters. The title page has no header, so
// it takes the plain 1in top margin instead.
r#" <style:page-layout style:name="pm1">
<style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="0.5in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in"/>
<style:header-style>
<style:header-footer-properties fo:min-height="0.25in" fo:margin-bottom="0.25in"/>
</style:header-style>
</style:page-layout>
<style:page-layout style:name="pm-title">
<style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in"/>
</style:page-layout>"#,
String::new(), // filled in below, since the header carries the text
)
} else {
(
r#" <style:page-layout style:name="pm1">
<style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in"/>
</style:page-layout>"#,
r#" <style:master-page style:name="Standard" style:page-layout-name="pm1"/>"#
.to_string(),
)
};
let master_pages = if std_fmt {
// Surname / Title / page number, flush right, on every page of text.
let head = format!(
"{} / {} / ",
esc(surname(meta.author.trim())),
esc(meta.title.trim())
);
format!(
r#" <style:master-page style:name="Standard" style:page-layout-name="pm1">
<style:header>
<text:p text:style-name="Running_20_Head">{head}<text:page-number text:select-page="current">1</text:page-number></text:p>
</style:header>
</style:master-page>
<style:master-page style:name="Title_20_Page" style:display-name="Title Page" style:page-layout-name="pm-title"/>"#
)
} else {
master_pages
};
// The title page. A book's is a display page: big, bold, centred. A
// submission's is a worksheet: everything at 12pt, the title sitting a third
// of the way down with the byline under it.
let title_styles = if std_fmt {
r#" <style:style style:name="Title_20_Page" style:display-name="Title Page" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:text-align="center" fo:text-indent="0in" fo:margin-top="2.5in" fo:margin-bottom="0in" fo:keep-with-next="always"/>
<style:text-properties fo:font-size="12pt"/>
</style:style>
<style:style style:name="Title_20_Page_20_Author" style:display-name="Title Page Author" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:text-align="center" fo:text-indent="0in" fo:margin-top="0.35in" fo:margin-bottom="0in"/>
<style:text-properties fo:font-size="12pt"/>
</style:style>
<style:style style:name="Title_20_Page_20_Contact" style:display-name="Title Page Contact" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:text-align="start" fo:text-indent="0in" fo:margin-top="0in" fo:margin-bottom="0in"/>
</style:style>"#
} else {
r#" <style:style style:name="Title_20_Page" style:display-name="Title Page" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:text-align="center" style:justify-single-word="false" fo:margin-top="2.5in" fo:margin-bottom="0.35in" fo:keep-with-next="always"/>
<style:text-properties fo:font-size="26pt" fo:font-weight="bold"/>
</style:style>
<style:style style:name="Title_20_Page_20_Author" style:display-name="Title Page Author" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:text-align="center" style:justify-single-word="false" fo:margin-top="0in" fo:margin-bottom="0.3in"/>
<style:text-properties fo:font-size="14pt"/>
</style:style>
<style:style style:name="Title_20_Page_20_Contact" style:display-name="Title Page Contact" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:text-align="center" style:justify-single-word="false" fo:margin-top="0in" fo:margin-bottom="0in"/>
<style:text-properties fo:font-size="11pt"/>
</style:style>"#
};
// Styles that only standard format uses. Emitting them unconditionally would
// put unused entries in every reader's style list.
//
// Careful with `style:master-page-name`: a paragraph style that carries one
// forces a page break before *every* paragraph using it, and the attribute
// is inherited. Only styles used exactly once may carry it, and nothing may
// inherit from them -- hence Contact_20_Line parenting to Standard rather
// than to Contact_20_Block, which it otherwise resembles.
let std_styles = if std_fmt {
r#" <style:style style:name="Running_20_Head" style:display-name="Running Head" style:family="paragraph" style:parent-style-name="Standard" style:class="extra">
<style:paragraph-properties fo:text-align="end" fo:margin-bottom="0in"/>
</style:style>
<style:style style:name="Scene_20_Break" style:display-name="Scene Break" style:family="paragraph" style:parent-style-name="Text_20_body" style:next-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:text-align="center" fo:text-indent="0in" fo:margin-top="0in" fo:margin-bottom="0in" fo:line-height="200%"/>
</style:style>
<style:style style:name="Chapter_20_Drop" style:display-name="Chapter Drop" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:break-before="page" fo:margin-top="0in" fo:margin-bottom="2.5in" fo:text-indent="0in"/>
<style:text-properties fo:font-size="12pt"/>
</style:style>
<style:style style:name="Chapter_20_Drop_20_First" style:display-name="Chapter Drop First" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:master-page-name="Standard" style:class="text">
<style:paragraph-properties style:page-number="1" fo:margin-top="0in" fo:margin-bottom="2.5in" fo:text-indent="0in"/>
<style:text-properties fo:font-size="12pt"/>
</style:style>
<style:style style:name="Chapter_20_Drop_20_Open" style:display-name="Chapter Drop Open" 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="2.5in" fo:text-indent="0in"/>
<style:text-properties fo:font-size="12pt"/>
</style:style>
<style:style style:name="Contact_20_Block" style:display-name="Contact Block" style:family="paragraph" style:parent-style-name="Standard" style:master-page-name="Title_20_Page" style:class="text">
<style:paragraph-properties fo:margin-top="0in" fo:margin-bottom="0in" fo:text-indent="0in" fo:text-align="start">
<style:tab-stops><style:tab-stop style:position="6.5in" style:type="right"/></style:tab-stops>
</style:paragraph-properties>
<style:text-properties fo:font-size="12pt"/>
</style:style>
<style:style style:name="Contact_20_Line" style:display-name="Contact Line" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:margin-top="0in" fo:margin-bottom="0in" fo:text-indent="0in" fo:text-align="start"/>
<style:text-properties fo:font-size="12pt"/>
</style:style>
"#
} else {
""
};
format!( format!(
r##"<?xml version="1.0" encoding="UTF-8"?> r##"<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.2"> <office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.2">
<office:styles> <office:styles>
<style:default-style style:family="paragraph"> <style:default-style style:family="paragraph">
<style:paragraph-properties fo:hyphenation-ladder-count="no-limit"/> <style:paragraph-properties fo:hyphenation-ladder-count="no-limit"/>
<style:text-properties fo:font-size="11pt" fo:language="en" fo:country="US"/> {default_text}
</style:default-style> </style:default-style>
<style:style style:name="Standard" style:family="paragraph" style:class="text"/> <style:style style:name="Standard" style:family="paragraph" style:class="text"/>
<style:style style:name="Text_20_body" style:display-name="Text body" style:family="paragraph" style:parent-style-name="Standard" style:class="text"> <style:style style:name="Text_20_body" style:display-name="Text body" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:margin-top="0in" fo:margin-bottom="0.1in"/> {body_para}
</style:style> </style:style>
<style:style style:name="Heading" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text"> <style:style style:name="Heading" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:margin-top="0.2in" fo:margin-bottom="0.08in" fo:keep-with-next="always"/> {heading_para}
<style:text-properties fo:font-size="14pt" fo:font-weight="bold"/>
</style:style> </style:style>
{headings} {headings}
{std_styles}
<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: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:paragraph-properties fo:break-before="page"/>
</style:style> </style:style>
{title_styles}
<style:style style:name="Page_20_Break" style:display-name="Page Break" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:break-before="page" fo:margin-top="0in" fo:margin-bottom="0in"/>
<style:text-properties fo:font-size="1pt"/>
</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"/> {caption}
<style:text-properties fo:font-style="italic" fo:font-size="12pt" fo:color="#666666"/>
</style:style> </style:style>
<style:style style:name="Quotations" style:family="paragraph" style:parent-style-name="Standard" style:class="html"> <style:style style:name="Quotations" style:family="paragraph" style:parent-style-name="Standard" style:class="html">
<style:paragraph-properties fo:margin-left="0.4in" fo:margin-right="0.4in" fo:margin-top="0.05in" fo:margin-bottom="0.05in"/> <style:paragraph-properties fo:margin-left="0.4in" fo:margin-right="0.4in" fo:margin-top="0.05in" fo:margin-bottom="0.05in"/>
@@ -480,32 +720,148 @@ fn styles_xml() -> String {
</text:list-style> </text:list-style>
</office:styles> </office:styles>
<office:automatic-styles> <office:automatic-styles>
<style:page-layout style:name="pm1"> {page_layouts}
<style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in"/>
</style:page-layout>
</office:automatic-styles> </office:automatic-styles>
<office:master-styles> <office:master-styles>
<style:master-page style:name="Standard" style:page-layout-name="pm1"/> {master_pages}
</office:master-styles> </office:master-styles>
</office:document-styles>"## </office:document-styles>"##
) )
} }
fn content_xml(chapters: &[Chapter]) -> String { /// The title page: the manuscript's title, the author's name beneath it, and
/// their contact details beneath that.
///
/// Returns an empty string when there is nothing to put on it, so an export with
/// no title, author or contact does not open on a blank sheet.
fn title_page_xml(meta: &DocMeta) -> String {
if meta.standard_format {
return standard_title_page_xml(meta);
}
let mut out = String::new();
let title = meta.title.trim();
if !title.is_empty() {
out.push_str(&format!(
"<text:p text:style-name=\"Title_20_Page\">{}</text:p>",
esc(title)
));
}
let author = meta.author.trim();
if !author.is_empty() {
out.push_str(&format!(
"<text:p text:style-name=\"Title_20_Page_20_Author\">{}</text:p>",
esc(author)
));
}
// A line per line, so the blank line an author leaves between their address
// and their email survives into the page. Leading and trailing blank lines
// are dropped; the ones in between are kept as empty paragraphs.
for line in meta.contact.trim().lines() {
let line = line.trim();
if line.is_empty() {
out.push_str("<text:p text:style-name=\"Title_20_Page_20_Contact\"/>");
} else {
out.push_str(&format!(
"<text:p text:style-name=\"Title_20_Page_20_Contact\">{}</text:p>",
esc(line)
));
}
}
out
}
/// The submission title page: contact details in the top-left corner, the word
/// count opposite them in the top-right, and the title a third of the way down
/// with the byline beneath it.
///
/// The contact block always leads, even when there is nothing to put in it,
/// because its style is what moves the page onto the header-less master.
fn standard_title_page_xml(meta: &DocMeta) -> String {
let title = meta.title.trim();
let author = meta.author.trim();
let contact = meta.contact.trim();
if title.is_empty() && author.is_empty() && contact.is_empty() {
return String::new();
}
// First line: the author's name, then a right-aligned tab to the word count.
let mut lines = contact.lines();
let first = lines.next().map(str::trim).unwrap_or("");
let lead = if first.is_empty() { author } else { first };
let mut out = format!(
"<text:p text:style-name=\"Contact_20_Block\">{}<text:tab/>{}</text:p>",
esc(lead),
esc(&approx_word_count(meta.word_count))
);
// If the contact block opened with something other than the author's name,
// the name still belongs in the corner, above the rest of the details.
for line in lines {
let line = line.trim();
if line.is_empty() {
out.push_str("<text:p text:style-name=\"Contact_20_Line\"/>");
} else {
out.push_str(&format!(
"<text:p text:style-name=\"Contact_20_Line\">{}</text:p>",
esc(line)
));
}
}
if !title.is_empty() {
out.push_str(&format!(
"<text:p text:style-name=\"Title_20_Page\">{}</text:p>",
esc(title)
));
}
if !author.is_empty() {
out.push_str(&format!(
"<text:p text:style-name=\"Title_20_Page_20_Author\">by {}</text:p>",
esc(author)
));
}
out
}
fn content_xml(chapters: &[Chapter], meta: &DocMeta) -> String {
let mut body = String::new(); let mut body = String::new();
let title_page = if meta.title_page {
title_page_xml(meta)
} else {
String::new()
};
let has_title_page = !title_page.is_empty();
body.push_str(&title_page);
for (i, chapter) in chapters.iter().enumerate() { 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. Every chapter after the // shifted down by one so they nest beneath it.
// first starts on a new page via a heading style with a page break. if meta.standard_format {
let heading_style = if i == 0 { // An empty paragraph carries both the page break and the drop that
"Heading_20_1" // opens a chapter a third of the way down the page. The first one
// also moves off the title page's master and starts the numbering.
let drop = match (i, has_title_page) {
(0, true) => "Chapter_20_Drop_20_First",
(0, false) => "Chapter_20_Drop_20_Open",
_ => "Chapter_20_Drop",
};
body.push_str(&format!("<text:p text:style-name=\"{drop}\"/>"));
body.push_str(&format!(
"<text:h text:style-name=\"Heading_20_1\" text:outline-level=\"1\">{}</text:h>",
esc(&chapter.title)
));
} else { } else {
"Chapter_20_Break" // Every chapter after the first starts on a new page via a heading
}; // style with a page break -- and so does the first, when a title
body.push_str(&format!( // page precedes it.
"<text:h text:style-name=\"{heading_style}\" text:outline-level=\"1\">{}</text:h>", let heading_style = if i == 0 && !has_title_page {
esc(&chapter.title) "Heading_20_1"
)); } else {
"Chapter_20_Break"
};
body.push_str(&format!(
"<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()) { if let Some(slug) = chapter.slug.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
// Italic at both the paragraph-style and character-run level so the // Italic at both the paragraph-style and character-run level so the
// caption stays italic regardless of how a reader applies styles. // caption stays italic regardless of how a reader applies styles.
@@ -515,7 +871,7 @@ fn content_xml(chapters: &[Chapter]) -> String {
esc(slug) esc(slug)
)); ));
} }
body.push_str(&markdown_to_body(&chapter.markdown, 1)); body.push_str(&markdown_to_body(&chapter.markdown, 1, meta.standard_format));
} }
format!( format!(
@@ -554,10 +910,10 @@ pub fn export(chapters: &[Chapter], meta: &DocMeta, out_path: &Path) -> std::io:
zip.write_all(meta_xml(meta).as_bytes())?; zip.write_all(meta_xml(meta).as_bytes())?;
zip.start_file("styles.xml", deflated).map_err(to_io)?; zip.start_file("styles.xml", deflated).map_err(to_io)?;
zip.write_all(styles_xml().as_bytes())?; zip.write_all(styles_xml(meta).as_bytes())?;
zip.start_file("content.xml", deflated).map_err(to_io)?; zip.start_file("content.xml", deflated).map_err(to_io)?;
zip.write_all(content_xml(chapters).as_bytes())?; zip.write_all(content_xml(chapters, meta).as_bytes())?;
zip.finish().map_err(to_io)?; zip.finish().map_err(to_io)?;
Ok(()) Ok(())
@@ -575,16 +931,29 @@ pub const SUBDOC_DIR: &str = "chapters";
/// `content.xml` for a master document: one linked section per chapter. /// `content.xml` for a master document: one linked section per chapter.
fn master_content_xml(meta: &DocMeta, links: &[(String, String)]) -> String { fn master_content_xml(meta: &DocMeta, links: &[(String, String)]) -> String {
let mut sections = String::new(); let mut sections = String::new();
if !meta.title.trim().is_empty() { let title_page = if meta.title_page {
title_page_xml(meta)
} else {
String::new()
};
if title_page.is_empty() {
if !meta.title.trim().is_empty() {
sections.push_str(&format!(
" <text:h text:style-name=\"Heading_20_1\" text:outline-level=\"1\">{}</text:h>\n",
esc(meta.title.trim())
));
}
if !meta.author.trim().is_empty() {
sections.push_str(&format!(
" <text:p text:style-name=\"Chapter_20_Caption\">{}</text:p>\n",
esc(meta.author.trim())
));
}
} else {
// The linked chapters begin with a plain heading of their own, so the
// break that separates them from the title page has to live here.
sections.push_str(&format!( sections.push_str(&format!(
" <text:h text:style-name=\"ChapterTitle\" text:outline-level=\"1\">{}</text:h>\n", " {title_page}\n <text:p text:style-name=\"Page_20_Break\"/>\n"
esc(meta.title.trim())
));
}
if !meta.author.trim().is_empty() {
sections.push_str(&format!(
" <text:p text:style-name=\"ChapterSlug\">{}</text:p>\n",
esc(meta.author.trim())
)); ));
} }
for (name, href) in links { for (name, href) in links {
@@ -682,6 +1051,8 @@ pub fn export_master(
title: chapter.title.clone(), title: chapter.title.clone(),
word_count: chapter.markdown.split_whitespace().count(), word_count: chapter.markdown.split_whitespace().count(),
chapter_count: 1, chapter_count: 1,
// The title page belongs to the master, not to every chapter.
title_page: false,
..meta.clone() ..meta.clone()
}; };
export(std::slice::from_ref(chapter), &chapter_meta, &path)?; export(std::slice::from_ref(chapter), &chapter_meta, &path)?;
@@ -707,7 +1078,7 @@ pub fn export_master(
zip.start_file("meta.xml", deflated).map_err(to_io)?; zip.start_file("meta.xml", deflated).map_err(to_io)?;
zip.write_all(meta_xml(meta).as_bytes())?; zip.write_all(meta_xml(meta).as_bytes())?;
zip.start_file("styles.xml", deflated).map_err(to_io)?; zip.start_file("styles.xml", deflated).map_err(to_io)?;
zip.write_all(styles_xml().as_bytes())?; zip.write_all(styles_xml(meta).as_bytes())?;
zip.start_file("content.xml", deflated).map_err(to_io)?; zip.start_file("content.xml", deflated).map_err(to_io)?;
zip.write_all(master_content_xml(meta, &links).as_bytes())?; zip.write_all(master_content_xml(meta, &links).as_bytes())?;
zip.finish().map_err(to_io)?; zip.finish().map_err(to_io)?;
@@ -748,7 +1119,7 @@ A [link](https://example.com) and a rule:
--- ---
Done."; Done.";
let body = markdown_to_body(md, 1); let body = markdown_to_body(md, 1, false);
assert!(body.contains("<text:span text:style-name=\"T_bold\">bold</text:span>")); assert!(body.contains("<text:span text:style-name=\"T_bold\">bold</text:span>"));
assert!(body.contains("<text:span text:style-name=\"T_italic\">italic</text:span>")); assert!(body.contains("<text:span text:style-name=\"T_italic\">italic</text:span>"));
assert!(body.contains("T_bolditalic")); assert!(body.contains("T_bolditalic"));
@@ -878,6 +1249,234 @@ mod meta_tests {
assert!(xml.contains("Cats &amp; Dogs &lt;draft&gt;"), "got {xml}"); assert!(xml.contains("Cats &amp; Dogs &lt;draft&gt;"), "got {xml}");
} }
fn title_page_meta() -> DocMeta {
DocMeta {
title: "The Winter Gate".to_string(),
author: "Lane Wight".to_string(),
contact: "12 Alder Row\nHalifax NS\n\nlane@example.com".to_string(),
title_page: true,
..DocMeta::default()
}
}
fn one_chapter() -> Vec<Chapter> {
vec![Chapter {
title: "Chapter One".to_string(),
slug: None,
markdown: "Ada stood before the gate.".to_string(),
}]
}
#[test]
fn a_title_page_carries_the_title_author_and_contact() {
let content = content_xml(&one_chapter(), &title_page_meta());
assert!(content.contains(
"<text:p text:style-name=\"Title_20_Page\">The Winter Gate</text:p>"
));
assert!(content.contains(
"<text:p text:style-name=\"Title_20_Page_20_Author\">Lane Wight</text:p>"
));
for line in ["12 Alder Row", "Halifax NS", "lane@example.com"] {
assert!(
content.contains(&format!(
"<text:p text:style-name=\"Title_20_Page_20_Contact\">{line}</text:p>"
)),
"contact line {line:?} missing from {content}"
);
}
// The blank line between the address and the email is kept as spacing.
assert!(content.contains("<text:p text:style-name=\"Title_20_Page_20_Contact\"/>"));
// The title page comes first, and the first chapter starts on a new page.
assert!(content.find("The Winter Gate").unwrap() < content.find("Chapter One").unwrap());
assert!(content.contains(
"<text:h text:style-name=\"Chapter_20_Break\" text:outline-level=\"1\">Chapter One</text:h>"
));
// And the styles it names are all defined.
let styles = styles_xml(&DocMeta::default());
for name in [
"Title_20_Page",
"Title_20_Page_20_Author",
"Title_20_Page_20_Contact",
] {
assert!(styles.contains(&format!("style:name=\"{name}\"")), "{name} undefined");
}
}
/// Off by default: an export that did not ask for a title page must look
/// exactly as it did before, first chapter and all.
#[test]
fn without_the_option_no_title_page_is_written() {
let content = content_xml(&one_chapter(), &DocMeta {
title_page: false,
..title_page_meta()
});
assert!(!content.contains("Title_20_Page"), "got {content}");
assert!(!content.contains("lane@example.com"));
assert!(content.contains(
"<text:h text:style-name=\"Heading_20_1\" text:outline-level=\"1\">Chapter One</text:h>"
));
}
/// Asking for a title page with nothing to put on it must not open the
/// document on a blank sheet.
#[test]
fn an_empty_title_page_is_skipped() {
let content = content_xml(&one_chapter(), &DocMeta {
title_page: true,
..DocMeta::default()
});
assert!(!content.contains("Title_20_Page"), "got {content}");
assert!(content.contains(
"<text:h text:style-name=\"Heading_20_1\" text:outline-level=\"1\">Chapter One</text:h>"
));
}
fn smf() -> DocMeta {
DocMeta {
title: "The Winter Gate".to_string(),
author: "Lane Wight".to_string(),
contact: "Lane Wight\nHalifax NS\nlane@example.com".to_string(),
title_page: true,
standard_format: true,
word_count: 88_640,
chapter_count: 2,
..DocMeta::default()
}
}
#[test]
fn a_submission_word_count_is_rounded_and_grouped() {
assert_eq!(approx_word_count(88_640), "About 89,000 words");
assert_eq!(approx_word_count(90_000), "About 90,000 words");
// Short work rounds to the nearest hundred instead.
assert_eq!(approx_word_count(4_240), "About 4,200 words");
assert_eq!(approx_word_count(9_960), "About 10,000 words");
// Never "About 0 words": a manuscript with any words at all has some.
assert_eq!(approx_word_count(0), "About 100 words");
assert_eq!(approx_word_count(12), "About 100 words");
}
#[test]
fn the_running_head_uses_the_last_word_of_the_author() {
assert_eq!(surname("Lane Wight"), "Wight");
assert_eq!(surname("Ada"), "Ada");
assert_eq!(surname(" Mary Anne Evans "), "Evans");
assert_eq!(surname(""), "");
}
#[test]
fn standard_format_sets_the_type_the_way_a_submission_wants_it() {
let styles = styles_xml(&smf());
// 12pt monospace, double-spaced, half-inch first-line indent.
assert!(styles.contains("style:font-family-generic=\"modern\""), "not monospace");
assert!(styles.contains("fo:font-size=\"12pt\""));
assert!(styles.contains("fo:line-height=\"200%\""));
assert!(styles.contains("fo:text-indent=\"0.5in\""));
// Surname / Title / page number, on a header of its own.
assert!(styles.contains("<style:header>"));
assert!(styles.contains("Wight / The Winter Gate / "));
assert!(styles.contains("<text:page-number"));
// The title page gets a master with no header at all.
assert!(styles.contains("style:name=\"Title_20_Page\" style:display-name=\"Title Page\" style:page-layout-name=\"pm-title\""));
// 1in of text margin, reached via a 0.5in page margin plus the header.
assert!(styles.contains("fo:margin-top=\"0.5in\""));
assert!(styles.contains("fo:margin-left=\"1in\" fo:margin-right=\"1in\""));
}
/// The bug this format cost the most time to: `style:master-page-name` is
/// inherited, and any style carrying it forces a page break before *every*
/// paragraph that uses it. A contact block of four lines became four pages.
#[test]
fn only_single_use_styles_carry_a_master_page_and_nothing_inherits_them() {
let styles = styles_xml(&smf());
let carriers: Vec<&str> = styles
.match_indices("style:master-page-name=")
.map(|(i, _)| {
let head = &styles[..i];
let start = head.rfind("style:name=\"").expect("a named style") + 12;
let name_end = head[start..].find('"').unwrap() + start;
&styles[start..name_end]
})
.collect();
assert_eq!(
carriers,
vec!["Chapter_20_Drop_20_First", "Contact_20_Block"],
"a new style carries a master page; make sure it is used exactly once"
);
for carrier in carriers {
assert!(
!styles.contains(&format!("style:parent-style-name=\"{carrier}\"")),
"{carrier} carries a master page, so inheriting from it breaks pages"
);
}
}
#[test]
fn standard_format_frames_chapters_and_scene_breaks() {
let chapters = vec![
Chapter {
title: "Chapter One".to_string(),
slug: None,
markdown: "First.\n\n---\n\nSecond.".to_string(),
},
Chapter {
title: "Chapter Two".to_string(),
slug: None,
markdown: "Third.".to_string(),
},
];
let content = content_xml(&chapters, &smf());
// A title page, then the first chapter moves onto the header master and
// restarts the page numbering; later chapters just break.
assert!(content.contains("<text:p text:style-name=\"Chapter_20_Drop_20_First\"/>"));
assert!(content.contains("<text:p text:style-name=\"Chapter_20_Drop\"/>"));
assert!(!content.contains("Chapter_20_Drop_20_Open"));
// A rule is a scene break, not a drawn line.
assert!(content.contains("<text:p text:style-name=\"Scene_20_Break\">#</text:p>"));
assert!(!content.contains("Horizontal_Line"));
// Without a title page the first chapter opens where it is, with no
// break that would leave a blank first page.
let content = content_xml(&chapters, &DocMeta { title_page: false, ..smf() });
assert!(content.contains("<text:p text:style-name=\"Chapter_20_Drop_20_Open\"/>"));
assert!(!content.contains("Chapter_20_Drop_20_First"));
}
#[test]
fn the_submission_title_page_puts_contact_left_and_word_count_right() {
let content = content_xml(&[], &smf());
assert!(content.contains(
"<text:p text:style-name=\"Contact_20_Block\">Lane Wight\
<text:tab/>About 89,000 words</text:p>"
));
assert!(content.contains("<text:p text:style-name=\"Contact_20_Line\">Halifax NS</text:p>"));
assert!(content.contains("<text:p text:style-name=\"Title_20_Page\">The Winter Gate</text:p>"));
assert!(content.contains(
"<text:p text:style-name=\"Title_20_Page_20_Author\">by Lane Wight</text:p>"
));
// The right-aligned tab stop the word count hangs off must exist.
assert!(styles_xml(&smf()).contains("style:type=\"right\""));
}
/// The ordinary export is a book, and must stay one.
#[test]
fn without_standard_format_nothing_changes() {
let styles = styles_xml(&DocMeta::default());
assert!(!styles.contains("style:font-family-generic=\"modern\" style:font-pitch"));
assert!(!styles.contains("fo:line-height=\"200%\""));
assert!(!styles.contains("<style:header>"));
assert!(!styles.contains("Scene_20_Break"));
assert!(!styles.contains("style:master-page-name"));
assert!(styles.contains("fo:font-size=\"11pt\""));
assert!(styles.contains("fo:margin-top=\"1in\""));
let content = content_xml(
&[Chapter { title: "One".to_string(), slug: None, markdown: "A.\n\n---\n\nB.".to_string() }],
&DocMeta::default(),
);
assert!(content.contains("Horizontal_Line"));
assert!(!content.contains("Chapter_20_Drop"));
}
#[test] #[test]
fn the_manifest_lists_every_member_written() { fn the_manifest_lists_every_member_written() {
let manifest = manifest_xml(); let manifest = manifest_xml();
@@ -964,6 +1563,50 @@ mod master_tests {
assert!(long.len() < 80, "got {} chars", long.len()); assert!(long.len() < 80, "got {} chars", long.len());
} }
/// The title page belongs to the master alone: repeating it in every
/// subdocument would put a title page between each pair of chapters.
#[test]
fn a_master_carries_the_title_page_and_its_chapters_do_not() {
let dir = std::env::temp_dir().join("md_manuscript_master_title");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("book.odm");
let meta = DocMeta {
title: "The Winter Gate".to_string(),
author: "Lane Wight".to_string(),
contact: "lane@example.com".to_string(),
title_page: true,
..DocMeta::default()
};
let written = export_master(&chapters(), &meta, &out).expect("master written");
use std::io::Read;
let mut content = String::new();
zip::ZipArchive::new(std::fs::File::open(&out).unwrap())
.unwrap()
.by_name("content.xml")
.unwrap()
.read_to_string(&mut content)
.unwrap();
assert!(content.contains("<text:p text:style-name=\"Title_20_Page\">The Winter Gate</text:p>"));
assert!(content.contains("lane@example.com"));
// The linked chapters start on the page after it.
assert!(content.contains("text:style-name=\"Page_20_Break\""));
assert!(styles_xml(&DocMeta::default()).contains("style:name=\"Page_20_Break\""));
for path in &written {
let mut chapter = String::new();
zip::ZipArchive::new(std::fs::File::open(path).unwrap())
.unwrap()
.by_name("content.xml")
.unwrap()
.read_to_string(&mut chapter)
.unwrap();
assert!(!chapter.contains("Title_20_Page"), "{path:?} repeats the title page");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn the_master_manifest_matches_what_is_written() { fn the_master_manifest_matches_what_is_written() {
let manifest = master_manifest_xml(); let manifest = master_manifest_xml();