235 lines
7.4 KiB
Python
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("<", "<")
|
|
.replace(">", ">")
|
|
.replace('"', """)
|
|
.replace("'", "'"))
|
|
|
|
|
|
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()
|