//! 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()); } }