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
+10 -1
View File
@@ -82,11 +82,20 @@ impl App {
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)
.id(egui::Id::new(EDITOR_ID))
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(30)
.desired_rows(rows)
.layouter(&mut layouter)
.show(ui);
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 {
/// 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) {
let show_files = self.config.show_file_panel;
egui::SidePanel::left("files")
.resizable(true)
.default_width(260.0)
@@ -113,7 +118,7 @@ impl App {
// that asks for more width than there is would otherwise push it
// wider every frame.
.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 widget text colours so file names stay legible (especially in
// dark mode) without affecting the rest of the app.
+5
View File
@@ -443,6 +443,11 @@ impl eframe::App for App {
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.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F)) {
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) {
let mut open = self.show_manuscript_settings;
let mut close = false;
@@ -320,12 +321,50 @@ impl App {
);
save_now |= r.lost_focus();
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(
egui::RichText::new(
"Written into exported .odt files as their document \
properties, which is what a word processor shows under \
File ▸ Properties.",
"The title and author are also written into exported .odt \
files as their document properties, which is what a word \
processor shows under File ▸ Properties.",
)
.small()
.weak(),
+88 -1
View File
@@ -6,8 +6,15 @@ use super::*;
impl App {
// ---- 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) {
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.horizontal(|ui| {
ui.label("Workspace:");
@@ -56,6 +63,16 @@ impl App {
if ui.button("Export ODT").clicked() {
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();
if ui
.checkbox(&mut self.config.show_preview, "Preview")
@@ -310,6 +327,35 @@ impl App {
}
});
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
.checkbox(&mut self.config.show_preview, "Preview pane")
.clicked()
@@ -375,6 +421,30 @@ impl App {
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
/// none: the editor is showing less than the file holds, and that has to be
/// 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) {
let collapsed = self.header_stash.is_some();
// 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
}
/// 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
/// if needed, and refresh git status.
pub(super) fn open_workspace(&mut self) {
@@ -656,8 +705,9 @@ impl App {
Some((goal, count_words(&header.body)))
}
/// Document properties for an export: the configured title and author, with
/// the title falling back to the project folder's own name.
/// Document properties for an export: the configured title, author and
/// contact details, with the title falling back to the project folder's own
/// name.
pub(super) fn doc_meta(&self, chapters: &[Chapter]) -> odt::DocMeta {
let title = match self.config.manuscript_title.trim() {
"" => self
@@ -671,6 +721,9 @@ impl App {
odt::DocMeta {
title,
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(),
keywords: String::new(),
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,
/// 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.
/// 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> {
let in_book = |name: &String| match manuscript_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 {
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]
fn untitled_name_fills_the_first_free_slot() {
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.
#[serde(default)]
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.
#[serde(default)]
pub collapse_header: bool,
@@ -127,6 +136,27 @@ pub struct Config {
/// Author written into exported documents.
#[serde(default)]
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…**.
@@ -237,6 +267,8 @@ impl Default for Config {
workspace,
export_path,
show_preview: false,
show_toolbar: default_show_toolbar(),
show_file_panel: default_show_toolbar(),
collapse_header: false,
show_reference_files: false,
draft_marker: default_marker(),
@@ -267,6 +299,9 @@ impl Default for Config {
archive_folder: default_archive_folder(),
manuscript_title: 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>,
/// Heading level offset so document headings nest under the chapter title.
heading_offset: u8,
/// In standard manuscript format a `---` is a scene break — a centred `#` —
/// rather than a drawn rule.
standard_format: bool,
}
impl Converter {
fn new(heading_offset: u8) -> Self {
fn new(heading_offset: u8, standard_format: bool) -> Self {
Converter {
standard_format,
out: String::new(),
block: Block::None,
bold: false,
@@ -252,8 +256,16 @@ impl Converter {
}
Event::Rule => {
self.close_block();
self.out
.push_str("<text:p text:style-name=\"Horizontal_Line\"></text:p>");
// A rule between paragraphs of prose is a scene break. Drawn
// 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 */ }
_ => {}
@@ -341,8 +353,8 @@ impl Converter {
/// Convert one markdown document into an ODF body fragment, with headings shifted
/// down by `heading_offset` levels.
fn markdown_to_body(markdown: &str, heading_offset: u8) -> String {
Converter::new(heading_offset).run(markdown)
fn markdown_to_body(markdown: &str, heading_offset: u8, standard_format: bool) -> String {
Converter::new(heading_offset, standard_format).run(markdown)
}
const MIMETYPE: &str = "application/vnd.oasis.opendocument.text";
@@ -355,6 +367,17 @@ pub struct DocMeta {
pub author: String,
pub subject: 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.
pub word_count: usize,
pub chapter_count: usize,
@@ -400,9 +423,56 @@ fn manifest_xml() -> String {
.to_string()
}
fn styles_xml() -> String {
// Heading sizes for the seven levels we may emit (chapter = 1).
let sizes = [18, 16, 14, 13, 12, 11, 11, 11, 11, 11];
/// The author's surname, for the running header — the last whitespace-separated
/// word of the author field, which is right for "Lane Wight" and for a single
/// 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();
for (i, size) in sizes.iter().enumerate() {
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:family=\"paragraph\" style:parent-style-name=\"Heading\" \
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>"
));
}
// 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!(
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:styles>
<style:default-style style:family="paragraph">
<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: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:paragraph-properties fo:margin-top="0in" fo:margin-bottom="0.1in"/>
{body_para}
</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: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"/>
{heading_para}
</style:style>
{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:paragraph-properties fo:break-before="page"/>
</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: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"/>
{caption}
</style:style>
<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"/>
@@ -480,32 +720,148 @@ fn styles_xml() -> String {
</text:list-style>
</office:styles>
<office:automatic-styles>
<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>
{page_layouts}
</office:automatic-styles>
<office:master-styles>
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
{master_pages}
</office:master-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 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() {
// Chapter heading (always level 1); the document's own headings are
// 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"
// shifted down by one so they nest beneath it.
if meta.standard_format {
// An empty paragraph carries both the page break and the drop that
// 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 {
"Chapter_20_Break"
};
body.push_str(&format!(
"<text:h text:style-name=\"{heading_style}\" text:outline-level=\"1\">{}</text:h>",
esc(&chapter.title)
));
// 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
// page precedes it.
let heading_style = if i == 0 && !has_title_page {
"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()) {
// Italic at both the paragraph-style and character-run level so the
// caption stays italic regardless of how a reader applies styles.
@@ -515,7 +871,7 @@ fn content_xml(chapters: &[Chapter]) -> String {
esc(slug)
));
}
body.push_str(&markdown_to_body(&chapter.markdown, 1));
body.push_str(&markdown_to_body(&chapter.markdown, 1, meta.standard_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.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.write_all(content_xml(chapters).as_bytes())?;
zip.write_all(content_xml(chapters, meta).as_bytes())?;
zip.finish().map_err(to_io)?;
Ok(())
@@ -575,16 +931,29 @@ pub const SUBDOC_DIR: &str = "chapters";
/// `content.xml` for a master document: one linked section per chapter.
fn master_content_xml(meta: &DocMeta, links: &[(String, String)]) -> String {
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!(
" <text:h text:style-name=\"ChapterTitle\" 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=\"ChapterSlug\">{}</text:p>\n",
esc(meta.author.trim())
" {title_page}\n <text:p text:style-name=\"Page_20_Break\"/>\n"
));
}
for (name, href) in links {
@@ -682,6 +1051,8 @@ pub fn export_master(
title: chapter.title.clone(),
word_count: chapter.markdown.split_whitespace().count(),
chapter_count: 1,
// The title page belongs to the master, not to every chapter.
title_page: false,
..meta.clone()
};
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.write_all(meta_xml(meta).as_bytes())?;
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.write_all(master_content_xml(meta, &links).as_bytes())?;
zip.finish().map_err(to_io)?;
@@ -748,7 +1119,7 @@ A [link](https://example.com) and a rule:
---
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_italic\">italic</text:span>"));
assert!(body.contains("T_bolditalic"));
@@ -878,6 +1249,234 @@ mod meta_tests {
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]
fn the_manifest_lists_every_member_written() {
let manifest = manifest_xml();
@@ -964,6 +1563,50 @@ mod master_tests {
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]
fn the_master_manifest_matches_what_is_written() {
let manifest = master_manifest_xml();