#!/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()