5a5017def8
A screenplay is its own project rather than a file type mixed in with prose, so this is one switch in Manuscript details, not a second file extension threaded through order.rs and the file panel. Files stay `.md` and keep the same editorial header; only the draft below the marker is read as Fountain. They list, reorder and header-strip exactly as before. src/fountain.rs parses the format. Almost nothing in Fountain is marked up -- what makes a line a character cue is that it is in capitals with something directly beneath it, and what makes the same words a transition is a blank line below instead. The forcing characters are all there for where that is not enough, along with notes, the boneyard and inline emphasis. Sections and synopses are parsed and kept but never printed: they are the writer's scaffolding. src/pdf.rs writes the PDF by hand, for the reason odt.rs writes ODT by hand -- no pandoc, no LibreOffice at runtime. It costs no dependency either: a screenplay is set entirely in Courier, which is one of the fourteen faces every reader must provide, so there is no font to embed and no metrics to parse. Streams are left uncompressed; a feature script is a few hundred kilobytes that way and stays readable when something needs debugging. src/screenplay.rs does layout. The geometry is the conventional one -- 55 lines of 12pt on US Letter, action at 1.5", dialogue 2.5", parentheticals 3.1", cues 3.7", transitions flush to 7.5" -- because a page only reads as a minute of screen time if it is. A speech broken by a page boundary is marked (MORE) and resumed under a repeated cue, a `^` cue sets two speeches side by side, and a scene heading is never left stranded at the foot of a page. Two faults the tests missed and measuring the rendered PDF caught. A hard-wrapped action paragraph was getting a blank line between every source line, which on the page reads as a beat the writer never wrote; consecutive lines are now one paragraph, with the breaks kept. And reserving one line after a scene heading did not stop it stranding, because every element that can follow a heading is separated from it by a blank -- the reserve has to cover both. Both now have tests. Verified beyond the unit tests: pdffonts confirms base-14 Courier with nothing embedded, pdftotext -bbox puts every indent within a hundredth of an inch of standard, and the export was driven through the real UI against a scratch workspace and an isolated config. Not built, because they were offered and never asked for: rendering Fountain in the Preview pane, and exporting a manuscript as a .fountain file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Bq2fUZNgksdPp3zeHqzSw
701 lines
24 KiB
Rust
701 lines
24 KiB
Rust
//! 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<String>) -> 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<Span>),
|
|
/// 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<Vec<Span>>, 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<Span>, dual: bool },
|
|
/// `(beat)` — a direction inside a dialogue block.
|
|
Parenthetical(Vec<Span>),
|
|
/// A line of dialogue.
|
|
Dialogue(Vec<Span>),
|
|
/// A `~`-prefixed line, sung rather than spoken. Printed in italic.
|
|
Lyric(Vec<Span>),
|
|
/// `CUT TO:`. Printed flush to the right margin.
|
|
Transition(Vec<Span>),
|
|
/// 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<Span> },
|
|
/// A `=`-prefixed one-line summary. Not printed, for the same reason.
|
|
Synopsis(Vec<Span>),
|
|
/// `===` — 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<Span> {
|
|
let mut out: Vec<Span> = Vec::new();
|
|
let (mut bold, mut italic, mut underline) = (false, false, false);
|
|
let mut buf = String::new();
|
|
let chars: Vec<char> = 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<Element>, line: Vec<Span>, 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<Element> {
|
|
let cleaned = strip_notes(&strip_boneyard(&text.replace("\r\n", "\n")));
|
|
let lines: Vec<&str> = cleaned.lines().collect();
|
|
let mut out: Vec<Element> = 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::<Vec<_>>()
|
|
.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());
|
|
}
|
|
}
|