From 5a5017def8e66a3b260b742540fe04947e29b553 Mon Sep 17 00:00:00 2001 From: landon Date: Wed, 26 Aug 2026 12:02:56 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017Bq2fUZNgksdPp3zeHqzSw --- README.md | 70 ++- src/app/project.rs | 24 +- src/app/ui.rs | 42 +- src/app/workspace.rs | 110 +++- src/config.rs | 25 + src/fountain.rs | 700 +++++++++++++++++++++++++ src/main.rs | 3 + src/pdf.rs | 473 +++++++++++++++++ src/screenplay.rs | 1158 ++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 2580 insertions(+), 25 deletions(-) create mode 100644 src/fountain.rs create mode 100644 src/pdf.rs create mode 100644 src/screenplay.rs diff --git a/README.md b/README.md index 9c5a457..2e2804d 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,14 @@ as an ordered manuscript. in `order.json` inside the workspace, so it syncs along with the files. It records each file's path, so subfolders travel with it. * **Export** the whole manuscript as a single concatenated `.odt` file, with a - chapter heading before each file's content. + chapter heading before each file's content — or, for a screenplay project, as + a paginated Fountain-to-PDF script (see + [Screenplays](#screenplays-fountain)). * **Start a new project** from a cookiecutter template (**File ▸ New project…**), which builds the whole folder structure and opens its drafting folder. Built in Rust with [`egui`](https://github.com/emilk/egui)/`eframe`. The ODT -writer is native (no `pandoc`/LibreOffice needed at runtime). +and PDF writers are both native (no `pandoc`/LibreOffice needed at runtime). ## Build @@ -366,6 +368,70 @@ 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. +### Screenplays (Fountain) + +Tick **Screenplay (Fountain)** in Manuscript details to make the project a +screenplay. Two things change, and nothing else does: + +- the draft below each file's marker is read as + [Fountain](https://fountain.io) rather than markdown, and +- **Export ODT** becomes **Export PDF**, writing a paginated screenplay instead + of a word-processor document. + +The files stay ordinary `.md` files with the same editorial header +(`# Title:`, `# Slug:`, `### Rough Draft:`), they list and reorder in the left +pane the same way, and the header is stripped on export exactly as it is for +prose. One file per scene or sequence is the natural layout, but nothing +enforces it — the files are joined into one continuous script, because what +divides a screenplay is its scene headings, not its chapters. + +Fountain needs no markup for the common elements; the shape of the line is +enough: + +``` +INT. REYNOLD'S HOUSE - KITCHEN - NIGHT + +Rain on the window. Bixby stands at the sink with a plate in +each hand, not washing either of them. + +BIXBY +(barely) +I said I'd do it. + +MOM (O.S.) +That was Tuesday. + +CUT TO: +``` + +A line starting `INT.`, `EXT.`, `EST.`, `INT./EXT.` or `I/E.` is a scene +heading. A line in capitals with something directly beneath it is a character +cue, and what follows is dialogue until the next blank line; a line in +parentheses inside that block is a parenthetical. A line in capitals ending +`TO:` with blank lines above and below is a transition. Everything else is +action, and consecutive lines are one paragraph. + +Where the shape is not enough, force it: `.` for a scene heading, `!` for +action, `@` for a character cue, `>` for a transition. `> text <` centres a +line, `===` starts a new page, `~` marks a lyric. `*italic*`, `**bold**`, +`***both***` and `_underline_` work as they look. `#` sections, `=` synopses, +`[[notes]]` and `/* boneyard */` blocks are for you and never reach the page. + +The PDF is the conventional layout, so a page reads as roughly a minute of +screen time: 12pt Courier on US Letter, 55 lines a page, scene headings and +action at 1.5″, dialogue at 2.5″, parentheticals at 3.1″, cues at 3.7″, +transitions flush to the 7.5″ right margin, and page numbers in the top-right +from the second page. A speech broken by a page boundary is marked `(MORE)` and +picked up under the cue again with `(CONT'D)`; a scene heading is never left +stranded at the foot of a page; a `^` cue sets the two speeches side by side. +Courier is one of the fonts every PDF reader is required to provide, so nothing +is embedded and the file stays small. + +**Begin exports with a title page** works here too, giving the standard spec +title page: the title a third of the way down, the credit beneath it, and the +contact details at the foot. **Standard manuscript format** has no effect on a +screenplay — a script is always set in screenplay format. + ### Exporting chapter files (File ▸ Export chapters + master) Writes one `.odt` per chapter into a `chapters/` folder, plus an `.odm` master diff --git a/src/app/project.rs b/src/app/project.rs index df96fac..84eb275 100644 --- a/src/app/project.rs +++ b/src/app/project.rs @@ -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(), ); diff --git a/src/app/ui.rs b/src/app/ui.rs index 08a8667..7975863 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -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(); diff --git a/src/app/workspace.rs b/src/app/workspace.rs index 6526ca5..65d2a9e 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -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 { + 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; diff --git a/src/config.rs b/src/config.rs index fdda29e..b111eef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -150,6 +150,12 @@ pub struct Config { /// agent or editor expects a submission in. #[serde(default)] pub manuscript_standard_format: bool, + /// Whether this project is a screenplay. The files keep the same editorial + /// header as a prose project, but the body below the draft marker is read + /// as Fountain rather than markdown, and the manuscript exports as a + /// paginated screenplay PDF instead of an ODT. + #[serde(default)] + pub manuscript_screenplay: bool, } /// The toolbar and the file list are what the window opens with; hiding either @@ -302,6 +308,7 @@ impl Default for Config { manuscript_contact: String::new(), manuscript_title_page: false, manuscript_standard_format: false, + manuscript_screenplay: false, } } } @@ -476,6 +483,24 @@ mod tests { assert_eq!(joined, PathBuf::from("/tmp/The Winter Gate/06-First Draft")); } + /// A config.json written before screenplay projects existed. Same risk as + /// the tests above: a missing `#[serde(default)]` would fail the whole + /// parse and reset every setting the user had. + #[test] + fn config_without_the_screenplay_field_still_loads() { + let old = r####"{ + "workspace": "/home/writer/Manuscript", + "export_path": "/home/writer/Manuscript/book.odt", + "manuscript_author": "A. Writer", + "manuscript_standard_format": true + }"####; + let cfg: Config = serde_json::from_str(old).expect("old config must still parse"); + assert_eq!(cfg.manuscript_author, "A. Writer"); + assert!(cfg.manuscript_standard_format); + // A project is prose unless it says otherwise. + assert!(!cfg.manuscript_screenplay); + } + /// A saved subfolder choice must win over the built-in default, so changing /// the default never silently moves an existing user's projects. #[test] diff --git a/src/fountain.rs b/src/fountain.rs new file mode 100644 index 0000000..02c858d --- /dev/null +++ b/src/fountain.rs @@ -0,0 +1,700 @@ +//! Fountain screenplay parsing. +//! +//! [Fountain](https://fountain.io) is a plain-text screenplay format: what +//! distinguishes a scene heading from a line of action is the shape of the +//! line and the blank lines around it, not any markup. This module turns that +//! text into a flat list of [`Element`]s, which `crate::screenplay` then lays +//! out on the page. +//! +//! Only the body is parsed. A screenplay project in this app keeps the same +//! editorial header as a prose one — `# Title:`, `# Slug:` and the +//! `### Rough Draft:` marker — so [`crate::preprocess::parse`] has already run +//! and lifted those out by the time the text arrives here. Fountain's own +//! `Title:` / `Credit:` title-page block is therefore *not* recognised: the +//! title page comes from Manuscript details, the same as it does for prose. + +/// A run of text sharing one set of emphases. +/// +/// Fountain marks emphasis the way markdown does (`*italic*`, `**bold**`, +/// `***both***`) and adds `_underline_`. Courier has a face for bold and for +/// italic, so those are a font change at layout time; underline has to be drawn +/// as a rule under the text, which is why it travels separately rather than +/// being folded into a single "style" enum. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Span { + pub text: String, + pub bold: bool, + pub italic: bool, + pub underline: bool, +} + +impl Span { + /// A span of plain text, carrying no emphasis. + pub fn plain(text: impl Into) -> Span { + Span { + text: text.into(), + ..Span::default() + } + } +} + +/// One element of a screenplay, in the order it was written. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Element { + /// `INT. KITCHEN - DAY`. Printed in capitals. + SceneHeading(Vec), + /// A paragraph of action, as its source lines. + /// + /// Consecutive non-blank lines are one paragraph, and the breaks between + /// them are kept rather than reflowed — a screenwriter uses them for + /// pacing, and a blank line between them on the page would read as a beat + /// that was never written. `centered` marks the `> text <` form. + Action { lines: Vec>, centered: bool }, + /// A character cue above a block of dialogue. `dual` marks the trailing + /// `^`, which asks for the block to sit beside the one before it. + Character { name: Vec, dual: bool }, + /// `(beat)` — a direction inside a dialogue block. + Parenthetical(Vec), + /// A line of dialogue. + Dialogue(Vec), + /// A `~`-prefixed line, sung rather than spoken. Printed in italic. + Lyric(Vec), + /// `CUT TO:`. Printed flush to the right margin. + Transition(Vec), + /// A `#`-prefixed structural heading. Not printed — it is the writer's + /// scaffolding — but kept so an outline could be built from it. + Section { level: u8, text: Vec }, + /// A `=`-prefixed one-line summary. Not printed, for the same reason. + Synopsis(Vec), + /// `===` — start a fresh page here. + PageBreak, +} + +impl Element { + /// Whether this element reaches the printed page at all. Sections and + /// synopses are notes to the writer and are silently dropped at layout. + pub fn is_printed(&self) -> bool { + !matches!(self, Element::Section { .. } | Element::Synopsis(_)) + } +} + +/// Remove `/* ... */` boneyard blocks, which may span lines. An unterminated +/// boneyard swallows the rest of the document, matching how the reference +/// implementations behave. +fn strip_boneyard(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(start) = rest.find("/*") { + out.push_str(&rest[..start]); + match rest[start + 2..].find("*/") { + Some(end) => rest = &rest[start + 2 + end + 2..], + None => return out, + } + } + out.push_str(rest); + out +} + +/// Remove `[[ ... ]]` notes. Like the boneyard these may span lines, and an +/// unterminated one runs to the end. +fn strip_notes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(start) = rest.find("[[") { + out.push_str(&rest[..start]); + match rest[start + 2..].find("]]") { + Some(end) => rest = &rest[start + 2 + end + 2..], + None => return out, + } + } + out.push_str(rest); + out +} + +/// Whether every letter in the line is a capital — the test that makes an +/// unadorned line a character cue or a transition. Lines with no letters at all +/// (a row of dashes, say) are not uppercase for this purpose. +fn is_upper(s: &str) -> bool { + let mut saw_letter = false; + for c in s.chars() { + if c.is_alphabetic() { + saw_letter = true; + if c.is_lowercase() { + return false; + } + } + } + saw_letter +} + +/// Prefixes that make a line a scene heading without a forcing `.`. +const SCENE_PREFIXES: &[&str] = &[ + "int./ext.", "int/ext.", "int./ext", "int/ext", "i/e.", "i/e", "int.", "int ", "ext.", + "ext ", "est.", "est ", +]; + +/// Whether `line` opens with one of the conventional scene-heading prefixes. +fn is_scene_heading(line: &str) -> bool { + let lower = line.trim().to_ascii_lowercase(); + SCENE_PREFIXES.iter().any(|p| lower.starts_with(p)) +} + +/// Strip a trailing `(V.O.)`-style extension from a character cue, so the +/// uppercase test that identifies the cue is not defeated by the very common +/// lowercase `(cont'd)`. +fn without_extension(line: &str) -> &str { + let trimmed = line.trim_end(); + if trimmed.ends_with(')') { + if let Some(open) = trimmed.rfind('(') { + return trimmed[..open].trim_end(); + } + } + trimmed +} + +/// Whether `line` is a transition: all capitals and ending in `TO:`, or one of +/// the handful of standard closers that do not. +fn is_transition(line: &str) -> bool { + let t = line.trim(); + if !is_upper(t) { + return false; + } + if t.ends_with("TO:") { + return true; + } + matches!( + t.trim_end_matches('.'), + "FADE OUT" | "FADE TO BLACK" | "CUT TO BLACK" | "THE END" | "IRIS OUT" | "WIPE OUT" + ) +} + +/// Parse one line's inline emphasis into spans. +/// +/// `*` and `_` toggle; a run of one to three asterisks sets italic, bold, or +/// both. A backslash escapes the following marker so it prints literally, which +/// is how a screenplay writes an actual asterisk. +pub fn spans(line: &str) -> Vec { + let mut out: Vec = Vec::new(); + let (mut bold, mut italic, mut underline) = (false, false, false); + let mut buf = String::new(); + let chars: Vec = line.chars().collect(); + let mut i = 0; + + let mut flush = |buf: &mut String, bold, italic, underline| { + if !buf.is_empty() { + out.push(Span { + text: std::mem::take(buf), + bold, + italic, + underline, + }); + } + }; + + while i < chars.len() { + match chars[i] { + '\\' if i + 1 < chars.len() => { + buf.push(chars[i + 1]); + i += 2; + } + '_' => { + flush(&mut buf, bold, italic, underline); + underline = !underline; + i += 1; + } + '*' => { + let run = chars[i..].iter().take_while(|c| **c == '*').count().min(3); + flush(&mut buf, bold, italic, underline); + match run { + 1 => italic = !italic, + 2 => bold = !bold, + _ => { + bold = !bold; + italic = !italic; + } + } + i += run; + } + c => { + buf.push(c); + i += 1; + } + } + } + flush(&mut buf, bold, italic, underline); + out +} + +/// Add a line of action, continuing the paragraph in progress when the source +/// line before it was one too. +fn push_action(out: &mut Vec, line: Vec, continues: bool) { + if continues { + if let Some(Element::Action { + lines, + centered: false, + }) = out.last_mut() + { + lines.push(line); + return; + } + } + out.push(Element::Action { + lines: vec![line], + centered: false, + }); +} + +/// Parse a Fountain body into its elements. +pub fn parse(text: &str) -> Vec { + let cleaned = strip_notes(&strip_boneyard(&text.replace("\r\n", "\n"))); + let lines: Vec<&str> = cleaned.lines().collect(); + let mut out: Vec = Vec::new(); + // Whether the previous line was blank. A great deal of Fountain turns on + // this: the same words are a character cue after a blank line and a line of + // action in the middle of a paragraph. + let mut after_blank = true; + // Whether we are inside a dialogue block, where an unadorned line is + // dialogue rather than action. + let mut in_dialogue = false; + let mut i = 0; + + while i < lines.len() { + let raw = lines[i]; + let line = raw.trim_end(); + let trimmed = line.trim(); + let next_blank = lines.get(i + 1).map(|l| l.trim().is_empty()).unwrap_or(true); + + if trimmed.is_empty() { + after_blank = true; + in_dialogue = false; + i += 1; + continue; + } + + // --- Forced and unambiguous forms, which need no context ------------ + if trimmed.chars().all(|c| c == '=') && trimmed.len() >= 3 { + out.push(Element::PageBreak); + after_blank = false; + in_dialogue = false; + i += 1; + continue; + } + if let Some(rest) = trimmed.strip_prefix('=') { + out.push(Element::Synopsis(spans(rest.trim()))); + after_blank = false; + in_dialogue = false; + i += 1; + continue; + } + if trimmed.starts_with('#') { + let level = trimmed.chars().take_while(|c| *c == '#').count().min(6) as u8; + out.push(Element::Section { + level, + text: spans(trimmed[level as usize..].trim()), + }); + after_blank = false; + in_dialogue = false; + i += 1; + continue; + } + // `> centered <` before the plain `>` transition, since both open the + // same way and only the trailing `<` tells them apart. + if trimmed.starts_with('>') && trimmed.ends_with('<') && trimmed.len() >= 2 { + let inner = trimmed[1..trimmed.len() - 1].trim(); + out.push(Element::Action { + lines: vec![spans(inner)], + centered: true, + }); + after_blank = false; + in_dialogue = false; + i += 1; + continue; + } + if let Some(rest) = trimmed.strip_prefix('>') { + out.push(Element::Transition(spans(rest.trim()))); + after_blank = false; + in_dialogue = false; + i += 1; + continue; + } + if let Some(rest) = trimmed.strip_prefix('~') { + out.push(Element::Lyric(spans(rest.trim()))); + after_blank = false; + in_dialogue = true; + i += 1; + continue; + } + // A forcing `.` must not be `..`, which is how a line of action that + // genuinely opens with an ellipsis escapes being read as a heading. + if trimmed.starts_with('.') && !trimmed.starts_with("..") && trimmed.len() > 1 { + out.push(Element::SceneHeading(spans(trimmed[1..].trim()))); + after_blank = false; + in_dialogue = false; + i += 1; + continue; + } + if let Some(rest) = trimmed.strip_prefix('!') { + push_action(&mut out, spans(rest), !after_blank); + after_blank = false; + in_dialogue = false; + i += 1; + continue; + } + if let Some(rest) = trimmed.strip_prefix('@') { + let (name, dual) = match rest.trim().strip_suffix('^') { + Some(n) => (n.trim(), true), + None => (rest.trim(), false), + }; + out.push(Element::Character { + name: spans(name), + dual, + }); + after_blank = false; + in_dialogue = true; + i += 1; + continue; + } + + // --- Context-sensitive forms --------------------------------------- + if after_blank && is_scene_heading(trimmed) { + out.push(Element::SceneHeading(spans(trimmed))); + after_blank = false; + in_dialogue = false; + i += 1; + continue; + } + // A transition stands alone: blank above and blank below. Without the + // second test, a character cue like `CUT TO:` would be misread whenever + // dialogue happened to follow it. + if after_blank && next_blank && is_transition(trimmed) { + out.push(Element::Transition(spans(trimmed))); + after_blank = false; + in_dialogue = false; + i += 1; + continue; + } + // A character cue is capitals with something directly beneath it. + if after_blank && !next_blank && is_upper(without_extension(trimmed)) { + let body = trimmed.trim_end(); + let (name, dual) = match body.strip_suffix('^') { + Some(n) => (n.trim_end(), true), + None => (body, false), + }; + out.push(Element::Character { + name: spans(name), + dual, + }); + after_blank = false; + in_dialogue = true; + i += 1; + continue; + } + if in_dialogue { + if trimmed.starts_with('(') && trimmed.ends_with(')') { + out.push(Element::Parenthetical(spans(trimmed))); + } else { + out.push(Element::Dialogue(spans(trimmed))); + } + after_blank = false; + i += 1; + continue; + } + + // --- Action, which is everything left ------------------------------ + push_action(&mut out, spans(line), !after_blank); + after_blank = false; + i += 1; + } + + out +} + +/// The flat text of a run of spans, with the emphasis discarded. +pub fn text_of(spans: &[Span]) -> String { + spans.iter().map(|s| s.text.as_str()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The text of every element, tagged with its kind, which is what most of + /// these tests want to assert on. + fn shape(text: &str) -> Vec<(String, String)> { + parse(text) + .into_iter() + .map(|e| { + let kind = match &e { + Element::SceneHeading(_) => "scene", + Element::Action { centered: true, .. } => "centered", + Element::Action { .. } => "action", + Element::Character { dual: true, .. } => "character^", + Element::Character { .. } => "character", + Element::Parenthetical(_) => "paren", + Element::Dialogue(_) => "dialogue", + Element::Lyric(_) => "lyric", + Element::Transition(_) => "transition", + Element::Section { .. } => "section", + Element::Synopsis(_) => "synopsis", + Element::PageBreak => "pagebreak", + }; + let body = match &e { + Element::Action { lines, .. } => lines + .iter() + .map(|l| text_of(l)) + .collect::>() + .join(" / "), + Element::SceneHeading(s) + | Element::Character { name: s, .. } + | Element::Parenthetical(s) + | Element::Dialogue(s) + | Element::Lyric(s) + | Element::Transition(s) + | Element::Section { text: s, .. } + | Element::Synopsis(s) => text_of(s), + Element::PageBreak => String::new(), + }; + (kind.to_string(), body) + }) + .collect() + } + + /// A short scene exercising the elements a writer actually uses, with none + /// of the forcing characters — everything identified from context alone. + #[test] + fn a_plain_scene_parses_into_its_elements() { + let script = "\ +INT. KITCHEN - NIGHT + +Bixby stands at the sink, not washing anything. + +BIXBY +(quietly) +I said I'd do it. + +He does not do it. + +CUT TO: +"; + assert_eq!( + shape(script), + [ + ("scene".to_string(), "INT. KITCHEN - NIGHT".to_string()), + ( + "action".to_string(), + "Bixby stands at the sink, not washing anything.".to_string() + ), + ("character".to_string(), "BIXBY".to_string()), + ("paren".to_string(), "(quietly)".to_string()), + ("dialogue".to_string(), "I said I'd do it.".to_string()), + ("action".to_string(), "He does not do it.".to_string()), + ("transition".to_string(), "CUT TO:".to_string()), + ] + ); + } + + /// Every scene-heading prefix in common use, plus the forcing dot for a + /// heading that starts with none of them. + #[test] + fn scene_headings_are_recognised_however_they_open() { + for opener in [ + "INT. HOUSE", + "EXT. STREET - DAY", + "EST. THE VALLEY", + "INT./EXT. CAR", + "I/E. CAR - MOVING", + "int. lowercase still counts", + ] { + let got = shape(&format!("{opener}\n\nSomething happens.\n")); + assert_eq!(got[0].0, "scene", "{opener} should be a scene heading"); + } + // A forced heading loses its dot; a doubled dot is action, which is how + // a line opening with an ellipsis escapes. + assert_eq!( + shape(".BLACK SCREEN\n"), + [("scene".to_string(), "BLACK SCREEN".to_string())] + ); + assert_eq!( + shape("...and then nothing.\n"), + [("action".to_string(), "...and then nothing.".to_string())] + ); + } + + /// The uppercase test that finds a character cue must survive the very + /// common lowercase `(cont'd)`, and must not fire on a lone capitalised + /// line of action with a blank beneath it. + #[test] + fn a_character_cue_needs_capitals_and_something_beneath_it() { + assert_eq!( + shape("MOM (cont'd)\nEat something.\n"), + [ + ("character".to_string(), "MOM (cont'd)".to_string()), + ("dialogue".to_string(), "Eat something.".to_string()), + ] + ); + // Nothing beneath it: capitals in the middle of action, not a cue. + assert_eq!( + shape("THE HOUSE BURNS DOWN.\n\nLater.\n"), + [ + ("action".to_string(), "THE HOUSE BURNS DOWN.".to_string()), + ("action".to_string(), "Later.".to_string()), + ] + ); + // A forced cue works where the automatic test would not. + assert_eq!( + shape("@McCLANE\nYippee.\n"), + [ + ("character".to_string(), "McCLANE".to_string()), + ("dialogue".to_string(), "Yippee.".to_string()), + ] + ); + } + + /// `CUT TO:` with dialogue under it is a character cue, not a transition — + /// the blank line beneath is what makes a transition a transition. + #[test] + fn a_transition_must_stand_alone() { + assert_eq!( + shape("SMASH CUT TO:\n\nEXT. FIELD - DAY\n"), + [ + ("transition".to_string(), "SMASH CUT TO:".to_string()), + ("scene".to_string(), "EXT. FIELD - DAY".to_string()), + ] + ); + assert_eq!(shape("CUT TO:\nWhat he says next.\n")[0].0, "character"); + // Forced, it is a transition wherever it sits. + assert_eq!( + shape("> Burn to white.\n"), + [("transition".to_string(), "Burn to white.".to_string())] + ); + // And the standard closers that do not end in "TO:". + assert_eq!(shape("FADE OUT.\n")[0].0, "transition"); + } + + #[test] + fn dual_dialogue_is_marked_on_the_second_cue() { + let got = shape("BIXBY\nNo.\n\nMOM ^\nYes.\n"); + assert_eq!(got[0].0, "character"); + assert_eq!(got[2].0, "character^"); + assert_eq!(got[2].1, "MOM"); + } + + #[test] + fn centred_text_transitions_and_page_breaks_are_distinguished() { + assert_eq!( + shape("> THE END <\n"), + [("centered".to_string(), "THE END".to_string())] + ); + assert_eq!(shape("===\n"), [("pagebreak".to_string(), String::new())]); + assert_eq!(shape("=====\n"), [("pagebreak".to_string(), String::new())]); + // A single `=` is a synopsis, not a page break. + assert_eq!( + shape("= She finally says it.\n"), + [("synopsis".to_string(), "She finally says it.".to_string())] + ); + } + + #[test] + fn sections_and_synopses_are_kept_but_not_printed() { + let els = parse("# Act One\n\n## Sequence A\n\n= the turn\n\nShe waits.\n"); + assert_eq!(els.len(), 4); + assert!(matches!(els[0], Element::Section { level: 1, .. })); + assert!(matches!(els[1], Element::Section { level: 2, .. })); + assert!(!els[0].is_printed()); + assert!(!els[2].is_printed()); + assert!(els[3].is_printed(), "action reaches the page"); + } + + #[test] + fn notes_and_the_boneyard_are_removed() { + let got = shape("She waits. [[check this]]\n\n/* cut for now\nAll of it.\n*/\nEnd.\n"); + assert_eq!( + got, + [ + ("action".to_string(), "She waits.".to_string()), + ("action".to_string(), "End.".to_string()), + ] + ); + } + + #[test] + fn an_unterminated_boneyard_swallows_the_rest() { + assert_eq!( + shape("Kept.\n\n/* dropped\nalso dropped\n"), + [("action".to_string(), "Kept.".to_string())] + ); + } + + #[test] + fn emphasis_becomes_spans() { + let s = spans("A *word* and **another** and ***both*** and _under_."); + let it = s.iter().find(|s| s.text == "word").unwrap(); + assert!(it.italic && !it.bold); + let b = s.iter().find(|s| s.text == "another").unwrap(); + assert!(b.bold && !b.italic); + let both = s.iter().find(|s| s.text == "both").unwrap(); + assert!(both.bold && both.italic); + let u = s.iter().find(|s| s.text == "under").unwrap(); + assert!(u.underline); + // The flattened text is the line without its markers. + assert_eq!( + text_of(&s), + "A word and another and both and under." + ); + } + + #[test] + fn an_escaped_marker_prints_literally() { + let s = spans(r"5 \* 3 is not emphasis"); + assert_eq!(text_of(&s), "5 * 3 is not emphasis"); + assert!(s.iter().all(|s| !s.italic && !s.bold)); + } + + /// Consecutive lines are one paragraph, and keep the breaks the writer + /// typed: they are pacing. A blank line starts a new paragraph. + #[test] + fn consecutive_action_lines_form_one_paragraph() { + let got = shape("He stands.\nHe sits.\n\nHe stands again.\n"); + assert_eq!( + got, + [ + ("action".to_string(), "He stands. / He sits.".to_string()), + ("action".to_string(), "He stands again.".to_string()), + ] + ); + let Element::Action { lines, .. } = &parse("One.\nTwo.\nThree.\n")[0] else { + panic!("action") + }; + assert_eq!(lines.len(), 3, "each source line is kept"); + } + + /// A blank line ends a dialogue block, so the line after it is action + /// again rather than more dialogue. + #[test] + fn a_blank_line_closes_a_dialogue_block() { + let got = shape("BIXBY\nOne.\nTwo.\n\nHe leaves.\n"); + assert_eq!( + got, + [ + ("character".to_string(), "BIXBY".to_string()), + ("dialogue".to_string(), "One.".to_string()), + ("dialogue".to_string(), "Two.".to_string()), + ("action".to_string(), "He leaves.".to_string()), + ] + ); + } + + #[test] + fn windows_line_endings_parse_the_same() { + assert_eq!( + shape("INT. ROOM - DAY\r\n\r\nShe waits.\r\n"), + shape("INT. ROOM - DAY\n\nShe waits.\n") + ); + } + + #[test] + fn an_empty_body_yields_nothing() { + assert!(parse("").is_empty()); + assert!(parse("\n\n \n").is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs index 1dd984e..3ad7000 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod app; mod characters; mod config; mod cookiecutter; +mod fountain; mod gitsync; mod help; mod langtool; @@ -13,7 +14,9 @@ mod mistral; mod odt; mod outline; mod order; +mod pdf; mod preprocess; +mod screenplay; mod spell; use eframe::egui; diff --git a/src/pdf.rs b/src/pdf.rs new file mode 100644 index 0000000..4052e94 --- /dev/null +++ b/src/pdf.rs @@ -0,0 +1,473 @@ +//! A very small PDF writer, enough to typeset a screenplay. +//! +//! This exists for the same reason `crate::odt` does: so an export needs no +//! `pandoc` and no LibreOffice at runtime. It is not a general PDF library and +//! does not try to be. A screenplay is set entirely in Courier, which is one of +//! the fourteen faces every PDF reader is required to provide, so there is no +//! font to embed and no font metrics to parse — the one measurement that +//! matters is that Courier is monospaced at [`ADVANCE`] of the point size. +//! +//! Content streams are written uncompressed. A feature-length screenplay comes +//! to a few hundred kilobytes that way, which is small enough not to care +//! about, and it keeps the output readable in a text editor when something +//! needs debugging. + +use std::io::Write; +use std::path::Path; + +/// Width of one Courier character as a fraction of the font size. Courier's +/// glyphs are all 600 units wide on a 1000-unit em, so 12pt Courier advances +/// 7.2pt per character — exactly ten characters to the inch, which is what the +/// whole geometry of a screenplay page is built on. +pub const ADVANCE: f32 = 0.6; + +/// One point, as a fraction of an inch — the unit PDF measures in. +pub const INCH: f32 = 72.0; + +/// Which face of Courier to set a run in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Face { + Regular, + Bold, + Italic, + BoldItalic, +} + +impl Face { + /// The face carrying `bold` and `italic` together. + pub fn of(bold: bool, italic: bool) -> Face { + match (bold, italic) { + (false, false) => Face::Regular, + (true, false) => Face::Bold, + (false, true) => Face::Italic, + (true, true) => Face::BoldItalic, + } + } + + /// The resource name this face is bound to in a page's font dictionary. + fn resource(self) -> &'static str { + match self { + Face::Regular => "F1", + Face::Bold => "F2", + Face::Italic => "F3", + Face::BoldItalic => "F4", + } + } + + /// The base-14 font this face maps to. + fn base_font(self) -> &'static str { + match self { + Face::Regular => "Courier", + Face::Bold => "Courier-Bold", + Face::Italic => "Courier-Oblique", + Face::BoldItalic => "Courier-BoldOblique", + } + } +} + +/// Map one character to its WinAnsi (CP1252) byte. +/// +/// The base-14 fonts are single-byte encoded, so anything outside CP1252 has no +/// glyph to point at. Everything a manuscript realistically contains is in +/// range — the curly quotes, dashes and ellipsis a word processor inserts live +/// in the 0x80..0x9F block that distinguishes CP1252 from Latin-1 — and the +/// rare character that is not degrades to a question mark rather than +/// corrupting the stream. +fn winansi(c: char) -> u8 { + match c { + '\u{20AC}' => 0x80, + '\u{201A}' => 0x82, + '\u{0192}' => 0x83, + '\u{201E}' => 0x84, + '\u{2026}' => 0x85, + '\u{2020}' => 0x86, + '\u{2021}' => 0x87, + '\u{02C6}' => 0x88, + '\u{2030}' => 0x89, + '\u{0160}' => 0x8A, + '\u{2039}' => 0x8B, + '\u{0152}' => 0x8C, + '\u{017D}' => 0x8E, + '\u{2018}' => 0x91, + '\u{2019}' => 0x92, + '\u{201C}' => 0x93, + '\u{201D}' => 0x94, + '\u{2022}' => 0x95, + '\u{2013}' => 0x96, + '\u{2014}' => 0x97, + '\u{02DC}' => 0x98, + '\u{2122}' => 0x99, + '\u{0161}' => 0x9A, + '\u{203A}' => 0x9B, + '\u{0153}' => 0x9C, + '\u{017E}' => 0x9E, + '\u{0178}' => 0x9F, + c if (c as u32) < 0x80 || ((c as u32) >= 0xA0 && (c as u32) <= 0xFF) => c as u32 as u8, + _ => b'?', + } +} + +/// Encode a string as a PDF literal string, escaping the three characters that +/// would otherwise end it or be read as an escape. +fn pdf_string(s: &str) -> Vec { + let mut out = vec![b'(']; + for c in s.chars() { + let b = winansi(c); + if b == b'(' || b == b')' || b == b'\\' { + out.push(b'\\'); + } + out.push(b); + } + out.push(b')'); + out +} + +/// The drawing operations for one page, in PDF content-stream syntax. +/// +/// Coordinates are in points from the bottom-left corner of the page, which is +/// PDF's own convention; the screenplay layout converts from its line grid. +#[derive(Default)] +pub struct PageContent { + ops: Vec, +} + +impl PageContent { + pub fn new() -> PageContent { + PageContent::default() + } + + /// The raw content stream built so far, which is what gets written into + /// the page's stream object. Exposed so a layout can be asserted on + /// without rendering and re-parsing a whole document. + #[cfg(test)] + pub fn ops(&self) -> &[u8] { + &self.ops + } + + /// Set `text` with its baseline at (`x`, `y`). + pub fn text(&mut self, x: f32, y: f32, face: Face, size: f32, text: &str) { + if text.is_empty() { + return; + } + let _ = write!( + self.ops, + "BT /{} {} Tf {:.2} {:.2} Td ", + face.resource(), + size, + x, + y + ); + self.ops.extend_from_slice(&pdf_string(text)); + self.ops.extend_from_slice(b" Tj ET\n"); + } + + /// Fill a rectangle — used to rule underlines, which Courier has no face + /// for and which therefore have to be drawn. + pub fn rule(&mut self, x: f32, y: f32, width: f32, height: f32) { + let _ = write!( + self.ops, + "{:.2} {:.2} {:.2} {:.2} re f\n", + x, y, width, height + ); + } +} + +/// A document being assembled, one page at a time. +pub struct Pdf { + width: f32, + height: f32, + pages: Vec, + /// Written into the document information dictionary, which is what a + /// reader shows in its properties panel and its window title. + pub title: String, + pub author: String, +} + +impl Pdf { + /// A document of the given page size, in points. + pub fn new(width: f32, height: f32) -> Pdf { + Pdf { + width, + height, + pages: Vec::new(), + title: String::new(), + author: String::new(), + } + } + + /// US Letter, the size a screenplay is submitted on. + pub fn letter() -> Pdf { + Pdf::new(8.5 * INCH, 11.0 * INCH) + } + + pub fn add_page(&mut self, page: PageContent) { + self.pages.push(page); + } + + /// Serialise the document. + /// + /// Objects are laid down in order and their byte offsets recorded as they + /// go, because the cross-reference table at the end has to name the exact + /// offset of every one of them — get that wrong and readers reject the + /// file outright. + pub fn to_bytes(&self) -> Vec { + // A document with no pages is still a valid PDF, but a reader will not + // open one with an empty page tree, so guarantee at least a blank leaf. + let blank = [PageContent::new()]; + let pages: &[PageContent] = if self.pages.is_empty() { + &blank + } else { + &self.pages + }; + + // Fixed object numbers, then two per page (the page, then its stream). + const CATALOG: usize = 1; + const PAGE_TREE: usize = 2; + const FIRST_FONT: usize = 3; + const INFO: usize = 7; + let first_page = 8; + + let mut out: Vec = Vec::new(); + // Offset 0 is the free-list head, which never names a real object. + let mut offsets: Vec = vec![0]; + out.extend_from_slice(b"%PDF-1.7\n"); + // A comment of high bytes, which tells anything transferring the file + // that it is binary and must not be line-ending translated. + out.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n"); + + let object = |out: &mut Vec, offsets: &mut Vec, body: &[u8]| { + offsets.push(out.len()); + let n = offsets.len() - 1; + let _ = write!(out, "{n} 0 obj\n"); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + }; + + let kids: String = (0..pages.len()) + .map(|i| format!("{} 0 R", first_page + i * 2)) + .collect::>() + .join(" "); + + object( + &mut out, + &mut offsets, + format!("<< /Type /Catalog /Pages {PAGE_TREE} 0 R >>").as_bytes(), + ); + object( + &mut out, + &mut offsets, + format!( + "<< /Type /Pages /Count {} /Kids [{}] >>", + pages.len(), + kids + ) + .as_bytes(), + ); + for face in [Face::Regular, Face::Bold, Face::Italic, Face::BoldItalic] { + object( + &mut out, + &mut offsets, + format!( + "<< /Type /Font /Subtype /Type1 /BaseFont /{} \ + /Encoding /WinAnsiEncoding >>", + face.base_font() + ) + .as_bytes(), + ); + } + let mut info = Vec::from(&b"<< /Producer (md-manuscript)"[..]); + if !self.title.trim().is_empty() { + info.extend_from_slice(b" /Title "); + info.extend_from_slice(&pdf_string(self.title.trim())); + } + if !self.author.trim().is_empty() { + info.extend_from_slice(b" /Author "); + info.extend_from_slice(&pdf_string(self.author.trim())); + } + info.extend_from_slice(b" >>"); + object(&mut out, &mut offsets, &info); + debug_assert_eq!(offsets.len() - 1, INFO, "fixed object numbering drifted"); + + let fonts: String = [Face::Regular, Face::Bold, Face::Italic, Face::BoldItalic] + .iter() + .enumerate() + .map(|(i, f)| format!("/{} {} 0 R", f.resource(), FIRST_FONT + i)) + .collect::>() + .join(" "); + + for (i, page) in pages.iter().enumerate() { + let stream = first_page + i * 2 + 1; + object( + &mut out, + &mut offsets, + format!( + "<< /Type /Page /Parent {PAGE_TREE} 0 R \ + /MediaBox [0 0 {:.2} {:.2}] \ + /Resources << /Font << {fonts} >> >> \ + /Contents {stream} 0 R >>", + self.width, self.height + ) + .as_bytes(), + ); + let mut body = format!("<< /Length {} >>\nstream\n", page.ops.len()).into_bytes(); + body.extend_from_slice(&page.ops); + body.extend_from_slice(b"endstream"); + object(&mut out, &mut offsets, &body); + } + + let xref_at = out.len(); + let count = offsets.len(); + let _ = write!(out, "xref\n0 {count}\n"); + // Every entry is exactly twenty bytes, including the two-byte ending. + out.extend_from_slice(b"0000000000 65535 f \n"); + for off in &offsets[1..] { + let _ = write!(out, "{off:010} 00000 n \n"); + } + let _ = write!( + out, + "trailer\n<< /Size {count} /Root {CATALOG} 0 R /Info {INFO} 0 R >>\n\ + startxref\n{xref_at}\n%%EOF\n" + ); + out + } + + /// Write the document to `path`. + pub fn write(&self, path: &Path) -> std::io::Result<()> { + std::fs::write(path, self.to_bytes()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Pdf { + let mut pdf = Pdf::letter(); + pdf.title = "The Winter Gate".to_string(); + pdf.author = "A. Writer".to_string(); + let mut p = PageContent::new(); + p.text(108.0, 700.0, Face::Regular, 12.0, "INT. KITCHEN - NIGHT"); + p.text(108.0, 676.0, Face::Bold, 12.0, "Bold action."); + p.rule(108.0, 672.0, 72.0, 0.6); + pdf.add_page(p); + pdf + } + + #[test] + fn a_document_has_the_structure_a_reader_expects() { + let bytes = sample().to_bytes(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("%PDF-1.7\n")); + assert!(text.ends_with("%%EOF\n")); + assert!(text.contains("/Type /Catalog")); + assert!(text.contains("/Type /Pages /Count 1")); + assert!(text.contains("/BaseFont /Courier ")); + assert!(text.contains("/BaseFont /Courier-BoldOblique")); + assert!(text.contains("/Title (The Winter Gate)")); + assert!(text.contains("/Author (A. Writer)")); + assert!(text.contains("(INT. KITCHEN - NIGHT) Tj")); + } + + /// The cross-reference table is the part a reader trusts absolutely: each + /// entry must be the true byte offset of the object it names, and each must + /// be exactly twenty bytes wide. + /// + /// Worked on raw bytes throughout. The header carries a comment of + /// deliberately invalid UTF-8 -- that is its whole purpose, to mark the file + /// binary -- so decoding the document to a `String` first would shift every + /// index past it and the offsets would appear wrong when they are right. + #[test] + fn every_cross_reference_offset_lands_on_its_object() { + let bytes = sample().to_bytes(); + + fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) + } + + let marker = b"startxref\n"; + let at = find(&bytes, marker).expect("a startxref keyword") + marker.len(); + let digits: String = bytes[at..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .map(|b| *b as char) + .collect(); + let xref_at: usize = digits.parse().expect("a startxref offset"); + assert_eq!(&bytes[xref_at..xref_at + 4], b"xref"); + + // `xref\n`, then a `0 \n` subsection header, then the entries. + let header_at = xref_at + 5; + let header_len = bytes[header_at..] + .iter() + .position(|b| *b == b'\n') + .expect("a subsection header"); + let header: String = bytes[header_at..header_at + header_len] + .iter() + .map(|b| *b as char) + .collect(); + let count: usize = header.split_whitespace().nth(1).unwrap().parse().unwrap(); + let entries_at = header_at + header_len + 1; + + // Entry 0 is the free-list head; every other must point at " 0 obj". + for n in 1..count { + let entry = &bytes[entries_at + n * 20..entries_at + (n + 1) * 20]; + assert_eq!(&entry[16..], b" n \n", "entry {n} is malformed"); + let off: usize = std::str::from_utf8(&entry[..10]) + .unwrap() + .parse() + .expect("a numeric offset"); + let expected = format!("{n} 0 obj"); + assert_eq!( + &bytes[off..off + expected.len()], + expected.as_bytes(), + "object {n} is not at offset {off}" + ); + } + } + + #[test] + fn each_page_adds_a_leaf_to_the_tree() { + let mut pdf = Pdf::letter(); + for _ in 0..3 { + let mut p = PageContent::new(); + p.text(72.0, 72.0, Face::Regular, 12.0, "x"); + pdf.add_page(p); + } + let text = String::from_utf8_lossy(&pdf.to_bytes()).to_string(); + assert!(text.contains("/Count 3")); + assert!(text.contains("/Kids [8 0 R 10 0 R 12 0 R]")); + } + + /// The three characters that would otherwise break out of a literal string. + #[test] + fn parentheses_and_backslashes_are_escaped() { + let s = String::from_utf8(pdf_string(r"a (b) c \ d")).unwrap(); + assert_eq!(s, r"(a \(b\) c \\ d)"); + } + + /// Curly quotes and dashes are what a word processor leaves in prose, and + /// they all have WinAnsi code points. + #[test] + fn typographic_punctuation_survives_the_encoding() { + let bytes = pdf_string("\u{201C}Don\u{2019}t,\u{201D} she said \u{2014} then\u{2026}"); + assert!(bytes.contains(&0x93) && bytes.contains(&0x94), "curly quotes"); + assert!(bytes.contains(&0x92), "apostrophe"); + assert!(bytes.contains(&0x97), "em dash"); + assert!(bytes.contains(&0x85), "ellipsis"); + // Something with no CP1252 glyph degrades rather than corrupting. + assert_eq!(pdf_string("\u{4E2D}"), b"(?)".to_vec()); + } + + /// Ten characters to the inch is the measurement the page grid rests on. + #[test] + fn courier_advances_ten_characters_to_the_inch_at_twelve_point() { + assert!((ADVANCE * 12.0 - 7.2).abs() < 1e-6); + assert!((INCH / (ADVANCE * 12.0) - 10.0).abs() < 1e-6); + } + + #[test] + fn a_document_with_no_pages_still_opens() { + let text = String::from_utf8_lossy(&Pdf::letter().to_bytes()).to_string(); + assert!(text.contains("/Count 1"), "a blank leaf stands in"); + assert!(text.ends_with("%%EOF\n")); + } +} diff --git a/src/screenplay.rs b/src/screenplay.rs new file mode 100644 index 0000000..32c3977 --- /dev/null +++ b/src/screenplay.rs @@ -0,0 +1,1158 @@ +//! Screenplay layout: Fountain elements onto paginated PDF pages. +//! +//! Screenplay format is rigid in a way that makes it easy to typeset and +//! obvious when it is wrong. Everything is 12pt Courier on US Letter, which +//! advances exactly ten characters to the inch, so every measurement below is +//! a whole number of characters and a whole number of lines. A reader who +//! knows the form reads a page as roughly a minute of screen time, and that +//! only holds if the geometry is the conventional one: +//! +//! ```text +//! 1.5" 2.5" 3.1" 3.7" 7.5" +//! | | | | | +//! scene INT. KITCHEN - NIGHT | +//! action Bixby stands at the sink. | +//! character BIXBY | +//! parenthetical (quietly) | +//! dialogue I said I'd do it. | +//! transition CUT TO: +//! ``` +//! +//! Sections and synopses are the writer's scaffolding and never reach the +//! page; everything else does. + +use crate::fountain::{self, Element, Span}; +use crate::pdf::{Face, PageContent, Pdf, ADVANCE, INCH}; +use std::path::Path; + +/// Type size, and the line height that goes with it. A screenplay is set solid +/// — one line of leading per line of type — and the page count depends on it. +const SIZE: f32 = 12.0; +const LINE: f32 = 12.0; +/// Width of one character: a tenth of an inch, at this size. +const CHAR: f32 = SIZE * ADVANCE; +/// Courier's descender, used to drop a baseline to the bottom of its line. +const DESCENDER: f32 = SIZE * 0.157; + +/// Lines of type on a full page: 9 inches of body between one-inch margins. +const LINES_PER_PAGE: usize = 55; + +const TOP: f32 = INCH; +const LEFT: f32 = 1.5 * INCH; +const RIGHT_EDGE: f32 = 7.5 * INCH; + +/// Left edge and width, in characters, of each element. +const ACTION_WIDTH: usize = 60; +const DIALOGUE_LEFT: f32 = 2.5 * INCH; +const DIALOGUE_WIDTH: usize = 35; +const PAREN_LEFT: f32 = 3.1 * INCH; +const PAREN_WIDTH: usize = 26; +const CHARACTER_LEFT: f32 = 3.7 * INCH; +const CHARACTER_WIDTH: usize = 38; + +/// The two columns a dual-dialogue pair is set in, and how wide each runs. +const DUAL_LEFT: [f32; 2] = [1.6 * INCH, 4.6 * INCH]; +const DUAL_WIDTH: usize = 27; +const DUAL_CUE_INDENT: usize = 6; +const DUAL_PAREN_INDENT: usize = 3; + +/// What an export needs to know beyond the script itself. +pub struct ScreenplayMeta { + pub title: String, + pub author: String, + /// Contact details, a line per line, set at the foot of the title page. + pub contact: String, + /// Whether to open with a title page. + pub title_page: bool, +} + +/// A run of text placed at an absolute horizontal position. +#[derive(Debug, Clone)] +struct Run { + x: f32, + spans: Vec, +} + +/// One line of the page grid. Usually a single run; a dual-dialogue row holds +/// one run per column. +#[derive(Debug, Clone, Default)] +struct Line { + runs: Vec, +} + +impl Line { + fn one(x: f32, spans: Vec) -> Line { + Line { + runs: vec![Run { x, spans }], + } + } +} + +/// What a block is, which decides how pagination may treat it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Scene, + Action, + Dialogue, + Transition, + PageBreak, +} + +/// One element's worth of lines, with the blank lines that precede it. +#[derive(Debug, Clone)] +struct Block { + blank_before: usize, + lines: Vec, + kind: Kind, + /// The cue to repeat under a `(MORE)` when a dialogue block is split + /// across a page. `None` marks a block that must not be split at all, + /// which is how a dual-dialogue pair keeps its two columns together. + character: Option, +} + +/// Uppercase every span's text, for the elements printed in capitals. +fn upper(spans: &[Span]) -> Vec { + spans + .iter() + .map(|s| Span { + text: s.text.to_uppercase(), + ..s.clone() + }) + .collect() +} + +/// The character count of a run of spans. +fn width_of(spans: &[Span]) -> usize { + spans.iter().map(|s| s.text.chars().count()).sum() +} + +/// One character carrying the emphasis it was written with, which is what +/// wrapping has to move around: a bold phrase may straddle a line break, and +/// each half has to keep the bold. +#[derive(Clone, Copy)] +struct Styled { + c: char, + bold: bool, + italic: bool, + underline: bool, +} + +/// Wrap spans to `width` characters, greedily, breaking at spaces. +/// +/// A word longer than the measure is broken rather than allowed to run into +/// the margin — that only happens with a URL or a long invented name, and a +/// screenplay has a hard right edge. +fn wrap(spans: &[Span], width: usize) -> Vec> { + let chars: Vec = spans + .iter() + .flat_map(|s| { + s.text.chars().map(move |c| Styled { + c, + bold: s.bold, + italic: s.italic, + underline: s.underline, + }) + }) + .collect(); + if chars.is_empty() { + return vec![Vec::new()]; + } + + let mut lines: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut word: Vec = Vec::new(); + + // Close the line under construction, dropping the trailing space that a + // break makes meaningless. + let end_line = |current: &mut Vec, lines: &mut Vec>| { + while current.last().is_some_and(|s| s.c == ' ') { + current.pop(); + } + lines.push(std::mem::take(current)); + }; + + for ch in chars { + if ch.c == ' ' { + current.append(&mut word); + current.push(ch); + continue; + } + word.push(ch); + // A word that cannot fit any line has to be broken wherever it lands. + if word.len() > width { + current.append(&mut word); + let keep = current.split_off(width.max(1)); + end_line(&mut current, &mut lines); + current = keep; + continue; + } + if current.len() + word.len() > width { + end_line(&mut current, &mut lines); + } + } + current.append(&mut word); + end_line(&mut current, &mut lines); + + // Regroup each line's characters back into the fewest spans that carry the + // same emphasis, so the renderer sets one string per font change. + lines + .into_iter() + .map(|line| { + let mut out: Vec = Vec::new(); + for s in line { + match out.last_mut() { + Some(last) + if last.bold == s.bold + && last.italic == s.italic + && last.underline == s.underline => + { + last.text.push(s.c) + } + _ => out.push(Span { + text: s.c.to_string(), + bold: s.bold, + italic: s.italic, + underline: s.underline, + }), + } + } + out + }) + .collect() +} + +/// Turn parsed elements into positioned blocks. +/// +/// Dual dialogue is folded here rather than at pagination: a cue marked `^` +/// takes the block before it and sets the two side by side as one block, which +/// keeps them from being torn apart by a page boundary. +fn blocks(elements: &[Element]) -> Vec { + let mut out: Vec = Vec::new(); + let mut i = 0; + while i < elements.len() { + let element = &elements[i]; + if !element.is_printed() { + i += 1; + continue; + } + match element { + Element::PageBreak => { + out.push(Block { + blank_before: 0, + lines: Vec::new(), + kind: Kind::PageBreak, + character: None, + }); + i += 1; + } + Element::SceneHeading(spans) => { + let lines = wrap(&upper(spans), ACTION_WIDTH) + .into_iter() + .map(|l| Line::one(LEFT, l)) + .collect(); + out.push(Block { + // Two blank lines set a new scene apart from what precedes it. + blank_before: 2, + lines, + kind: Kind::Scene, + character: None, + }); + i += 1; + } + Element::Action { lines: source, centered } => { + // Each source line is wrapped on its own and the results run + // together, so the breaks the writer typed survive but an + // over-long line still respects the right margin. + let lines = source + .iter() + .flat_map(|line| wrap(line, ACTION_WIDTH)) + .map(|l| { + let x = if *centered { + LEFT + (ACTION_WIDTH.saturating_sub(width_of(&l)) as f32 / 2.0) * CHAR + } else { + LEFT + }; + Line::one(x, l) + }) + .collect(); + out.push(Block { + blank_before: 1, + lines, + kind: Kind::Action, + character: None, + }); + i += 1; + } + Element::Transition(spans) => { + let lines = wrap(&upper(spans), ACTION_WIDTH) + .into_iter() + .map(|l| Line::one(RIGHT_EDGE - width_of(&l) as f32 * CHAR, l)) + .collect(); + out.push(Block { + blank_before: 1, + lines, + kind: Kind::Transition, + character: None, + }); + i += 1; + } + Element::Character { name, dual } => { + let (block, consumed) = dialogue_block(elements, i); + if *dual && !name.is_empty() { + // Pair with the block before, which is the other half. + if let Some(previous) = out.pop().filter(|b| b.kind == Kind::Dialogue) { + out.push(pair(&previous, &block)); + i += consumed; + continue; + } + } + out.push(block); + i += consumed; + } + // A parenthetical, line of dialogue or lyric with no cue above it. + // Fountain allows it; set it where it would have gone. + Element::Parenthetical(spans) => { + out.push(simple(spans, PAREN_LEFT, PAREN_WIDTH, Kind::Dialogue)); + i += 1; + } + Element::Dialogue(spans) => { + out.push(simple(spans, DIALOGUE_LEFT, DIALOGUE_WIDTH, Kind::Dialogue)); + i += 1; + } + Element::Lyric(spans) => { + let italic: Vec = spans + .iter() + .map(|s| Span { + italic: true, + ..s.clone() + }) + .collect(); + out.push(simple(&italic, DIALOGUE_LEFT, DIALOGUE_WIDTH, Kind::Dialogue)); + i += 1; + } + Element::Section { .. } | Element::Synopsis(_) => unreachable!("filtered above"), + } + } + out +} + +/// A one-element block set at a fixed indent. +fn simple(spans: &[Span], left: f32, width: usize, kind: Kind) -> Block { + Block { + blank_before: 1, + lines: wrap(spans, width) + .into_iter() + .map(|l| Line::one(left, l)) + .collect(), + kind, + character: None, + } +} + +/// Collect a character cue and everything spoken under it into one block, +/// returning it with the number of elements consumed. +fn dialogue_block(elements: &[Element], start: usize) -> (Block, usize) { + let Element::Character { name, .. } = &elements[start] else { + unreachable!("called on a character cue") + }; + let cue = upper(name); + let mut lines: Vec = wrap(&cue, CHARACTER_WIDTH) + .into_iter() + .map(|l| Line::one(CHARACTER_LEFT, l)) + .collect(); + + let mut i = start + 1; + while i < elements.len() { + match &elements[i] { + Element::Parenthetical(spans) => { + lines.extend( + wrap(spans, PAREN_WIDTH) + .into_iter() + .map(|l| Line::one(PAREN_LEFT, l)), + ); + } + Element::Dialogue(spans) => { + lines.extend( + wrap(spans, DIALOGUE_WIDTH) + .into_iter() + .map(|l| Line::one(DIALOGUE_LEFT, l)), + ); + } + Element::Lyric(spans) => { + let italic: Vec = spans + .iter() + .map(|s| Span { + italic: true, + ..s.clone() + }) + .collect(); + lines.extend( + wrap(&italic, DIALOGUE_WIDTH) + .into_iter() + .map(|l| Line::one(DIALOGUE_LEFT, l)), + ); + } + _ => break, + } + i += 1; + } + + ( + Block { + blank_before: 1, + lines, + kind: Kind::Dialogue, + character: Some(fountain::text_of(&cue)), + }, + i - start, + ) +} + +/// Re-set one dialogue block into a narrow column at `column`. +fn column(block: &Block, column: usize) -> Vec { + let left = DUAL_LEFT[column]; + block + .lines + .iter() + .flat_map(|line| { + // The lines came out at full width; re-wrap them to the column and + // keep the relative indent that says cue from parenthetical. + let spans: Vec = line.runs.iter().flat_map(|r| r.spans.clone()).collect(); + let indent = match line.runs.first().map(|r| r.x) { + Some(x) if (x - CHARACTER_LEFT).abs() < 0.5 => DUAL_CUE_INDENT, + Some(x) if (x - PAREN_LEFT).abs() < 0.5 => DUAL_PAREN_INDENT, + _ => 0, + }; + wrap(&spans, DUAL_WIDTH - indent) + .into_iter() + .map(move |l| Line::one(left + indent as f32 * CHAR, l)) + }) + .collect() +} + +/// Set two dialogue blocks side by side as a single unsplittable block. +fn pair(left: &Block, right: &Block) -> Block { + let a = column(left, 0); + let b = column(right, 1); + let rows = a.len().max(b.len()); + let mut lines = Vec::with_capacity(rows); + for row in 0..rows { + let mut runs = Vec::new(); + if let Some(l) = a.get(row) { + runs.extend(l.runs.iter().cloned()); + } + if let Some(r) = b.get(row) { + runs.extend(r.runs.iter().cloned()); + } + lines.push(Line { runs }); + } + Block { + blank_before: 1, + lines, + kind: Kind::Dialogue, + // No cue: a split would have to break both columns, so it is refused. + character: None, + } +} + +/// A page under construction: the lines placed on it, by grid row. +#[derive(Default)] +struct Page { + rows: Vec<(usize, Line)>, + used: usize, +} + +/// Break blocks into pages, honouring the conventions that stop a page ending +/// badly: a scene heading never sits alone at the foot of a page, and a +/// dialogue block split across pages is marked `(MORE)` and resumed under a +/// repeated cue. +fn paginate(blocks: &[Block]) -> Vec { + let mut pages: Vec = Vec::new(); + let mut page = Page::default(); + + // How many lines of a dialogue block must survive on each side of a split + // for the split to be worth making, rather than moving the whole block on. + const MIN_SPLIT: usize = 2; + + for block in blocks { + if block.kind == Kind::PageBreak { + pages.push(std::mem::take(&mut page)); + continue; + } + let mut lines: &[Line] = &block.lines; + // Blank lines are separators; they never open a page. + let mut gap = if page.used == 0 { 0 } else { block.blank_before }; + + loop { + let room = LINES_PER_PAGE.saturating_sub(page.used + gap); + // A scene heading must not be left stranded at the foot of a page. + // Keeping one line of what follows it is not enough: every element + // that can follow a heading is separated from it by a blank, so the + // reserve has to cover the blank *and* the line, or the heading is + // placed and its action still lands on the next page. + let need_extra = if block.kind == Kind::Scene { 2 } else { 0 }; + + if lines.len() + need_extra <= room { + place(&mut page, gap, lines); + break; + } + + let can_split = block.character.is_some() + && room >= MIN_SPLIT + 1 // the kept lines, plus the (MORE) + && lines.len() >= MIN_SPLIT * 2 + 1; + if can_split { + // Leave room for the (MORE) that marks the break. + let take = room - 1; + place(&mut page, gap, &lines[..take]); + let more = Line::one(PAREN_LEFT, vec![Span::plain("(MORE)")]); + place(&mut page, 0, std::slice::from_ref(&more)); + pages.push(std::mem::take(&mut page)); + + let cue = block.character.clone().unwrap_or_default(); + let cont = Line::one( + CHARACTER_LEFT, + vec![Span::plain(format!("{cue} (CONT'D)"))], + ); + place(&mut page, 0, std::slice::from_ref(&cont)); + lines = &lines[take..]; + gap = 0; + continue; + } + + // It does not fit and cannot be split: start a fresh page. A block + // taller than a whole page is placed anyway and allowed to run on, + // which only a pathological single element could manage. + if page.used == 0 { + place(&mut page, 0, lines); + break; + } + pages.push(std::mem::take(&mut page)); + gap = 0; + } + } + + if page.used > 0 { + pages.push(page); + } + pages +} + +/// Put `lines` on the page after `gap` blank rows. +fn place(page: &mut Page, gap: usize, lines: &[Line]) { + page.used += gap; + for line in lines { + page.rows.push((page.used, line.clone())); + page.used += 1; + } +} + +/// The baseline of grid row `row`, in points from the bottom of the page. +fn baseline(height: f32, row: usize) -> f32 { + height - TOP - (row as f32 + 1.0) * LINE + DESCENDER +} + +/// Draw one line's runs. +fn draw(content: &mut PageContent, height: f32, row: usize, line: &Line) { + let y = baseline(height, row); + for run in &line.runs { + let mut x = run.x; + for span in &run.spans { + let w = span.text.chars().count() as f32 * CHAR; + content.text(x, y, Face::of(span.bold, span.italic), SIZE, &span.text); + if span.underline && !span.text.trim().is_empty() { + // A rule just below the baseline, clear of the descenders. + content.rule(x, y - 2.0, w, 0.6); + } + x += w; + } + } +} + +/// The title page: title a third of the way down, credit beneath it, contact +/// details at the foot. Unnumbered, and not counted as page one. +fn title_page(meta: &ScreenplayMeta, height: f32) -> Option { + 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 None; + } + let mut content = PageContent::new(); + + let centred = |content: &mut PageContent, row: usize, text: &str, face: Face| { + let x = LEFT + (ACTION_WIDTH.saturating_sub(text.chars().count()) as f32 / 2.0) * CHAR; + content.text(x, baseline(height, row), face, SIZE, text); + }; + + let mut row = LINES_PER_PAGE / 3; + if !title.is_empty() { + let shown = title.to_uppercase(); + centred(&mut content, row, &shown, Face::Bold); + // The title is conventionally underlined; rule it to the same measure. + let width = shown.chars().count() as f32 * CHAR; + let x = LEFT + (ACTION_WIDTH.saturating_sub(shown.chars().count()) as f32 / 2.0) * CHAR; + content.rule(x, baseline(height, row) - 2.5, width, 0.8); + row += 2; + } + if !author.is_empty() { + centred(&mut content, row, "written by", Face::Regular); + row += 2; + centred(&mut content, row, author, Face::Regular); + } + if !contact.is_empty() { + // Far enough down to be clear of the credit, and clear of the margin. + let mut row = LINES_PER_PAGE - 1 - contact.lines().count().min(8); + for line in contact.lines().take(8) { + content.text(LEFT, baseline(height, row), Face::Regular, SIZE, line.trim()); + row += 1; + } + } + Some(content) +} + +/// Lay a script out and write it as a PDF. +/// +/// `bodies` are the Fountain bodies of the manuscript's files in order — one +/// per scene or sequence. They are joined into one continuous script, because +/// a screenplay has no chapter breaks: what divides it is its scene headings. +/// +/// Returns the number of script pages written, not counting the title page — +/// the figure a screenwriter actually wants, since a page is about a minute of +/// screen time. +pub fn export( + bodies: &[String], + meta: &ScreenplayMeta, + out_path: &Path, +) -> std::io::Result { + let script = bodies + .iter() + .map(|b| b.trim()) + .filter(|b| !b.is_empty()) + .collect::>() + .join("\n\n"); + + let mut pdf = Pdf::letter(); + pdf.title = meta.title.clone(); + pdf.author = meta.author.clone(); + let height = 11.0 * INCH; + + if meta.title_page { + if let Some(page) = title_page(meta, height) { + pdf.add_page(page); + } + } + + let pages = paginate(&blocks(&fountain::parse(&script))); + for (i, page) in pages.iter().enumerate() { + let mut content = PageContent::new(); + // Page numbers sit in the top margin, flush right, and the first page + // of a script does not carry one. + if i > 0 { + let label = format!("{}.", i + 1); + content.text( + RIGHT_EDGE - label.chars().count() as f32 * CHAR, + height - 0.5 * INCH, + Face::Regular, + SIZE, + &label, + ); + } + for (row, line) in &page.rows { + draw(&mut content, height, *row, line); + } + pdf.add_page(content); + } + if pages.is_empty() { + pdf.add_page(PageContent::new()); + } + + if let Some(parent) = out_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + pdf.write(out_path)?; + Ok(pages.len().max(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The laid-out text of a line, with its left edge in inches — the two + /// things that decide whether a screenplay page looks right. + fn shape(block: &Block) -> Vec<(String, f32)> { + block + .lines + .iter() + .map(|l| { + let text: String = l + .runs + .iter() + .map(|r| fountain::text_of(&r.spans)) + .collect::>() + .join(" | "); + let x = l.runs.first().map(|r| r.x).unwrap_or(0.0) / INCH; + (text, (x * 100.0).round() / 100.0) + }) + .collect() + } + + fn laid_out(script: &str) -> Vec { + blocks(&fountain::parse(script)) + } + + /// Every element lands at its conventional indent, and the two that are + /// printed in capitals are capitalised. + #[test] + fn each_element_sits_at_its_standard_indent() { + // The scene heading is lower case on purpose: Fountain matches its + // prefix case-insensitively and prints it in capitals. The cue is not, + // because an unforced cue is *defined* as a line of capitals. + let bs = laid_out( + "int. kitchen - night\n\nBixby stands.\n\nBIXBY\n(quietly)\nI said I'd do it.\n\n> cut to:\n", + ); + assert_eq!(shape(&bs[0]), [("INT. KITCHEN - NIGHT".to_string(), 1.5)]); + assert_eq!(shape(&bs[1]), [("Bixby stands.".to_string(), 1.5)]); + assert_eq!( + shape(&bs[2]), + [ + ("BIXBY".to_string(), 3.7), + ("(quietly)".to_string(), 3.1), + ("I said I'd do it.".to_string(), 2.5), + ] + ); + // A transition is flush to the right margin, so its left edge depends + // on its length: 7.5" less seven characters at a tenth of an inch. + assert_eq!(shape(&bs[3]), [("CUT TO:".to_string(), 6.8)]); + } + + /// A hard-wrapped action paragraph sets as consecutive lines. Putting a + /// blank between them would read on the page as a beat the writer never + /// wrote, which is what makes this worth a test of its own. + #[test] + fn a_wrapped_action_paragraph_has_no_blank_lines_inside_it() { + let bs = laid_out("Rain on the window.\nThe tap runs.\n\nHe does not move.\n"); + assert_eq!(bs.len(), 2, "one paragraph, then another"); + assert_eq!( + shape(&bs[0]), + [ + ("Rain on the window.".to_string(), 1.5), + ("The tap runs.".to_string(), 1.5), + ] + ); + assert_eq!(bs[1].blank_before, 1, "but paragraphs are separated"); + + // Laid onto the page, the two lines are adjacent rows. + let page = &paginate(&bs)[0]; + assert_eq!(page.rows[0].0, 0); + assert_eq!(page.rows[1].0, 1, "no blank row between them"); + } + + /// A scene is set off by two blank lines; everything else by one. + #[test] + fn blank_lines_separate_the_elements_conventionally() { + let bs = laid_out("EXT. FIELD - DAY\n\nShe waits.\n\nSHE\nNow.\n"); + assert_eq!(bs[0].blank_before, 2, "a new scene gets two"); + assert_eq!(bs[1].blank_before, 1); + assert_eq!(bs[2].blank_before, 1); + } + + /// Action wraps at sixty characters and dialogue at thirty-five, which is + /// what makes the right edge of a screenplay page straight. + #[test] + fn text_wraps_to_each_elements_measure() { + let long = "word ".repeat(40); + let action = &laid_out(long.trim())[0]; + for (text, _) in shape(action) { + assert!(text.chars().count() <= ACTION_WIDTH, "{text:?} overruns"); + } + assert!(action.lines.len() > 1, "it has to wrap at all"); + + let dialogue = &laid_out(&format!("BIXBY\n{long}"))[0]; + for (text, x) in shape(dialogue).into_iter().skip(1) { + assert!(text.chars().count() <= DIALOGUE_WIDTH, "{text:?} overruns"); + assert_eq!(x, 2.5); + } + } + + /// Emphasis has to survive being wrapped: a bold phrase broken over two + /// lines must still be bold on both. + #[test] + fn emphasis_survives_a_line_break() { + let script = format!("She said **{}** loudly.", "insistent ".repeat(10)); + let block = &laid_out(&script)[0]; + assert!(block.lines.len() > 1); + let bold_runs: usize = block + .lines + .iter() + .flat_map(|l| &l.runs) + .flat_map(|r| &r.spans) + .filter(|s| s.bold && !s.text.trim().is_empty()) + .count(); + assert!(bold_runs >= 2, "bold must carry across the break"); + } + + /// A word too long for the measure is broken rather than allowed to run + /// past the right margin. + #[test] + fn an_over_long_word_is_broken_at_the_margin() { + let block = &laid_out(&"x".repeat(150))[0]; + for (text, _) in shape(block) { + assert!(text.chars().count() <= ACTION_WIDTH); + } + let total: usize = shape(block).iter().map(|(t, _)| t.chars().count()).sum(); + assert_eq!(total, 150, "no characters are lost"); + } + + /// Fifty-five lines to the page, and the fifty-sixth starts a new one. + #[test] + fn a_page_holds_fifty_five_lines() { + // Action lines separated by a blank each occupy two rows. + let script = (0..40) + .map(|i| format!("Line {i}.")) + .collect::>() + .join("\n\n"); + let pages = paginate(&laid_out(&script)); + assert!(pages.len() > 1, "forty spaced lines overrun one page"); + for page in &pages { + assert!(page.used <= LINES_PER_PAGE, "{} rows", page.used); + for (row, _) in &page.rows { + assert!(*row < LINES_PER_PAGE); + } + } + } + + /// A page never opens with the blank line that would have separated the + /// block from what preceded it on the page before. + #[test] + fn a_page_never_opens_with_a_blank_line() { + let script = (0..60) + .map(|i| format!("Line {i}.")) + .collect::>() + .join("\n\n"); + for page in paginate(&laid_out(&script)) { + assert_eq!(page.rows.first().map(|(r, _)| *r), Some(0)); + } + } + + /// A dialogue block broken by a page boundary is marked `(MORE)` and picked + /// up under the cue again, with `(CONT'D)`. + #[test] + fn a_split_dialogue_block_is_marked_more_and_continued() { + // Fill most of a page, then a speech far too long for what is left. + let filler = (0..24) + .map(|i| format!("Line {i}.")) + .collect::>() + .join("\n\n"); + let speech = (0..14) + .map(|i| format!("Sentence number {i} of the speech.")) + .collect::>() + .join("\n"); + let pages = paginate(&laid_out(&format!("{filler}\n\nBIXBY\n{speech}\n"))); + assert!(pages.len() >= 2); + + let text_of_page = |p: &Page| -> String { + p.rows + .iter() + .map(|(_, l)| { + l.runs + .iter() + .map(|r| fountain::text_of(&r.spans)) + .collect::() + }) + .collect::>() + .join("\n") + }; + let first = text_of_page(&pages[0]); + let second = text_of_page(&pages[1]); + assert!(first.trim_end().ends_with("(MORE)"), "got: {first}"); + assert!(second.starts_with("BIXBY (CONT'D)"), "got: {second}"); + // Nothing is lost across the join. + assert!(first.contains("Sentence number 0")); + assert!(second.contains("Sentence number 13")); + } + + /// A scene heading is never left stranded as the last line of a page. + #[test] + fn a_scene_heading_is_not_orphaned_at_the_foot_of_a_page() { + // Tune the filler so a heading would land exactly on the last line. + for n in 20..30 { + let filler = (0..n) + .map(|i| format!("Line {i}.")) + .collect::>() + .join("\n\n"); + let pages = paginate(&laid_out(&format!( + "{filler}\n\nINT. LATER - NIGHT\n\nShe waits.\n" + ))); + for page in &pages { + let last = page.rows.last().map(|(_, l)| { + l.runs + .iter() + .map(|r| fountain::text_of(&r.spans)) + .collect::() + }); + assert_ne!( + last.as_deref(), + Some("INT. LATER - NIGHT"), + "heading orphaned with {n} lines of filler" + ); + } + } + } + + /// `===` starts a fresh page wherever it falls. + #[test] + fn a_forced_page_break_starts_a_new_page() { + let pages = paginate(&laid_out("First.\n\n===\n\nSecond.\n")); + assert_eq!(pages.len(), 2); + assert_eq!(pages[0].rows.len(), 1); + assert_eq!(pages[1].rows.len(), 1); + } + + /// A `^` cue sets the two speeches side by side, in two columns, as one + /// block that a page boundary cannot pull apart. + #[test] + fn dual_dialogue_is_set_in_two_columns() { + let bs = laid_out("BIXBY\nNo, I won't.\n\nMOM ^\nYes, you will.\n"); + assert_eq!(bs.len(), 1, "the pair becomes a single block"); + let block = &bs[0]; + assert!(block.character.is_none(), "a pair must never be split"); + // The first row carries both cues, at the two column indents. + let first = &block.lines[0]; + assert_eq!(first.runs.len(), 2); + assert_eq!(fountain::text_of(&first.runs[0].spans), "BIXBY"); + assert_eq!(fountain::text_of(&first.runs[1].spans), "MOM"); + assert!(first.runs[0].x < first.runs[1].x); + // Both columns sit inside the text block. + for line in &block.lines { + for run in &line.runs { + assert!(run.x >= LEFT - 0.01, "left of the margin"); + let right = run.x + width_of(&run.spans) as f32 * CHAR; + assert!(right <= RIGHT_EDGE + 0.01, "past the right margin"); + } + } + } + + /// Sections and synopses are the writer's own notes and never print. + #[test] + fn sections_and_synopses_do_not_reach_the_page() { + let bs = laid_out("# Act One\n\n= she decides\n\nShe decides.\n"); + assert_eq!(bs.len(), 1); + assert_eq!(shape(&bs[0])[0].0, "She decides."); + } + + /// Row zero's baseline is one line below the top margin, and the last row + /// clears the bottom margin — the check that the grid fits the paper. + #[test] + fn the_line_grid_fits_between_the_margins() { + let height = 11.0 * INCH; + let first = baseline(height, 0); + assert!(first < height - TOP, "the first line is below the margin"); + assert!(first > height - TOP - LINE, "and within one line of it"); + // Fifty-five lines of 12pt below a one-inch top margin reach to within + // about five sixths of an inch of the foot, which is the bottom margin + // a screenplay actually carries -- not a full inch. + let last = baseline(height, LINES_PER_PAGE - 1); + assert!(last > 0.5 * INCH, "the last line clears the foot: {last}"); + assert!(last < 0.9 * INCH, "and the page is filled: {last}"); + } + + fn meta() -> ScreenplayMeta { + ScreenplayMeta { + title: "The Winter Gate".to_string(), + author: "A. Writer".to_string(), + contact: "12 Any Street\nTown\nwriter@example.com".to_string(), + title_page: true, + } + } + + #[test] + fn a_title_page_carries_the_title_credit_and_contact() { + let page = title_page(&meta(), 11.0 * INCH).expect("a title page"); + let ops = String::from_utf8_lossy(page.ops()).to_string(); + assert!(ops.contains("THE WINTER GATE"), "the title, in capitals"); + assert!(ops.contains("written by")); + assert!(ops.contains("A. Writer")); + assert!(ops.contains("writer@example.com")); + } + + /// Nothing to put on it means no title page at all, rather than a blank + /// sheet at the front of the script. + #[test] + fn an_empty_title_page_is_skipped() { + let empty = ScreenplayMeta { + title: " ".to_string(), + author: String::new(), + contact: "\n ".to_string(), + title_page: true, + }; + assert!(title_page(&empty, 11.0 * INCH).is_none()); + } + + /// An empty script paginates to nothing rather than panicking; the export + /// is what turns that into a single blank sheet. + #[test] + fn an_empty_script_paginates_to_nothing() { + assert!(paginate(&laid_out("")).is_empty()); + assert!(paginate(&laid_out(" \n\n ")).is_empty()); + } + + /// The page count tracks the script's real length -- a page being about a + /// minute of screen time is the whole reason the geometry is fixed. + #[test] + fn the_page_count_grows_with_the_script() { + assert_eq!(paginate(&laid_out("INT. ROOM - DAY\n\nShe waits.\n")).len(), 1); + let long = (0..200) + .map(|i| format!("Line {i}.")) + .collect::>() + .join("\n\n"); + assert!(paginate(&laid_out(&long)).len() > 5); + } +} + +#[cfg(test)] +mod export_tests { + use super::*; + + fn scratch(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("md-manuscript-screenplay-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + const SCENE: &str = "\ +INT. KITCHEN - NIGHT + +Bixby stands at the sink, not washing anything. + +BIXBY +(quietly) +I said I'd do it. + +CUT TO: +"; + + #[test] + fn an_export_writes_a_readable_pdf() { + let dir = scratch("basic"); + let out = dir.join("script.pdf"); + let meta = ScreenplayMeta { + title: "The Winter Gate".to_string(), + author: "A. Writer".to_string(), + contact: "writer@example.com".to_string(), + title_page: true, + }; + let pages = export(&[SCENE.to_string()], &meta, &out).expect("export"); + assert_eq!(pages, 1, "one short scene is one page"); + + let bytes = std::fs::read(&out).expect("the file exists"); + assert!(bytes.starts_with(b"%PDF-1.7")); + assert!(bytes.ends_with(b"%%EOF\n")); + let text = String::from_utf8_lossy(&bytes); + // The title page and the script page, so two leaves in the tree. + assert!(text.contains("/Count 2"), "a title page plus the script"); + assert!(text.contains("(INT. KITCHEN - NIGHT) Tj")); + assert!(text.contains("(THE WINTER GATE) Tj")); + } + + #[test] + fn without_a_title_page_the_script_starts_at_page_one() { + let dir = scratch("no-title"); + let out = dir.join("script.pdf"); + let meta = ScreenplayMeta { + title: "T".to_string(), + author: "A".to_string(), + contact: String::new(), + title_page: false, + }; + export(&[SCENE.to_string()], &meta, &out).unwrap(); + let text = String::from_utf8_lossy(&std::fs::read(&out).unwrap()).to_string(); + assert!(text.contains("/Count 1")); + assert!(!text.contains("(written by) Tj")); + } + + /// Every file's body joins into one continuous script — a screenplay has no + /// chapter breaks, so nothing should force a page between scenes. + #[test] + fn separate_files_join_into_one_script() { + let dir = scratch("joined"); + let out = dir.join("script.pdf"); + let meta = ScreenplayMeta { + title: String::new(), + author: String::new(), + contact: String::new(), + title_page: false, + }; + let bodies = vec![ + "INT. ONE - DAY\n\nFirst.\n".to_string(), + "INT. TWO - DAY\n\nSecond.\n".to_string(), + ]; + let pages = export(&bodies, &meta, &out).unwrap(); + assert_eq!(pages, 1, "two short scenes still share a page"); + let text = String::from_utf8_lossy(&std::fs::read(&out).unwrap()).to_string(); + assert!(text.contains("(INT. ONE - DAY) Tj")); + assert!(text.contains("(INT. TWO - DAY) Tj")); + } + + /// A speech that runs over a page boundary reaches the written file marked + /// `(MORE)` and resumed under a repeated cue. The pagination test pins the + /// rows; this pins that they survive being rendered. + #[test] + fn a_split_speech_is_marked_in_the_written_pdf() { + let dir = scratch("more"); + let out = dir.join("script.pdf"); + let meta = ScreenplayMeta { + title: String::new(), + author: String::new(), + contact: String::new(), + title_page: false, + }; + let filler = (0..24) + .map(|i| format!("Line {i}.")) + .collect::>() + .join("\n\n"); + let speech = (0..14) + .map(|i| format!("Sentence number {i} of the speech.")) + .collect::>() + .join("\n"); + let pages = export( + &[format!("{filler}\n\nBIXBY\n{speech}\n")], + &meta, + &out, + ) + .unwrap(); + assert!(pages >= 2); + let text = String::from_utf8_lossy(&std::fs::read(&out).unwrap()).to_string(); + // The parentheses are escaped on their way into a PDF literal string, + // so what lands in the file is `(\(MORE\))`. + assert!(text.contains(r"(\(MORE\)) Tj"), "the break is marked"); + assert!( + text.contains(r"(BIXBY \(CONT'D\)) Tj"), + "and picked up again" + ); + } + + /// Page numbers sit in the top margin from the second script page on; the + /// first carries none. + #[test] + fn script_pages_are_numbered_from_the_second() { + let dir = scratch("numbers"); + let out = dir.join("script.pdf"); + let meta = ScreenplayMeta { + title: String::new(), + author: String::new(), + contact: String::new(), + title_page: false, + }; + let long = (0..200) + .map(|i| format!("Line {i}.")) + .collect::>() + .join("\n\n"); + let pages = export(&[long], &meta, &out).unwrap(); + assert!(pages >= 3, "got {pages}"); + let text = String::from_utf8_lossy(&std::fs::read(&out).unwrap()).to_string(); + assert!(!text.contains("(1.) Tj"), "the first page is unnumbered"); + assert!(text.contains("(2.) Tj")); + assert!(text.contains("(3.) Tj")); + } +}