Add Fountain screenplay projects, exported straight to PDF
A screenplay is its own project rather than a file type mixed in with prose, so this is one switch in Manuscript details, not a second file extension threaded through order.rs and the file panel. Files stay `.md` and keep the same editorial header; only the draft below the marker is read as Fountain. They list, reorder and header-strip exactly as before. src/fountain.rs parses the format. Almost nothing in Fountain is marked up -- what makes a line a character cue is that it is in capitals with something directly beneath it, and what makes the same words a transition is a blank line below instead. The forcing characters are all there for where that is not enough, along with notes, the boneyard and inline emphasis. Sections and synopses are parsed and kept but never printed: they are the writer's scaffolding. src/pdf.rs writes the PDF by hand, for the reason odt.rs writes ODT by hand -- no pandoc, no LibreOffice at runtime. It costs no dependency either: a screenplay is set entirely in Courier, which is one of the fourteen faces every reader must provide, so there is no font to embed and no metrics to parse. Streams are left uncompressed; a feature script is a few hundred kilobytes that way and stays readable when something needs debugging. src/screenplay.rs does layout. The geometry is the conventional one -- 55 lines of 12pt on US Letter, action at 1.5", dialogue 2.5", parentheticals 3.1", cues 3.7", transitions flush to 7.5" -- because a page only reads as a minute of screen time if it is. A speech broken by a page boundary is marked (MORE) and resumed under a repeated cue, a `^` cue sets two speeches side by side, and a scene heading is never left stranded at the foot of a page. Two faults the tests missed and measuring the rendered PDF caught. A hard-wrapped action paragraph was getting a blank line between every source line, which on the page reads as a beat the writer never wrote; consecutive lines are now one paragraph, with the breaks kept. And reserving one line after a scene heading did not stop it stranding, because every element that can follow a heading is separated from it by a blank -- the reserve has to cover both. Both now have tests. Verified beyond the unit tests: pdffonts confirms base-14 Courier with nothing embedded, pdftotext -bbox puts every indent within a hundredth of an inch of standard, and the export was driven through the real UI against a scratch workspace and an isolated config. Not built, because they were offered and never asked for: rendering Fountain in the Preview pane, and exporting a manuscript as a .fountain file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Bq2fUZNgksdPp3zeHqzSw
This commit is contained in:
+102
-8
@@ -1,6 +1,7 @@
|
||||
//! Workspace, file-list and git operations: opening a folder, creating,
|
||||
//! renaming and deleting manuscript files, persisting order and titles, and
|
||||
//! exporting the assembled manuscript to ODT.
|
||||
//! exporting the assembled manuscript to ODT, or — for a screenplay
|
||||
//! project — to a paginated PDF.
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -785,6 +786,91 @@ impl App {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Details for a screenplay export. The same Manuscript details a prose
|
||||
/// export uses: a screenplay project differs in how its pages are set, not
|
||||
/// in who wrote it or how to reach them.
|
||||
pub(super) fn screenplay_meta(&self) -> crate::screenplay::ScreenplayMeta {
|
||||
crate::screenplay::ScreenplayMeta {
|
||||
title: match self.config.manuscript_title.trim() {
|
||||
"" => self
|
||||
.workspace()
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("Screenplay")
|
||||
.to_string(),
|
||||
set => set.to_string(),
|
||||
},
|
||||
author: self.config.manuscript_author.trim().to_string(),
|
||||
contact: self.config.manuscript_contact.clone(),
|
||||
title_page: self.config.manuscript_title_page,
|
||||
}
|
||||
}
|
||||
|
||||
/// The manuscript's files as Fountain bodies, in order.
|
||||
///
|
||||
/// A screenplay project keeps the same editorial header as a prose one, so
|
||||
/// the header is stripped exactly as it is for an ODT export; what is left
|
||||
/// below the draft marker is the Fountain.
|
||||
fn collect_bodies(&self) -> Vec<String> {
|
||||
let marker = self.config.draft_marker.clone();
|
||||
self.manuscript_files()
|
||||
.into_iter()
|
||||
.map(|(_, name)| {
|
||||
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
|
||||
crate::preprocess::parse(&raw, &marker).body
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Export the manuscript the way this project wants exporting: a screenplay
|
||||
/// as a paginated PDF, anything else as an ODT.
|
||||
pub(super) fn export_manuscript(&mut self) {
|
||||
if self.config.manuscript_screenplay {
|
||||
self.export_screenplay();
|
||||
} else {
|
||||
self.export_odt();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the export button says and does, which depends on the project.
|
||||
pub(super) fn export_label(&self) -> &'static str {
|
||||
if self.config.manuscript_screenplay {
|
||||
"Export PDF"
|
||||
} else {
|
||||
"Export ODT"
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay the manuscript out as a screenplay and write it as a PDF beside
|
||||
/// wherever the export path points.
|
||||
///
|
||||
/// The extension is forced rather than taken from the box, the same way the
|
||||
/// master export forces `.odm`: the path is remembered across projects and
|
||||
/// would otherwise still be naming last project's `.odt`.
|
||||
pub(super) fn export_screenplay(&mut self) {
|
||||
self.save_current();
|
||||
let bodies = self.collect_bodies();
|
||||
if bodies.iter().all(|b| b.trim().is_empty()) {
|
||||
self.status = "Nothing to export — the screenplay is empty".to_string();
|
||||
return;
|
||||
}
|
||||
let out = PathBuf::from(self.export_input.trim()).with_extension("pdf");
|
||||
let meta = self.screenplay_meta();
|
||||
match crate::screenplay::export(&bodies, &meta, &out) {
|
||||
Ok(pages) => {
|
||||
self.config.export_path = out.clone();
|
||||
self.config.save();
|
||||
self.export_input = out.display().to_string();
|
||||
self.status = format!(
|
||||
"Exported {} page(s) of screenplay to {}",
|
||||
pages,
|
||||
out.display()
|
||||
);
|
||||
}
|
||||
Err(e) => self.status = format!("Export failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn export_odt(&mut self) {
|
||||
self.save_current();
|
||||
let marker = self.config.draft_marker.clone();
|
||||
@@ -844,24 +930,32 @@ impl App {
|
||||
/// Open a native save dialog to choose the export `.odt` path.
|
||||
pub(super) fn browse_export(&mut self) {
|
||||
let current = PathBuf::from(self.export_input.trim());
|
||||
let screenplay = self.config.manuscript_screenplay;
|
||||
let (label, ext) = if screenplay {
|
||||
("PDF", "pdf")
|
||||
} else {
|
||||
("OpenDocument Text", "odt")
|
||||
};
|
||||
let mut dialog = rfd::FileDialog::new()
|
||||
.set_title("Choose export file")
|
||||
.add_filter("OpenDocument Text", &["odt"]);
|
||||
.add_filter(label, &[ext]);
|
||||
if let Some(parent) = current.parent().filter(|p| p.is_dir()) {
|
||||
dialog = dialog.set_directory(parent);
|
||||
}
|
||||
let fallback = format!("manuscript.{ext}");
|
||||
let name = current
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("manuscript.odt");
|
||||
.unwrap_or(&fallback);
|
||||
if let Some(mut path) = dialog.set_file_name(name).save_file() {
|
||||
// Ensure the chosen path ends in .odt even if the user omitted it.
|
||||
let has_odt = path
|
||||
// Ensure the chosen path carries the extension even if it was
|
||||
// omitted, or was left over from the other kind of export.
|
||||
let matches = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("odt"));
|
||||
if !has_odt {
|
||||
path.set_extension("odt");
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case(ext));
|
||||
if !matches {
|
||||
path.set_extension(ext);
|
||||
}
|
||||
self.export_input = path.display().to_string();
|
||||
self.config.export_path = path;
|
||||
|
||||
Reference in New Issue
Block a user