Files

235 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Render a screenplay text file into a PDF with Courier font."""
import sys
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import SimpleDocTemplate, Spacer, Paragraph, PageBreak, KeepTogether
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.lib.colors import black, Color
INPUT = "/root/workspace/trilogy/screenplay/night-trilogy-script.txt"
OUTPUT = "/root/workspace/trilogy/screenplay/night-trilogy.pdf"
WIDTH = A4[0] - 2 * 20 * mm # 20mm margins each side
HEIGHT = A4[1] - 2 * 25 * mm
def make_story_title(text):
return ParagraphStyle(
"STTitle", fontName="Courier-Bold", fontSize=16, leading=22,
alignment=TA_CENTER, spaceAfter=12, textColor="000000"
)
def make_credit(text):
return ParagraphStyle(
"Credit", fontName="Courier", fontSize=12, leading=18,
alignment=TA_CENTER, spaceAfter=4, textColor="000000"
)
def make_scene_heading(text):
return ParagraphStyle(
"SceneH", fontName="Courier-Bold", fontSize=11, leading=14,
alignment=TA_LEFT, spaceBefore=12, spaceAfter=6, textColor="000000"
)
def make_action(text):
return ParagraphStyle(
"Action", fontName="Courier", fontSize=11, leading=15,
alignment=TA_LEFT, spaceAfter=6, textColor="000000"
)
def make_dialogue_label(text):
return ParagraphStyle(
"DL", fontName="Courier-Bold", fontSize=11, leading=15,
alignment=TA_LEFT, spaceAfter=0, textColor="000000"
)
def make_dialogue(text, paren=False):
if paren:
fn = "Courier-Oblique"
else:
fn = "Courier"
return ParagraphStyle(
"Dial", fontName=fn, fontSize=11, leading=15,
leftIndent=40 * mm, rightIndent=40 * mm,
alignment=TA_LEFT, spaceAfter=2, textColor="000000"
)
def make_transition(text):
return ParagraphStyle(
"Trans", fontName="Courier-Bold", fontSize=11, leading=15,
alignment=TA_RIGHT, spaceBefore=10, spaceAfter=10, textColor="000000"
)
def make_superimpose(text):
return ParagraphStyle(
"Super", fontName="Courier-BoldOblique", fontSize=10, leading=14,
alignment=TA_CENTER, leftIndent=20*mm, rightIndent=20*mm,
spaceBefore=6, spaceAfter=6, textColor="333333"
)
def make_part_title(text):
return ParagraphStyle(
"Part", fontName="Courier-Bold", fontSize=14, leading=20,
alignment=TA_CENTER, spaceBefore=20, spaceAfter=8, textColor="000000"
)
def escape(text):
"""Escape XML-like characters for reportlab Paragraph."""
return (text
.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&#34;")
.replace("'", "&#39;"))
def parse_and_build():
"""Parse the screenplay text and return flowables for reportlab."""
with open(INPUT, "r") as f:
lines = f.readlines()
elements = []
i = 0
n = len(lines)
while i < n:
raw = lines[i].rstrip("\n")
text = raw.strip()
i += 1
# Skip blank lines
if not text:
continue
# Skip "PAGE N" markers
if text.startswith("PAGE "):
continue
# Skip "FADE IN:" handled as transition
if text == "FADE IN:":
elements.append(Spacer(1, 12))
elements.append(Paragraph(escape("FADE IN:"), make_transition("FADE IN:")))
continue
# Skip "FADE TO BLACK." / "THE END."
if text in ("FADE TO BLACK.", "THE END.", "FADE OUT.", "CUT TO BLACK."):
elements.append(Spacer(1, 8))
elements.append(Paragraph(escape(text), make_transition(text)))
continue
# Transitions: CUT TO:, DISSOLVE TO:, etc.
if text.endswith(":") and text.isupper() and " " in text or text in ("CUT TO:", "DISSOLVE TO:"):
elements.append(Paragraph(escape(text), make_transition(text)))
continue
# Title: THE NIGHT TRILOGY (centered, bold, top of doc)
if "THE NIGHT TRI" in text.upper():
elements.append(Spacer(1, 30))
elements.append(Paragraph(escape(text), make_story_title(text)))
continue
# Subtitle line
if text.upper().startswith("THREE STORIES"):
elements.append(Paragraph(escape(text), make_credit(text)))
continue
# Credit lines
if text.upper() in ("WRITTEN BY", "BASED ON THE NEWS", "HEADLINES OF JUNE 2026"):
elements.append(Paragraph(escape(text), make_credit(text)))
continue
if text == "Kevin Hermes":
elements.append(Paragraph(escape(text), make_credit(text)))
continue
# Part titles
if text.startswith("PART ") and text.isupper():
# Remove the (YEAR) parenthetical and handle separately
paren = ""
if "(" in text:
idx = text.index("(")
paren = text[idx:]
text = text[:idx].strip()
elements.append(Spacer(1, 10))
elements.append(Paragraph(escape(text), make_part_title(text)))
if paren:
elements.append(Paragraph(escape(paren), make_credit(paren)))
continue
# Superimpose
if text.startswith("SUPERIMPOSE:"):
elements.append(Spacer(1, 6))
# Read next line(s) as superimpose content
super_lines = []
while i < n:
ln = lines[i].rstrip("\n").strip()
i += 1
if not ln or ln.startswith("PART ") or ln in ("FADE TO BLACK.", "THE END.", "FADE IN:"):
break
super_lines.append(ln)
content = " ".join(super_lines)
elements.append(Paragraph(escape(content), make_superimpose(content)))
continue
# Scene headings: EXT./INT.
if text.startswith(("EXT.", "INT.")):
elements.append(Spacer(1, 6))
elements.append(Paragraph(escape(text), make_scene_heading(text)))
continue
# Character dialogue labels (short, ALL CAPS, possibly with parenthetical)
if text.isupper() and len(text) < 60 and not text.startswith("THE "):
# Check if next line(s) are dialogue (indented in original)
# We treat this as a character name
# Handle "(V.O.)", "(on phone)", "(into headset)"
elements.append(Paragraph(escape(text), make_dialogue_label(text)))
continue
# Parenthetical directions: (quietly), (beat), (whisper)
if text.startswith("(") and text.endswith(")") and len(text) < 40:
elements.append(Paragraph(escape(text), make_dialogue(text, paren=True)))
continue
# "HOLD ON..." lines -- treat as action
if text.startswith("HOLD ON"):
elements.append(Paragraph(escape(text), make_action(text)))
continue
# Default: action/description
elements.append(Paragraph(escape(text), make_action(text)))
return elements
def main():
doc = SimpleDocTemplate(
OUTPUT,
pagesize=A4,
leftMargin=20*mm,
rightMargin=20*mm,
topMargin=25*mm,
bottomMargin=25*mm,
title="The Night Trilogy - Screenplay",
author="Kevin Hermes",
)
elements = parse_and_build()
doc.build(elements)
print(f"PDF written: {OUTPUT}")
if __name__ == "__main__":
main()