Add Fountain screenplay projects, exported straight to PDF
A screenplay is its own project rather than a file type mixed in with prose, so this is one switch in Manuscript details, not a second file extension threaded through order.rs and the file panel. Files stay `.md` and keep the same editorial header; only the draft below the marker is read as Fountain. They list, reorder and header-strip exactly as before. src/fountain.rs parses the format. Almost nothing in Fountain is marked up -- what makes a line a character cue is that it is in capitals with something directly beneath it, and what makes the same words a transition is a blank line below instead. The forcing characters are all there for where that is not enough, along with notes, the boneyard and inline emphasis. Sections and synopses are parsed and kept but never printed: they are the writer's scaffolding. src/pdf.rs writes the PDF by hand, for the reason odt.rs writes ODT by hand -- no pandoc, no LibreOffice at runtime. It costs no dependency either: a screenplay is set entirely in Courier, which is one of the fourteen faces every reader must provide, so there is no font to embed and no metrics to parse. Streams are left uncompressed; a feature script is a few hundred kilobytes that way and stays readable when something needs debugging. src/screenplay.rs does layout. The geometry is the conventional one -- 55 lines of 12pt on US Letter, action at 1.5", dialogue 2.5", parentheticals 3.1", cues 3.7", transitions flush to 7.5" -- because a page only reads as a minute of screen time if it is. A speech broken by a page boundary is marked (MORE) and resumed under a repeated cue, a `^` cue sets two speeches side by side, and a scene heading is never left stranded at the foot of a page. Two faults the tests missed and measuring the rendered PDF caught. A hard-wrapped action paragraph was getting a blank line between every source line, which on the page reads as a beat the writer never wrote; consecutive lines are now one paragraph, with the breaks kept. And reserving one line after a scene heading did not stop it stranding, because every element that can follow a heading is separated from it by a blank -- the reserve has to cover both. Both now have tests. Verified beyond the unit tests: pdffonts confirms base-14 Courier with nothing embedded, pdftotext -bbox puts every indent within a hundredth of an inch of standard, and the export was driven through the real UI against a scratch workspace and an isolated config. Not built, because they were offered and never asked for: rendering Fountain in the Preview pane, and exporting a manuscript as a .fountain file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Bq2fUZNgksdPp3zeHqzSw
This commit is contained in:
@@ -14,12 +14,14 @@ as an ordered manuscript.
|
|||||||
in `order.json` inside the workspace, so it syncs along with the files. It
|
in `order.json` inside the workspace, so it syncs along with the files. It
|
||||||
records each file's path, so subfolders travel with it.
|
records each file's path, so subfolders travel with it.
|
||||||
* **Export** the whole manuscript as a single concatenated `.odt` file, with a
|
* **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…**),
|
* **Start a new project** from a cookiecutter template (**File ▸ New project…**),
|
||||||
which builds the whole folder structure and opens its drafting folder.
|
which builds the whole folder structure and opens its drafting folder.
|
||||||
|
|
||||||
Built in Rust with [`egui`](https://github.com/emilk/egui)/`eframe`. The ODT
|
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
|
## 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
|
For a chapters-plus-master export the title page goes on the master, not on each
|
||||||
chapter file.
|
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)
|
### Exporting chapter files (File ▸ Export chapters + master)
|
||||||
|
|
||||||
Writes one `.odt` per chapter into a `chapters/` folder, plus an `.odm` master
|
Writes one `.odt` per chapter into a `chapters/` folder, plus an `.odm` master
|
||||||
|
|||||||
+21
-3
@@ -360,12 +360,30 @@ impl App {
|
|||||||
of the way down, and # for a scene break",
|
of the way down, and # for a scene break",
|
||||||
);
|
);
|
||||||
save_now |= r.changed();
|
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(
|
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 \
|
"The title and author are also written into exported .odt \
|
||||||
files as their document properties, which is what a word \
|
files as their document properties, which is what a word \
|
||||||
processor shows under File ▸ Properties.",
|
processor shows under File ▸ Properties."
|
||||||
)
|
})
|
||||||
.small()
|
.small()
|
||||||
.weak(),
|
.weak(),
|
||||||
);
|
);
|
||||||
|
|||||||
+24
-6
@@ -57,11 +57,26 @@ impl App {
|
|||||||
ui.add(
|
ui.add(
|
||||||
egui::TextEdit::singleline(&mut self.export_input).desired_width(300.0),
|
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();
|
self.browse_export();
|
||||||
}
|
}
|
||||||
if ui.button("Export ODT").clicked() {
|
let export_hint = if screenplay {
|
||||||
self.export_odt();
|
"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
|
// Resolved first: the hover text borrows the path, and the click
|
||||||
// handler needs `self` mutably.
|
// handler needs `self` mutably.
|
||||||
@@ -268,11 +283,14 @@ impl App {
|
|||||||
ui.close_menu();
|
ui.close_menu();
|
||||||
self.browse_workspace();
|
self.browse_workspace();
|
||||||
}
|
}
|
||||||
if ui.button("Export ODT").clicked() {
|
if ui.button(self.export_label()).clicked() {
|
||||||
ui.close_menu();
|
ui.close_menu();
|
||||||
self.export_odt();
|
self.export_manuscript();
|
||||||
}
|
}
|
||||||
if ui
|
// 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)")
|
.button("Export chapters + master (.odm)")
|
||||||
.on_hover_text(
|
.on_hover_text(
|
||||||
"One .odt per chapter in a chapters/ folder, plus an \
|
"One .odt per chapter in a chapters/ folder, plus an \
|
||||||
|
|||||||
+102
-8
@@ -1,6 +1,7 @@
|
|||||||
//! Workspace, file-list and git operations: opening a folder, creating,
|
//! Workspace, file-list and git operations: opening a folder, creating,
|
||||||
//! renaming and deleting manuscript files, persisting order and titles, and
|
//! 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::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -785,6 +786,91 @@ impl App {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Details for a screenplay export. The same Manuscript details a prose
|
||||||
|
/// export uses: a screenplay project differs in how its pages are set, not
|
||||||
|
/// in who wrote it or how to reach them.
|
||||||
|
pub(super) fn screenplay_meta(&self) -> crate::screenplay::ScreenplayMeta {
|
||||||
|
crate::screenplay::ScreenplayMeta {
|
||||||
|
title: match self.config.manuscript_title.trim() {
|
||||||
|
"" => self
|
||||||
|
.workspace()
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("Screenplay")
|
||||||
|
.to_string(),
|
||||||
|
set => set.to_string(),
|
||||||
|
},
|
||||||
|
author: self.config.manuscript_author.trim().to_string(),
|
||||||
|
contact: self.config.manuscript_contact.clone(),
|
||||||
|
title_page: self.config.manuscript_title_page,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The manuscript's files as Fountain bodies, in order.
|
||||||
|
///
|
||||||
|
/// A screenplay project keeps the same editorial header as a prose one, so
|
||||||
|
/// the header is stripped exactly as it is for an ODT export; what is left
|
||||||
|
/// below the draft marker is the Fountain.
|
||||||
|
fn collect_bodies(&self) -> Vec<String> {
|
||||||
|
let marker = self.config.draft_marker.clone();
|
||||||
|
self.manuscript_files()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, name)| {
|
||||||
|
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
|
||||||
|
crate::preprocess::parse(&raw, &marker).body
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the manuscript the way this project wants exporting: a screenplay
|
||||||
|
/// as a paginated PDF, anything else as an ODT.
|
||||||
|
pub(super) fn export_manuscript(&mut self) {
|
||||||
|
if self.config.manuscript_screenplay {
|
||||||
|
self.export_screenplay();
|
||||||
|
} else {
|
||||||
|
self.export_odt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the export button says and does, which depends on the project.
|
||||||
|
pub(super) fn export_label(&self) -> &'static str {
|
||||||
|
if self.config.manuscript_screenplay {
|
||||||
|
"Export PDF"
|
||||||
|
} else {
|
||||||
|
"Export ODT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lay the manuscript out as a screenplay and write it as a PDF beside
|
||||||
|
/// wherever the export path points.
|
||||||
|
///
|
||||||
|
/// The extension is forced rather than taken from the box, the same way the
|
||||||
|
/// master export forces `.odm`: the path is remembered across projects and
|
||||||
|
/// would otherwise still be naming last project's `.odt`.
|
||||||
|
pub(super) fn export_screenplay(&mut self) {
|
||||||
|
self.save_current();
|
||||||
|
let bodies = self.collect_bodies();
|
||||||
|
if bodies.iter().all(|b| b.trim().is_empty()) {
|
||||||
|
self.status = "Nothing to export — the screenplay is empty".to_string();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let out = PathBuf::from(self.export_input.trim()).with_extension("pdf");
|
||||||
|
let meta = self.screenplay_meta();
|
||||||
|
match crate::screenplay::export(&bodies, &meta, &out) {
|
||||||
|
Ok(pages) => {
|
||||||
|
self.config.export_path = out.clone();
|
||||||
|
self.config.save();
|
||||||
|
self.export_input = out.display().to_string();
|
||||||
|
self.status = format!(
|
||||||
|
"Exported {} page(s) of screenplay to {}",
|
||||||
|
pages,
|
||||||
|
out.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => self.status = format!("Export failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn export_odt(&mut self) {
|
pub(super) fn export_odt(&mut self) {
|
||||||
self.save_current();
|
self.save_current();
|
||||||
let marker = self.config.draft_marker.clone();
|
let marker = self.config.draft_marker.clone();
|
||||||
@@ -844,24 +930,32 @@ impl App {
|
|||||||
/// Open a native save dialog to choose the export `.odt` path.
|
/// Open a native save dialog to choose the export `.odt` path.
|
||||||
pub(super) fn browse_export(&mut self) {
|
pub(super) fn browse_export(&mut self) {
|
||||||
let current = PathBuf::from(self.export_input.trim());
|
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()
|
let mut dialog = rfd::FileDialog::new()
|
||||||
.set_title("Choose export file")
|
.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()) {
|
if let Some(parent) = current.parent().filter(|p| p.is_dir()) {
|
||||||
dialog = dialog.set_directory(parent);
|
dialog = dialog.set_directory(parent);
|
||||||
}
|
}
|
||||||
|
let fallback = format!("manuscript.{ext}");
|
||||||
let name = current
|
let name = current
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|s| s.to_str())
|
.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() {
|
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.
|
// Ensure the chosen path carries the extension even if it was
|
||||||
let has_odt = path
|
// omitted, or was left over from the other kind of export.
|
||||||
|
let matches = path
|
||||||
.extension()
|
.extension()
|
||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
.is_some_and(|e| e.eq_ignore_ascii_case("odt"));
|
.is_some_and(|e| e.eq_ignore_ascii_case(ext));
|
||||||
if !has_odt {
|
if !matches {
|
||||||
path.set_extension("odt");
|
path.set_extension(ext);
|
||||||
}
|
}
|
||||||
self.export_input = path.display().to_string();
|
self.export_input = path.display().to_string();
|
||||||
self.config.export_path = path;
|
self.config.export_path = path;
|
||||||
|
|||||||
@@ -150,6 +150,12 @@ pub struct Config {
|
|||||||
/// agent or editor expects a submission in.
|
/// agent or editor expects a submission in.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub manuscript_standard_format: bool,
|
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
|
/// 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_contact: String::new(),
|
||||||
manuscript_title_page: false,
|
manuscript_title_page: false,
|
||||||
manuscript_standard_format: 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"));
|
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
|
/// A saved subfolder choice must win over the built-in default, so changing
|
||||||
/// the default never silently moves an existing user's projects.
|
/// the default never silently moves an existing user's projects.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+700
@@ -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<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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ mod app;
|
|||||||
mod characters;
|
mod characters;
|
||||||
mod config;
|
mod config;
|
||||||
mod cookiecutter;
|
mod cookiecutter;
|
||||||
|
mod fountain;
|
||||||
mod gitsync;
|
mod gitsync;
|
||||||
mod help;
|
mod help;
|
||||||
mod langtool;
|
mod langtool;
|
||||||
@@ -13,7 +14,9 @@ mod mistral;
|
|||||||
mod odt;
|
mod odt;
|
||||||
mod outline;
|
mod outline;
|
||||||
mod order;
|
mod order;
|
||||||
|
mod pdf;
|
||||||
mod preprocess;
|
mod preprocess;
|
||||||
|
mod screenplay;
|
||||||
mod spell;
|
mod spell;
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|||||||
+473
@@ -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<u8> {
|
||||||
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PageContent>,
|
||||||
|
/// 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<u8> {
|
||||||
|
// 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<u8> = Vec::new();
|
||||||
|
// Offset 0 is the free-list head, which never names a real object.
|
||||||
|
let mut offsets: Vec<usize> = 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<u8>, offsets: &mut Vec<usize>, 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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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<usize> {
|
||||||
|
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 <count>\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 "<n> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1158
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user