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:
2026-08-26 12:02:56 -05:00
parent be6a2d6a8b
commit 5a5017def8
9 changed files with 2580 additions and 25 deletions
+21 -3
View File
@@ -360,12 +360,30 @@ impl App {
of the way down, and # for a scene break",
);
save_now |= r.changed();
let r = ui
.checkbox(
&mut self.config.manuscript_screenplay,
"Screenplay (Fountain)",
)
.on_hover_text(
"Read each file's draft as Fountain rather than markdown \
and export the whole manuscript as a paginated \
screenplay PDF: 12pt Courier on US Letter, scene \
headings and dialogue at their standard indents, \
(MORE) and (CONT'D) across page breaks",
);
save_now |= r.changed();
ui.label(
egui::RichText::new(
egui::RichText::new(if self.config.manuscript_screenplay {
"Standard manuscript format and the title page above still \
apply to the title page; the script itself is always set \
in screenplay format. Files keep their usual editorial \
header — only the draft below the marker is Fountain."
} else {
"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.",
)
processor shows under File ▸ Properties."
})
.small()
.weak(),
);
+30 -12
View File
@@ -57,11 +57,26 @@ impl App {
ui.add(
egui::TextEdit::singleline(&mut self.export_input).desired_width(300.0),
);
if ui.button("📂").on_hover_text("Browse for export .odt file").clicked() {
let screenplay = self.config.manuscript_screenplay;
let browse_hint = if screenplay {
"Browse for export .pdf file"
} else {
"Browse for export .odt file"
};
if ui.button("📂").on_hover_text(browse_hint).clicked() {
self.browse_export();
}
if ui.button("Export ODT").clicked() {
self.export_odt();
let export_hint = if screenplay {
"Lay the script out in screenplay format and write it as a PDF"
} else {
"Write the manuscript as a single OpenDocument file"
};
if ui
.button(self.export_label())
.on_hover_text(export_hint)
.clicked()
{
self.export_manuscript();
}
// Resolved first: the hover text borrows the path, and the click
// handler needs `self` mutably.
@@ -268,17 +283,20 @@ impl App {
ui.close_menu();
self.browse_workspace();
}
if ui.button("Export ODT").clicked() {
if ui.button(self.export_label()).clicked() {
ui.close_menu();
self.export_odt();
self.export_manuscript();
}
if ui
.button("Export chapters + master (.odm)")
.on_hover_text(
"One .odt per chapter in a chapters/ folder, plus an \
.odm master that links them",
)
.clicked()
// The master export assembles chapters, which a screenplay
// does not have: its scenes are one continuous script.
if !self.config.manuscript_screenplay
&& ui
.button("Export chapters + master (.odm)")
.on_hover_text(
"One .odt per chapter in a chapters/ folder, plus an \
.odm master that links them",
)
.clicked()
{
ui.close_menu();
self.export_master();
+102 -8
View File
@@ -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;