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")); + } +}