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:
+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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user