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:
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+671
-28
@@ -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,9 +256,17 @@ impl Converter {
|
|||||||
}
|
}
|
||||||
Event::Rule => {
|
Event::Rule => {
|
||||||
self.close_block();
|
self.close_block();
|
||||||
|
// 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
|
self.out
|
||||||
.push_str("<text:p text:style-name=\"Horizontal_Line\"></text:p>");
|
.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,24 +720,139 @@ 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
|
||||||
|
// 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 {
|
||||||
|
// 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"
|
"Heading_20_1"
|
||||||
} else {
|
} else {
|
||||||
"Chapter_20_Break"
|
"Chapter_20_Break"
|
||||||
@@ -506,6 +861,7 @@ fn content_xml(chapters: &[Chapter]) -> String {
|
|||||||
"<text:h text:style-name=\"{heading_style}\" text:outline-level=\"1\">{}</text:h>",
|
"<text:h text:style-name=\"{heading_style}\" text:outline-level=\"1\">{}</text:h>",
|
||||||
esc(&chapter.title)
|
esc(&chapter.title)
|
||||||
));
|
));
|
||||||
|
}
|
||||||
if let Some(slug) = chapter.slug.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
if let Some(slug) = chapter.slug.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
// 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,18 +931,31 @@ 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();
|
||||||
|
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() {
|
if !meta.title.trim().is_empty() {
|
||||||
sections.push_str(&format!(
|
sections.push_str(&format!(
|
||||||
" <text:h text:style-name=\"ChapterTitle\" text:outline-level=\"1\">{}</text:h>\n",
|
" <text:h text:style-name=\"Heading_20_1\" text:outline-level=\"1\">{}</text:h>\n",
|
||||||
esc(meta.title.trim())
|
esc(meta.title.trim())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !meta.author.trim().is_empty() {
|
if !meta.author.trim().is_empty() {
|
||||||
sections.push_str(&format!(
|
sections.push_str(&format!(
|
||||||
" <text:p text:style-name=\"ChapterSlug\">{}</text:p>\n",
|
" <text:p text:style-name=\"Chapter_20_Caption\">{}</text:p>\n",
|
||||||
esc(meta.author.trim())
|
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!(
|
||||||
|
" {title_page}\n <text:p text:style-name=\"Page_20_Break\"/>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
for (name, href) in links {
|
for (name, href) in links {
|
||||||
sections.push_str(&format!(
|
sections.push_str(&format!(
|
||||||
" <text:section text:name=\"{}\" text:protected=\"false\">\n \
|
" <text:section text:name=\"{}\" text:protected=\"false\">\n \
|
||||||
@@ -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 & Dogs <draft>"), "got {xml}");
|
assert!(xml.contains("Cats & Dogs <draft>"), "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();
|
||||||
|
|||||||
Reference in New Issue
Block a user