trelby_export/src/screenplay.py

3357 lines
100 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

# -*- coding: utf-8 -*-
# linebreak types
LB_SPACE = 1
# we don't use this anymore, but we have to keep it in order to be able to
# load old scripts
LB_SPACE2 = 2
LB_NONE = 3
LB_FORCED = 4
LB_LAST = 5
# line types
SCENE = 1
ACTION = 2
CHARACTER = 3
DIALOGUE = 4
PAREN = 5
TRANSITION = 6
SHOT = 7
NOTE = 8
ACTBREAK = 9
import autocompletion
import config
import error
import headers
import locations
import mypager
import pdf
import pml
import spellcheck
import titles
import undo
import util
import copy
import difflib
import re
import sys
import time
from lxml import etree
# screenplay
class Screenplay:
def __init__(self, cfgGl):
self.autoCompletion = autocompletion.AutoCompletion()
self.headers = headers.Headers()
self.locations = locations.Locations()
self.titles = titles.Titles()
self.scDict = spellcheck.Dict()
self.lines = [ Line(LB_LAST, SCENE) ]
self.cfgGl = cfgGl
self.cfg = config.Config()
# cursor position: line and column
self.line = 0
self.column = 0
# first line shown on screen. use getTopLine/setTopLine to access
# this.
self._topLine = 0
# Mark object if selection active, or None.
self.mark = None
# FIXME: document these
self.pages = [-1, 0]
self.pagesNoAdjust = [-1, 0]
# time when last paginated
self.lastPaginated = 0.0
# list of active auto-completion strings
self.acItems = None
# selected auto-completion item (only valid when acItems contains
# something)
self.acSel = -1
# max nr of auto comp items displayed at once
self.acMax = 10
# True if script has had changes done to it after
# load/save/creation.
self.hasChanged = False
# first/last undo objects (undo.Base)
self.firstUndo = None
self.lastUndo = None
# value of this, depending on the user's last action:
# undo: the undo object that was used
# redo: the next undo object from the one that was used
# anything else: None
self.currentUndo = None
# estimated amount of memory used by undo objects, in bytes
self.undoMemoryUsed = 0
def isModified(self):
if not self.hasChanged:
return False
# nothing of value is ever lost by not saving a completely empty
# script, and it's annoying getting warnings about unsaved changes
# on those, so don't do that
return (len(self.lines) > 1) or bool(self.lines[0].text)
def markChanged(self, state = True):
self.hasChanged = state
def cursorAsMark(self):
return Mark(self.line, self.column)
# return True if the line is a parenthetical and not the first line of
# that element (such lines need an extra space of indenting).
def needsExtraParenIndent(self, line):
return (self.lines[line].lt == PAREN) and not self.isFirstLineOfElem(line)
def getSpacingBefore(self, i):
if i == 0:
return 0
tcfg = self.cfg.types[self.lines[i].lt]
if self.lines[i - 1].lb == LB_LAST:
return tcfg.beforeSpacing
else:
return tcfg.intraSpacing
# we implement our own custom deepcopy because it's 8-10x faster than
# the generic one (times reported by cmdSpeedTest using a 119-page
# screenplay):
#
# ╭─────────────────────────────┬─────────┬────────╮
# │ │ Generic │ Custom │
# ├─────────────────────────────┼─────────┼────────┤
# │ Intel Core Duo T2050 1.6GHz │ 0.173s │ 0.020s │
# │ Intel i5-2400 3.1GHz │ 0.076s │ 0.007s │
# ╰─────────────────────────────┴─────────┴────────╯
def __deepcopy__(self):
sp = Screenplay(self.cfgGl)
sp.cfg = copy.deepcopy(self.cfg)
sp.autoCompletion = copy.deepcopy(self.autoCompletion)
sp.headers = copy.deepcopy(self.headers)
sp.locations = copy.deepcopy(self.locations)
sp.titles = copy.deepcopy(self.titles)
sp.scDict = copy.deepcopy(self.scDict)
sp.lines = [Line(ln.lb, ln.lt, ln.text) for ln in self.lines]
# "open PDF on current page" breaks on scripts we're removing
# notes from before printing if we don't copy these
sp.line = self.line
sp.column = self.column
return sp
# save script to a string and return that
def save(self):
self.cfg.cursorLine = self.line
self.cfg.cursorColumn = self.column
output = ''
output += '\ufeff'
output += "#Version 3\n"
output += "#Begin-Auto-Completion \n"
output += self.autoCompletion.save()
output += "#End-Auto-Completion \n"
output += "#Begin-Config \n"
output += self.cfg.save()
output += "#End-Config \n"
output += "#Begin-Locations \n"
output += self.locations.save()
output += "#End-Locations \n"
output += "#Begin-Spell-Checker-Dict \n"
output += self.scDict.save()
output += "#End-Spell-Checker-Dict \n"
pgs = self.titles.pages
for pg in range(len(pgs)):
if pg != 0:
output += "#Title-Page \n"
for i in range(len(pgs[pg])):
output += "#Title-String %s\n" % str(pgs[pg][i])
for h in self.headers.hdrs:
output += "#Header-String %s\n" % str(h)
output += "#Header-Empty-Lines %d\n" % self.headers.emptyLinesAfter
output += "#Start-Script \n"
for i in range(len(self.lines)):
output += str(self.lines[i]) + "\n"
return output.encode("UTF-8")
# load script from string s and return a (Screenplay, msg) tuple,
# where msgs is string (possibly empty) of warnings about the loading
# process. fatal errors are indicated by raising a MiscError. note
# that this is a static function.
@staticmethod
def load(s, cfgGl):
lines = s.splitlines()
sp = Screenplay(cfgGl)
# remove default empty line
sp.lines = []
if len(lines) < 2:
raise error.MiscError("File has too few lines to be a valid\n"
"screenplay file.")
key, version = Screenplay.parseConfigLine(lines[0])
if not key or (key != "Version"):
raise error.MiscError("File doesn't seem to be a proper\n"
"screenplay file.")
if version not in ("1", "2", "3"):
raise error.MiscError("File uses fileformat version '%s',\n"
"which is not supported by this version\n"
"of the program." % version)
version = int(version)
# current position at 'lines'
index = 1
s, index = Screenplay.getConfigPart(lines, "Auto-Completion", index)
if s:
sp.autoCompletion.load(s)
s, index = Screenplay.getConfigPart(lines, "Config", index)
if s:
sp.cfg.load(s)
s, index = Screenplay.getConfigPart(lines, "Locations", index)
if s:
sp.locations.load(s)
s, index = Screenplay.getConfigPart(lines, "Spell-Checker-Dict",
index)
if s:
sp.scDict.load(s)
# used to keep track that element type only changes after a
# LB_LAST line.
prevType = None
# did we encounter unknown lb types
unknownLb = False
# did we encounter unknown element types
unknownTypes = False
# did we encounter unknown config lines
unknownConfigs = False
# have we seen the Start-Script line. defaults to True in old
# files which didn't have it.
startSeen = version < 3
for i in range(index, len(lines)):
s = lines[i]
if len(s) < 2:
raise error.MiscError("Line %d is too short." % (i + 1))
if s[0] == "#":
key, val = Screenplay.parseConfigLine(s)
if not key:
raise error.MiscError("Line %d has invalid syntax for\n"
"config line." % (i + 1))
if key == "Title-Page":
sp.titles.pages.append([])
elif key == "Title-String":
if len(sp.titles.pages) == 0:
sp.titles.pages.append([])
tmp = titles.TitleString([])
tmp.load(val)
sp.titles.pages[-1].append(tmp)
elif key == "Header-String":
tmp = headers.HeaderString()
tmp.load(val)
sp.headers.hdrs.append(tmp)
elif key == "Header-Empty-Lines":
sp.headers.emptyLinesAfter = util.str2int(val, 1, 0, 5)
elif key == "Start-Script":
startSeen = True
else:
unknownConfigs = True
else:
if not startSeen:
unknownConfigs = True
continue
lb = config.char2lb(s[0], False)
lt = config.char2lt(s[1], False)
text = util.toInputStr(util.fromUTF8(s[2:]))
# convert unknown lb types into LB_SPACE
if lb == None:
lb = LB_SPACE
unknownLb = True
# convert unknown types into ACTION
if lt == None:
lt = ACTION
unknownTypes = True
if prevType and (lt != prevType):
raise error.MiscError("Line %d has invalid element"
" type." % (i + 1))
line = Line(lb, lt, text)
sp.lines.append(line)
if lb != LB_LAST:
prevType = lt
else:
prevType = None
if not startSeen:
raise error.MiscError("Start-Script line not found.")
if len(sp.lines) == 0:
raise error.MiscError("File doesn't contain any screenplay"
" lines.")
if sp.lines[-1].lb != LB_LAST:
raise error.MiscError("Last line doesn't end an element.")
if cfgGl.honorSavedPos:
sp.line = sp.cfg.cursorLine
sp.column = sp.cfg.cursorColumn
sp.validatePos()
sp.reformatAll()
sp.paginate()
sp.titles.sort()
sp.locations.refresh(sp.getSceneNames())
msgs = []
if unknownLb:
msgs.append("Screenplay contained unknown linebreak types.")
if unknownTypes:
msgs.append("Screenplay contained unknown element types. These"
" have been converted to Action elements.")
if unknownConfigs:
msgs.append("Screenplay contained unknown information. This"
" probably means that the file was created with a"
" newer version of this program.\n\n"
" You'll lose that information if you save over"
" the existing file.")
return (sp, "\n\n".join(msgs))
# lines is an array of strings. if lines[startIndex] == "Begin-$name
# ", this searches for a string of "End-$name ", takes all the strings
# between those two, joins the lines into a single string (lines
# separated by a "\n") and returns (string,
# line-index-after-the-end-line). returns ("", startIndex) if
# startIndex does not contain the start line or startIndex is too big
# for 'lines'. raises error.MiscError on errors.
@staticmethod
def getConfigPart(lines, name, startIndex):
if (startIndex >= len(lines)) or\
(lines[startIndex] != ("#Begin-%s " % name)):
return ("", startIndex)
try:
endIndex = lines.index("#End-%s " % name, startIndex)
except ValueError:
raise error.MiscError("#End-%s not found" % name)
return ("\n".join(lines[startIndex + 1:endIndex]), endIndex + 1)
# parse a line containing a config-value in the format detailed in
# fileformat.txt. line must have newline stripped from the end
# already. returns a (key, value) tuple. if line doesn't match the
# format, (None, None) is returned.
@staticmethod
def parseConfigLine(s):
pattern = re.compile("#([a-zA-Z0-9\-]+) (.*)")
m = pattern.search(s)
if m:
return (m.group(1), m.group(2))
else:
return (None, None)
# apply new config.
def applyCfg(self, cfg):
self.firstUndo = None
self.lastUndo = None
self.currentUndo = None
self.undoMemoryUsed = 0
self.cfg = copy.deepcopy(cfg)
self.cfg.recalc()
self.reformatAll()
self.paginate()
self.markChanged()
# return script config as a string.
def saveCfg(self):
return self.cfg.save()
# generate formatted text and return it as a string. if 'dopages' is
# True, marks pagination in the output.
def generateText(self, doPages):
ls = self.lines
output = util.String()
for p in range(1, len(self.pages)):
start, end = self.page2lines(p)
if doPages and (p != 1):
s = "%s %d. " % ("-" * 30, p)
s += "-" * (60 - len(s))
output += "\n%s\n\n" % s
for i in range(start, end + 1):
line = ls[i]
tcfg = self.cfg.getType(line.lt)
if tcfg.export.isCaps:
text = util.upper(line.text)
else:
text = line.text
if (i != 0) and (not doPages or (i != start)):
output += (self.getSpacingBefore(i) // 10) * "\n"
if text and self.needsExtraParenIndent(i):
text = " " + text
output += " " * tcfg.indent + text + "\n"
return str(output)
# generate HTML output and return it as a string, optionally including
# notes.
def generateHtml(self, includeNotes = True):
ls = self.lines
# We save space by shorter class names in html.
htmlMap = {
ACTION : "ac",
CHARACTER : "ch",
DIALOGUE : "di",
PAREN : "pa",
SCENE : "sc",
SHOT : "sh",
TRANSITION : "tr",
NOTE : "nt",
ACTBREAK : "ab",
}
# html header for files
htmlHeader = """
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Exported Screenplay</title>
<style type="text/css">
body {background: #ffffff; color: #000000; text-align: center;}
pre, p {font: 12px/14px Courier, "Courier New", monospace !important;}
pre {text-align: left !important; letter-spacing: 0 !important; margin-top: 0px !important; margin-bottom: 0px !important;}
p {text-align: center;}
.title, .footer {margin: 15px;}
.spcenter {margin: 0 auto; width: 500px;}
.sc, .ab {font-weight: bold !important;}
.nt {color: blue; font-style: italic !important;}
</style>
</head>
<body>
"""
htmlFooter = """<p class = "footer">***<br>
Generated with <a href="http://www.trelby.org">Trelby</a>.</p>
</body>
</html>"""
content = etree.Element("div")
content.set("class","spcenter")
# title pages
for page in self.titles.pages:
for ts in page:
for s in ts.items:
para = etree.SubElement(content, "p")
para.set("class", "title")
para.text = s
para = etree.SubElement(content, "p")
para.set("class", "title")
para.text = "***"
for i in range(len(ls)):
line = ls[i]
if not includeNotes and (line.lt == NOTE):
continue
tcfg = self.cfg.getType(line.lt)
if tcfg.export.isCaps:
text = util.upper(line.text)
else:
text = line.text
if text and self.needsExtraParenIndent(i):
text = " " + text
text = " " * tcfg.indent + text
# do we need space before this line?
lineSpaces = self.getSpacingBefore(i) // 10
for num in range(lineSpaces):
para = etree.SubElement(content, "pre")
para.set("class", htmlMap[line.lt])
para.text = " "
# and now the line text
para = etree.SubElement(content, "pre")
para.set("class", htmlMap[line.lt])
para.text = str(text)
bodyText = etree.tostring(content, encoding='UTF-8', pretty_print=True).decode()
return htmlHeader + bodyText + htmlFooter
# Return screenplay as list of tuples of the form (elType, elText).
# forced linebreaks are represented as \n characters.
def getElementsAsList(self):
ls = self.lines
eleList = []
curLine = ""
for line in ls:
lineType = line.lt
lineText = line.text
if self.cfg.getType(line.lt).export.isCaps:
lineText = util.upper(lineText)
curLine += lineText
if line.lb == LB_LAST:
eleList.append((lineType, curLine))
curLine = ""
elif line.lb == LB_SPACE:
curLine += " "
elif line.lb == LB_FORCED:
curLine += "\n"
return eleList
# Generate a Final Draft XML file and return as string.
def generateFDX(self):
eleList = self.getElementsAsList()
fd = etree.Element("FinalDraft")
fd.set("DocumentType", "Script")
fd.set("Template", "No")
fd.set("Version", "1")
content = etree.SubElement(fd, "Content")
xmlMap = {
ACTION : "Action",
CHARACTER : "Character",
DIALOGUE : "Dialogue",
PAREN : "Parenthetical",
SCENE : "Scene Heading",
SHOT : "Shot",
TRANSITION : "Transition",
NOTE : "Action",
ACTBREAK : "New Act",
}
for ele in eleList:
typ, txt = ele
if typ == NOTE:
dummyPara = etree.SubElement(content, "Paragraph")
dummyPara.set("Type", xmlMap[typ])
scriptnote = etree.SubElement(dummyPara, "ScriptNote")
scriptnote.set("ID", "1")
para = etree.SubElement(scriptnote, "Paragraph")
else:
para = etree.SubElement(content, "Paragraph")
para.set("Type", xmlMap[typ])
paratxt = etree.SubElement(para, "Text")
paratxt.text = str(txt)
# FD does not recognize "New Act" by default. It needs an
# ElementSettings element added.
eleSet = etree.SubElement(fd, "ElementSettings")
eleSet.set("Type", xmlMap[ACTBREAK])
eleSetFont = etree.SubElement(eleSet, "FontSpec")
eleSetFont.set("Style", "Underline+AllCaps")
eleSetPara = etree.SubElement(eleSet, "ParagraphSpec")
eleSetPara.set("Alignment","Center")
return etree.tostring(
fd, xml_declaration=True, encoding='UTF-8', pretty_print=True)
# generate Fountain and return it as a string.
def generateFountain(self):
eleList = self.getElementsAsList()
flines = []
TWOSPACE = " "
sceneStartsList = ("INT", "EXT", "EST", "INT./EXT", "INT/EXT", "I/E", "I./E")
# does s look like a fountain scene line:
def looksLikeScene(s):
s = s.upper()
looksGood = False
for t in sceneStartsList:
if s.startswith(t):
looksGood = True
break
return looksGood
for ele in eleList:
typ, txt = ele
lns = txt.split("\n")
#ensure last element of flines is empty for some types.
if typ in (SCENE, ACTION, CHARACTER, TRANSITION, SHOT, ACTBREAK, NOTE):
if flines and flines[-1] != "":
flines.append("")
# special handling of some elements.
if typ == SCENE:
# if the line would not be recognized as Scene by fountain,
# append a "." so it is forced as a scene line.
if not looksLikeScene(txt):
txt = "." + txt
elif typ == ACTION:
tmp = []
for ln in lns:
if ln and (ln.isupper() or looksLikeScene(txt)):
tmp.append(ln + TWOSPACE)
else:
tmp.append(ln)
txt = "\n".join(tmp)
elif typ == TRANSITION:
if not txt.endswith("TO:"):
txt = "> " + txt
elif typ == DIALOGUE:
tmp = []
for ln in lns:
if not ln:
tmp.append(TWOSPACE)
else:
tmp.append(ln)
txt = "\n".join(tmp)
elif typ == NOTE:
txt = "[[" + txt + "]]"
elif typ == ACTBREAK:
txt = ">" + txt + "<"
elif typ == SHOT:
txt += TWOSPACE
flines.append(txt)
return util.toUTF8("\n".join(flines))
# generate RTF and return it as a string.
def generateRTF(self):
ls = self.lines
s = util.String()
s += r"{\rtf1\ansi\deff0{\fonttbl{\f0\fmodern Courier;}}" + "\n"
s+= "{\\stylesheet\n"
mt = util.mm2twips
fs = self.cfg.fontSize
# since some of our units (beforeSpacing, indent, width) are
# easier to handle if we assume normal font size, this is a scale
# factor from actual font size to normal font size
sf = fs / 12.0
for ti in config.getTIs():
t = self.cfg.getType(ti.lt)
tt = t.export
# font size is expressed as font size * 2 in RTF
tmp = " \\fs%d" % (fs * 2)
if tt.isCaps:
tmp += r" \caps"
if tt.isBold:
tmp += r" \b"
if tt.isItalic:
tmp += r" \i"
if tt.isUnderlined:
tmp += r" \ul"
# some hairy conversions going on here...
tmp += r" \li%d\ri%d" % (sf * t.indent * 144,
mt(self.cfg.paperWidth) -
(mt(self.cfg.marginLeft + self.cfg.marginRight) +
(t.indent + t.width) * 144 * sf))
tmp += r" \sb%d" % (sf * t.beforeSpacing * 24)
s += "{\\s%d%s %s}\n" % (ti.lt, tmp, ti.name)
s += "}\n"
s += r"\paperw%d\paperh%d\margt%d\margr%d\margb%d\margl%d" % (
mt(self.cfg.paperWidth), mt(self.cfg.paperHeight),
mt(self.cfg.marginTop), mt(self.cfg.marginRight),
mt(self.cfg.marginBottom), mt(self.cfg.marginLeft))
s += "\n"
s += self.titles.generateRTF()
length = len(ls)
i = 0
magicslash = "TRELBY-MAGIC-SLASH"
while i < length:
lt = ls[i].lt
text = ""
while 1:
ln = ls[i]
i += 1
lb = ln.lb
text += ln.text
if lb in (LB_SPACE, LB_NONE):
text += config.lb2str(lb)
elif lb == LB_FORCED:
text += magicslash + "line "
elif lb == LB_LAST:
break
else:
raise error.MiscError("Unknown line break style %d"
" in generateRTF" % lb)
s += (r"{\pard \s%d " % lt) + util.escapeRTF(text).replace(
magicslash, "\\") + "}{\\par}\n"
s += "}"
return str(s)
# generate PDF and return it as a string. assumes paginate/reformat is
# 100% correct for the screenplay. isExport is True if this is an
# "export to file" operation, False if we're just going to launch a
# PDF viewer with the data.
def generatePDF(self, isExport):
return pdf.generate(self.generatePML(isExport))
# Same arguments as generatePDF, but returns a PML document.
def generatePML(self, isExport):
pager = mypager.Pager(self.cfg)
self.titles.generatePages(pager.doc)
pager.doc.showTOC = self.cfg.pdfShowTOC
if not isExport and self.cfg.pdfOpenOnCurrentPage:
pager.doc.defPage = len(self.titles.pages) + \
self.line2page(self.line) - 1
for i in range(1, len(self.pages)):
pg = self.generatePMLPage(pager, i, True, True)
if pg:
pager.doc.add(pg)
else:
break
for pfi in self.cfg.getPDFFontIds():
pf = self.cfg.getPDFFont(pfi)
if pf.pdfName:
# TODO: it's nasty calling loadFile from here since it
# uses wxMessageBox. dialog stacking order is also wrong
# since we don't know the frame to give. so, we should
# remove references to wxMessageBox from util and instead
# pass in an ErrorHandlerObject to all functions that need
# it. then the GUI program can use a subclass of that that
# stores the frame pointer inside it, and testing
# framework / other non-interactive uses can use a version
# that logs errors to stderr / raises an exception /
# whatever.
if pf.filename != "":
# we load at most 10 MB to avoid a denial-of-service
# attack by passing around scripts containing
# references to fonts with filenames like "/dev/zero"
# etc. no real font that I know of is this big so it
# shouldn't hurt.
fontProgram = util.loadFile(pf.filename, None,
10 * 1024 * 1024)
else:
fontProgram = None
pager.doc.addFont(pf.style,
pml.PDFFontInfo(pf.pdfName, fontProgram))
return pager.doc
# generate one page of PML data and return it.
#
# if forPDF is True, output is meant for PDF generation.
#
# if doExtra is False, omits headers and other stuff that is
# automatically added, i.e. outputs only actual screenplay lines. also
# text style/capitalization is not done 100% correctly. this should
# only be True for callers that do not show the results in any way,
# just calculate things based on text positions.
#
# can also return None, which means pagination is not up-to-date and
# the given page number doesn't point to a valid page anymore, and the
# caller should stop calling this since all pages have been generated
# (assuming 1-to-n calling sequence).
def generatePMLPage(self, pager, pageNr, forPDF, doExtra):
#lsdjflksj = util.TimerDev("generatePMLPage")
cfg = self.cfg
ls = self.lines
fs = cfg.fontSize
chX = util.getTextWidth(" ", pml.COURIER, fs)
chY = util.getTextHeight(fs)
length = len(ls)
start = self.pages[pageNr - 1] + 1
if start >= length:
# text has been deleted at end of script and pagination has
# not been updated.
return None
# pagination may not be up-to-date, so any overflow text gets
# dumped onto the last page which may thus be arbitrarily long.
if pageNr == (len(self.pages) - 1):
end = length - 1
else:
# another side-effect is that if text is deleted at the end,
# self.pages can point to lines that no longer exist, so we
# need to clamp it.
end = util.clamp(self.pages[pageNr], maxVal = length - 1)
pg = pml.Page(pager.doc)
# what line we're on, counted from first line after top
# margin, units = line / 10
y = 0
if pageNr != 1:
if doExtra:
self.headers.generatePML(pg, str(pageNr), cfg)
y += self.headers.getNrOfLines() * 10
if cfg.sceneContinueds and not self.isFirstLineOfScene(start):
if doExtra:
s = cfg.strContinuedPageStart
if pager.sceneContNr != 0:
s += " (%d)" % (pager.sceneContNr + 1)
pg.add(pml.TextOp(s,
cfg.marginLeft + pager.sceneIndent * chX,
cfg.marginTop + (y / 10.0) * chY, fs))
pager.sceneContNr += 1
if cfg.pdfShowSceneNumbers:
self.addSceneNumbers(pg, "%d" % pager.scene,
cfg.getType(SCENE).width, y, chX, chY)
y += 20
if self.needsMore(start - 1):
if doExtra:
pg.add(pml.TextOp(self.getPrevSpeaker(start) +
cfg.strDialogueContinued,
cfg.marginLeft + pager.charIndent * chX,
cfg.marginTop + (y / 10.0) * chY, fs))
y += 10
for i in range(start, end + 1):
line = ls[i]
tcfg = cfg.getType(line.lt)
if i != start:
y += self.getSpacingBefore(i)
typ = pml.NORMAL
if doExtra:
if forPDF:
tt = tcfg.export
else:
tt = tcfg.screen
if tt.isCaps:
text = util.upper(line.text)
else:
text = line.text
if tt.isBold:
typ |= pml.BOLD
if tt.isItalic:
typ |= pml.ITALIC
if tt.isUnderlined:
typ |= pml.UNDERLINED
else:
text = line.text
extraIndent = 1 if self.needsExtraParenIndent(i) else 0
to = pml.TextOp(text,
cfg.marginLeft + (tcfg.indent + extraIndent) * chX,
cfg.marginTop + (y / 10.0) * chY, fs, typ, line = i)
pg.add(to)
if forPDF and (tcfg.lt == NOTE) and cfg.pdfOutlineNotes:
offset = chX / 2.0
nx = cfg.marginLeft + tcfg.indent * chX
ny = cfg.marginTop + (y / 10.0) * chY
nw = tcfg.width * chX
lw = 0.25
pg.add(pml.genLine(nx - offset, ny, 0.0, chY, lw))
pg.add(pml.genLine(nx + nw + offset, ny, 0.0, chY, lw))
if self.isFirstLineOfElem(i):
pg.add(pml.QuarterCircleOp(nx, ny, offset, lw))
pg.add(pml.genLine(nx, ny - offset, nw, 0.0, lw))
pg.add(pml.QuarterCircleOp(nx + nw, ny, offset, lw, True))
pg.add(pml.TextOp("Note",
(nx + nx + nw) / 2.0, ny - offset, 6, pml.ITALIC,
util.ALIGN_CENTER, util.VALIGN_BOTTOM))
if self.isLastLineOfElem(i):
pg.add(pml.QuarterCircleOp(nx, ny + chY, offset, lw,
False, True))
pg.add(pml.genLine(nx, ny + chY + offset, nw, 0.0, lw))
pg.add(pml.QuarterCircleOp(nx + nw, ny + chY, offset, lw,
True, True))
if doExtra and (tcfg.lt == SCENE) and self.isFirstLineOfElem(i):
pager.sceneContNr = 0
if cfg.pdfShowSceneNumbers:
pager.scene += 1
self.addSceneNumbers(pg, "%d" % pager.scene, tcfg.width,
y, chX, chY)
if cfg.pdfIncludeTOC:
if cfg.pdfShowSceneNumbers:
s = "%d %s" % (pager.scene, text)
else:
s = text
to.toc = pml.TOCItem(s, to)
pager.doc.addTOC(to.toc)
if doExtra and cfg.pdfShowLineNumbers:
pg.add(pml.TextOp("%02d" % (i - start + 1),
cfg.marginLeft - 3 * chX,
cfg.marginTop + (y / 10.0) * chY, fs))
y += 10
if self.needsMore(end):
if doExtra:
pg.add(pml.TextOp(cfg.strMore,
cfg.marginLeft + pager.charIndent * chX,
cfg.marginTop + (y / 10.0) * chY, fs))
y += 10
if cfg.sceneContinueds and not self.isLastLineOfScene(end):
if doExtra:
pg.add(pml.TextOp(cfg.strContinuedPageEnd,
cfg.marginLeft + cfg.sceneContinuedIndent * chX,
cfg.marginTop + (y / 10.0 + 1.0) * chY, fs))
y += 10
if forPDF and cfg.pdfShowMargins:
lx = cfg.marginLeft
rx = cfg.paperWidth - cfg.marginRight
uy = cfg.marginTop
dy = cfg.paperHeight - cfg.marginBottom
pg.add(pml.LineOp([(lx, uy), (rx, uy), (rx, dy), (lx, dy)],
0, True))
return pg
def addSceneNumbers(self, pg, s, width, y, chX, chY):
cfg = self.cfg
pg.add(pml.TextOp(s, cfg.marginLeft - 6 * chX,
cfg.marginTop + (y / 10.0) * chY, cfg.fontSize))
pg.add(pml.TextOp(s, cfg.marginLeft + (width + 1) * chX,
cfg.marginTop + (y / 10.0) * chY, cfg.fontSize))
# get topLine, clamping it to the valid range in the process.
def getTopLine(self):
self._topLine = util.clamp(self._topLine, 0, len(self.lines) - 1)
return self._topLine
# set topLine, clamping it to the valid range.
def setTopLine(self, line):
self._topLine = util.clamp(line, 0, len(self.lines) - 1)
def reformatAll(self):
# doing a reformatAll while we have undo history will completely
# break undo, so that can't be allowed.
assert not self.firstUndo
#sfdlksjf = util.TimerDev("reformatAll")
line = 0
while 1:
line += self.rewrapPara(line)
if line >= len(self.lines):
break
# reformat part of the screenplay. par1 is line number of paragraph to
# start at, par2 the same for the ending one, inclusive.
def reformatRange(self, par1, par2):
ls = self.lines
# add special tag to last paragraph we'll reformat
ls[par2].reformatMarker = 0
end = False
line = par1
while 1:
if hasattr(ls[line], "reformatMarker"):
del ls[line].reformatMarker
end = True
line += self.rewrapPara(line)
if end:
break
# wraps a single line into however many lines are needed, according to
# the type's width. doesn't modify the input line, returns a list of
# new lines.
def wrapLine(self, line):
ret = []
width = self.cfg.getType(line.lt).width
isParen = line.lt == PAREN
# text remaining to be wrapped
text = line.text
while 1:
# reduce parenthetical width by 1 from second line onwards
if isParen and ret:
w = width - 1
else:
w = width
if len(text) <= w:
ret.append(Line(line.lb, line.lt, text))
break
else:
i = text.rfind(" ", 0, w + 1)
if i == w:
# we allow space characters to go over the line
# length, for two reasons:
#
# 1) it is impossible to get the behavior right
# otherwise in situations where a line ends in two
# spaces and the user inserts a non-space character at
# the end of the line. the cursor must be positioned
# at the second space character for this to work
# right, and the only way to get that is to allow
# spaces to go over the normal line length.
#
# 2) doing this results in no harm, since space
# characters print as empty, so they don't overwrite
# anything.
i += 1
while text[i:i + 1] == " ":
i += 1
if i == len(text):
ret.append(Line(line.lb, line.lt, text))
break
else:
ret.append(Line(LB_SPACE, line.lt, text[0:i - 1]))
text = text[i:]
elif i >= 0:
ret.append(Line(LB_SPACE, line.lt, text[0:i]))
text = text[i + 1:]
else:
ret.append(Line(LB_NONE, line.lt, text[0:w]))
text = text[w:]
return ret
# rewrap paragraph starting at given line. returns the number of lines
# in the wrapped paragraph. if line1 is -1, rewraps paragraph
# containing self.line. maintains cursor position correctness.
def rewrapPara(self, line1 = -1):
ls = self.lines
if line1 == -1:
line1 = self.getParaFirstIndexFromLine(self.line)
line2 = line1
while ls[line2].lb not in (LB_LAST, LB_FORCED):
line2 += 1
if (self.line >= line1) and (self.line <= line2):
# cursor is in this paragraph, save its offset from the
# beginning of the paragraph
cursorOffset = 0
for i in range(line1, line2 + 1):
if i == self.line:
cursorOffset += self.column
break
else:
cursorOffset += len(ls[i].text) + \
len(config.lb2str(ls[i].lb))
else:
cursorOffset = -1
s = ls[line1].text
for i in range(line1 + 1, line2 + 1):
s += config.lb2str(ls[i - 1].lb)
s += ls[i].text
tmp = Line(ls[line2].lb, ls[line1].lt, s)
wrappedLines = self.wrapLine(tmp)
ls[line1:line2 + 1] = wrappedLines
# adjust cursor position
if cursorOffset != -1:
for i in range(line1, line1 + len(wrappedLines)):
ln = ls[i]
llen = len(ln.text) + len(config.lb2str(ln.lb))
if cursorOffset < llen:
self.line = i
self.column = min(cursorOffset, len(ln.text))
break
else:
cursorOffset -= llen
elif self.line >= line1:
# cursor position is below current paragraph, modify its
# linenumber appropriately
self.line += len(wrappedLines) - (line2 - line1 + 1)
return len(wrappedLines)
# rewraps paragraph previous to current one.
def rewrapPrevPara(self):
line = self.getParaFirstIndexFromLine(self.line)
if line == 0:
return
line = self.getParaFirstIndexFromLine(line - 1)
self.rewrapPara(line)
# rewrap element starting at given line. if line is -1, rewraps
# element containing self.line.
def rewrapElem(self, line = -1):
ls = self.lines
if line == -1:
line = self.getElemFirstIndex()
while 1:
line += self.rewrapPara(line)
if ls[line - 1].lb == LB_LAST:
break
def isFirstLineOfElem(self, line):
return (line == 0) or (self.lines[line - 1].lb == LB_LAST)
def isLastLineOfElem(self, line):
return self.lines[line].lb == LB_LAST
def isOnlyLineOfElem(self, line):
# this is just "isLastLineOfElem(line) and isFirstLineOfElem(line)"
# inlined here, since it's 130% faster this way.
return (self.lines[line].lb == LB_LAST) and \
((line == 0) or (self.lines[line - 1].lb == LB_LAST))
# get first index of paragraph
def getParaFirstIndexFromLine(self, line):
ls = self.lines
while 1:
tmp = line - 1
if tmp < 0:
break
if ls[tmp].lb in (LB_LAST, LB_FORCED):
break
line -= 1
return line
# get last index of paragraph
def getParaLastIndexFromLine(self, line):
ls = self.lines
while 1:
if ls[line].lb in (LB_LAST, LB_FORCED):
break
if (line + 1) >= len(ls):
break
line += 1
return line
def getElemFirstIndex(self):
return self.getElemFirstIndexFromLine(self.line)
def getElemFirstIndexFromLine(self, line):
ls = self.lines
while 1:
tmp = line - 1
if tmp < 0:
break
if ls[tmp].lb == LB_LAST:
break
line -= 1
return line
def getElemLastIndex(self):
return self.getElemLastIndexFromLine(self.line)
def getElemLastIndexFromLine(self, line):
ls = self.lines
while 1:
if ls[line].lb == LB_LAST:
break
if (line + 1) >= len(ls):
break
line += 1
return line
def getElemIndexes(self):
return self.getElemIndexesFromLine(self.line)
def getElemIndexesFromLine(self, line):
return (self.getElemFirstIndexFromLine(line),
self.getElemLastIndexFromLine(line))
def isFirstLineOfScene(self, line):
if line == 0:
return True
ls = self.lines
if ls[line].lt != SCENE:
return False
l = ls[line - 1]
return (l.lt != SCENE) or (l.lb == LB_LAST)
def isLastLineOfScene(self, line):
ls = self.lines
if ls[line].lb != LB_LAST:
return False
if line == (len(ls) - 1):
return True
return ls[line + 1].lt == SCENE
def getTypeOfPrevElem(self, line):
line = self.getElemFirstIndexFromLine(line)
line -= 1
if line < 0:
return None
return self.lines[line].lt
def getTypeOfNextElem(self, line):
line = self.getElemLastIndexFromLine(line)
line += 1
if line >= len(self.lines):
return None
return self.lines[line].lt
def getSceneIndexes(self):
return self.getSceneIndexesFromLine(self.line)
def getSceneIndexesFromLine(self, line):
top, bottom = self.getElemIndexesFromLine(line)
ls = self.lines
while 1:
if ls[top].lt in (SCENE, ACTBREAK):
break
tmp = top - 1
if tmp < 0:
break
top = self.getElemIndexesFromLine(tmp)[0]
while 1:
tmp = bottom + 1
if tmp >= len(ls):
break
if ls[tmp].lt in (SCENE, ACTBREAK):
break
bottom = self.getElemIndexesFromLine(tmp)[1]
return (top, bottom)
# return scene number for the given line. if line is -1, return 0.
def getSceneNumber(self, line):
ls = self.lines
sc = SCENE
scene = 0
for i in range(line + 1):
if (ls[i].lt == sc) and self.isFirstLineOfElem(i):
scene += 1
return scene
# return how many elements one must advance to get from element
# containing line1 to element containing line2. line1 must be <=
# line2, and either line can be anywhere in their respective elements.
# returns 0 if they're in the same element, 1 if they're in
# consecutive elements, etc.
def elemsDistance(self, line1, line2):
ls = self.lines
count = 0
line = line1
while line < line2:
if ls[line].lb == LB_LAST:
count += 1
line += 1
return count
# returns true if 'line', which must be the last line on a page, needs
# (MORE) after it and the next page needs a "SOMEBODY (cont'd)".
def needsMore(self, line):
ls = self.lines
return ls[line].lt in (DIALOGUE, PAREN)\
and (line != (len(ls) - 1)) and\
ls[line + 1].lt in (DIALOGUE, PAREN)
# starting at line, go backwards until a line with type of CHARACTER
# and lb of LAST is found, and return that line's text, possibly
# upper-cased if CHARACTER's config for export says so.
def getPrevSpeaker(self, line):
ls = self.lines
while 1:
if line < 0:
return "UNKNOWN"
ln = ls[line]
if (ln.lt == CHARACTER) and (ln.lb == LB_LAST):
s = ln.text
if self.cfg.getType(CHARACTER).export.isCaps:
s = util.upper(s)
return s
line -= 1
# return total number of characters in script
def getCharCount(self):
return sum([len(ln.text) for ln in self.lines])
def paginate(self):
#sfdlksjf = util.TimerDev("paginate")
self.pages = [-1]
self.pagesNoAdjust = [-1]
ls = self.lines
cfg = self.cfg
length = len(ls)
lastBreak = -1
# fast aliases for stuff
lbl = LB_LAST
ct = cfg.types
hdrLines = self.headers.getNrOfLines()
i = 0
while 1:
lp = cfg.linesOnPage * 10
if i != 0:
lp -= hdrLines * 10
# decrease by 2 if we have to put a "CONTINUED:" on top of
# this page.
if cfg.sceneContinueds and not self.isFirstLineOfScene(i):
lp -= 20
# decrease by 1 if we have to put a "WHOEVER (cont'd)" on
# top of this page.
if self.needsMore(i - 1):
lp -= 10
# just a safeguard
lp = max(50, lp)
pageLines = 0
if i < length:
pageLines = 10
# advance i until it points to the last line to put on
# this page (before adjustments)
while i < (length - 1):
pageLines += 10
if ls[i].lb == lbl:
pageLines += ct[ls[i + 1].lt].beforeSpacing
else:
pageLines += ct[ls[i + 1].lt].intraSpacing
if pageLines > lp:
break
i += 1
if i >= (length - 1):
if pageLines != 0:
self.pages.append(length - 1)
self.pagesNoAdjust.append(length - 1)
break
self.pagesNoAdjust.append(i)
line = ls[i]
if line.lt == SCENE:
i = self.removeDanglingElement(i, SCENE, lastBreak)
elif line.lt == SHOT:
i = self.removeDanglingElement(i, SHOT, lastBreak)
i = self.removeDanglingElement(i, SCENE, lastBreak)
elif line.lt == ACTION:
if line.lb != LB_LAST:
first = self.getElemFirstIndexFromLine(i)
if first > (lastBreak + 1):
linesOnThisPage = i - first + 1
if linesOnThisPage < cfg.pbActionLines:
i = first - 1
i = self.removeDanglingElement(i, SCENE,
lastBreak)
elif line.lt == CHARACTER:
i = self.removeDanglingElement(i, CHARACTER, lastBreak)
i = self.removeDanglingElement(i, SCENE, lastBreak)
elif line.lt in (DIALOGUE, PAREN):
if line.lb != LB_LAST or\
self.getTypeOfNextElem(i) in (DIALOGUE, PAREN):
cutDialogue = False
cutParen = False
while 1:
oldI = i
line = ls[i]
if line.lt == PAREN:
i = self.removeDanglingElement(i, PAREN,
lastBreak)
cutParen = True
elif line.lt == DIALOGUE:
if cutParen:
break
first = self.getElemFirstIndexFromLine(i)
if first > (lastBreak + 1):
linesOnThisPage = i - first + 1
# do we need to reserve one line for (MORE)
reserveLine = not (cutDialogue or cutParen)
val = cfg.pbDialogueLines
if reserveLine:
val += 1
if linesOnThisPage < val:
i = first - 1
cutDialogue = True
else:
if reserveLine:
i -= 1
break
else:
# leave space for (MORE)
i -= 1
break
elif line.lt == CHARACTER:
i = self.removeDanglingElement(i, CHARACTER,
lastBreak)
i = self.removeDanglingElement(i, SCENE,
lastBreak)
break
else:
break
if i == oldI:
break
# make sure no matter how buggy the code above is, we always
# advance at least one line per page
i = max(i, lastBreak + 1)
self.pages.append(i)
lastBreak = i
i += 1
self.lastPaginated = time.time()
def removeDanglingElement(self, line, lt, lastBreak):
ls = self.lines
startLine = line
while 1:
if line < (lastBreak + 2):
break
ln = ls[line]
if ln.lt != lt:
break
# only remove one element at most, to avoid generating
# potentially thousands of pages in degenerate cases when
# script only contains scenes or characters or something like
# that.
if (line != startLine) and (ln.lb == LB_LAST):
break
line -= 1
return line
# convert element(s) to given type
# - if multiple elements are selected, all are changed
# - if not, the change is applied to element under cursor.
def convertTypeTo(self, lt, saveUndo):
ls = self.lines
selection = self.getMarkedLines()
if selection:
startSection, endSection = selection
selectedElems = self.elemsDistance(startSection, endSection) + 1
else:
startSection, endSection = self.getElemIndexes()
selectedElems = 1
currentLine = startSection
if saveUndo:
u = undo.ManyElems(
self, undo.CMD_MISC, currentLine, selectedElems, selectedElems)
while currentLine <= endSection:
first, last = self.getElemIndexesFromLine(currentLine)
# if changing away from PAREN containing only "()", remove it
if (first == last) and (ls[first].lt == PAREN) and\
(ls[first].text == "()"):
ls[first].text = ""
if first == self.line:
self.column = 0
for i in range(first, last + 1):
ls[i].lt = lt
# if changing empty element to PAREN, add "()"
if (first == last) and (ls[first].lt == PAREN) and\
(len(ls[first].text) == 0):
ls[first].text = "()"
if first == self.line:
self.column = 1
currentLine = last + 1
if selection:
self.clearMark()
# this is moderately complex because we need to deal with
# forced linebreaks; reformatRange wants paragraph indexes but
# we are converting elements, so we must find the indexes of
# the a) first paragraph of the first selected element and b)
# last paragraph of the last selected element
self.reformatRange(
self.getElemFirstIndexFromLine(startSection),
self.getParaFirstIndexFromLine(self.getElemLastIndexFromLine(endSection)))
else:
self.rewrapElem(first)
self.markChanged()
if saveUndo:
u.setAfter(self)
self.addUndo(u)
# join lines 'line' and 'line + 1' and position cursor at the join
# position.
def joinLines(self, line):
ls = self.lines
ln = ls[line]
pos = len(ln.text)
ln.text += ls[line + 1].text
ln.lb = ls[line + 1].lb
self.setLineTypes(line + 1, ln.lt)
del ls[line + 1]
self.line = line
self.column = pos
# split current line at current column position.
def splitLine(self):
ln = self.lines[self.line]
s = ln.text
preStr = s[:self.column]
postStr = s[self.column:]
newLine = Line(ln.lb, ln.lt, postStr)
ln.text = preStr
ln.lb = LB_FORCED
self.lines.insert(self.line + 1, newLine)
self.line += 1
self.column = 0
self.markChanged()
# split element at current position. newType is type to give to the
# new element.
def splitElement(self, newType):
ls = self.lines
if self.mark:
self.clearMark()
return
u = undo.ManyElems(self, undo.CMD_MISC, self.line, 1, 2)
if not self.acItems:
if self.isAtEndOfParen():
self.column += 1
else:
ls[self.line].text = self.acItems[self.acSel]
self.column = len(ls[self.line].text)
self.splitLine()
ls[self.line - 1].lb = LB_LAST
self.convertTypeTo(newType, False)
self.rewrapPara()
self.rewrapPrevPara()
self.markChanged()
u.setAfter(self)
self.addUndo(u)
# delete character at given position and optionally position
# cursor there.
def deleteChar(self, line, column, posCursor = True):
s = self.lines[line].text
self.lines[line].text = s[:column] + s[column + 1:]
if posCursor:
self.column = column
self.line = line
# set line types from 'line' to the end of the element to 'lt'.
def setLineTypes(self, line, lt):
ls = self.lines
while 1:
ln = ls[line]
ln.lt = lt
if ln.lb == LB_LAST:
break
line += 1
def line2page(self, line):
return self.line2pageReal(line, self.pages)
def line2pageNoAdjust(self, line):
return self.line2pageReal(line, self.pagesNoAdjust)
def line2pageReal(self, line, p):
lo = 1
hi = len(p) - 1
while lo != hi:
mid = (lo + hi) // 2
if line <= p[mid]:
hi = mid
else:
lo = mid + 1
return lo
# return (startLine, endLine) for given page number (1-based). if
# pageNr is out of bounds, it is clamped to the valid range. if
# pagination is out of date and the lines no longer exist, they are
# clamped to the valid range as well.
def page2lines(self, pageNr):
pageNr = util.clamp(pageNr, 1, len(self.pages) - 1)
last = len(self.lines) - 1
return (util.clamp(self.pages[pageNr - 1] + 1, 0, last),
util.clamp(self.pages[pageNr], 0, last))
# return a list of all page numbers as strings.
def getPageNumbers(self):
pages = []
for p in range(1, len(self.pages)):
pages.append(str(p))
return pages
# return a list of all scene locations in a [(sceneNumber, startLine),
# ...] format. if script does not start with a scene line, that scene
# is not included in this list. note that the sceneNumber in the
# returned list is a string, not a number.
def getSceneLocations(self):
ls = self.lines
sc = SCENE
scene = 0
ret = []
for i in range(len(ls)):
if (ls[i].lt == sc) and self.isFirstLineOfElem(i):
scene += 1
ret.append((str(scene), i))
return ret
# return a dictionary of all scene names (single-line text elements
# only, upper-cased, values = None).
def getSceneNames(self):
names = {}
for ln in self.lines:
if (ln.lt == SCENE) and (ln.lb == LB_LAST):
names[util.upper(ln.text)] = None
return names
# return a dictionary of all character names (single-line text
# elements only, lower-cased, values = None).
def getCharacterNames(self):
names = {}
ul = util.lower
for ln in self.lines:
if (ln.lt == CHARACTER) and (ln.lb == LB_LAST):
names[ul(ln.text)] = None
return names
# get next word, starting at (line, col). line must be valid, but col
# can point after the line's length, in which case the search starts
# at (line + 1, 0). returns (word, line, col), where word is None if
# at end of script, and (line, col) point to the start of the word.
# note that this only handles words that are on a single line.
def getWord(self, line, col):
ls = self.lines
while 1:
if ((line < 0) or (line >= len(ls))):
return (None, 0, 0)
s = ls[line].text
if col >= len(s):
line += 1
col = 0
continue
ch = s[col : col + 1]
if not util.isWordBoundary(ch):
word = ch
startCol = col
col += 1
while col < len(s):
ch = s[col : col + 1]
if util.isWordBoundary(ch):
break
word += ch
col += 1
return (word, line, startCol)
else:
col += 1
# returns True if we're at second-to-last character of PAREN element,
# and last character is ")"
def isAtEndOfParen(self):
ls = self.lines
return self.isLastLineOfElem(self.line) and\
(ls[self.line].lt == PAREN) and\
(ls[self.line].text[self.column:] == ")")
# returns True if pressing TAB at current position would make a new
# element, False if it would just change element's type.
def tabMakesNew(self):
l = self.lines[self.line]
if self.isAtEndOfParen():
return True
if (l.lb != LB_LAST) or (self.column != len(l.text)):
return False
if (len(l.text) == 0) and self.isOnlyLineOfElem(self.line):
return False
return True
# if auto-completion is active, clear it and return True. otherwise
# return False.
def clearAutoComp(self):
if not self.acItems:
return False
self.acItems = None
return True
def fillAutoComp(self):
ls = self.lines
lt = ls[self.line].lt
t = self.autoCompletion.getType(lt)
if t and t.enabled:
self.acItems = self.getMatchingText(ls[self.line].text, lt)
self.acSel = 0
# page up (dir == -1) or page down (dir == 1) was pressed and we're in
# auto-comp mode, handle it.
def pageScrollAutoComp(self, dir):
if len(self.acItems) > self.acMax:
if dir < 0:
self.acSel -= self.acMax
if self.acSel < 0:
self.acSel = len(self.acItems) - 1
else:
self.acSel = (self.acSel + self.acMax) % len(self.acItems)
# get a list of strings (single-line text elements for now) that start
# with 'text' (not case sensitive) and are of of type 'type'. also
# mixes in the type's default items from config. ignores current line.
def getMatchingText(self, text, lt):
text = util.upper(text)
t = self.autoCompletion.getType(lt)
ls = self.lines
matches = {}
last = None
for i in range(len(ls)):
if (ls[i].lt == lt) and (ls[i].lb == LB_LAST):
upstr = util.upper(ls[i].text)
if upstr.startswith(text) and i != self.line:
matches[upstr] = None
if i < self.line:
last = upstr
for s in t.items:
upstr = util.upper(s)
if upstr.startswith(text):
matches[upstr] = None
if last:
del matches[last]
mlist = list(matches.keys())
mlist.sort()
if last:
mlist.insert(0, last)
return mlist
# returns pair (start, end) of marked lines, inclusive. if mark is
# after the end of the script (text has been deleted since setting
# it), returns a valid pair (by truncating selection to current
# end). returns None if no lines marked.
def getMarkedLines(self):
if not self.mark:
return None
mark = min(len(self.lines) - 1, self.mark.line)
if self.line < mark:
return (self.line, mark)
else:
return (mark, self.line)
# returns pair (start, end) (inclusive) of marked columns for the
# given line (line must be inside the marked lines). 'marked' is the
# value returned from getMarkedLines. if marked column is invalid
# (text has been deleted since setting the mark), returns a valid pair
# by truncating selection as needed. returns None on errors.
def getMarkedColumns(self, line, marked):
if not self.mark:
return None
# line is not marked at all
if (line < marked[0]) or (line > marked[1]):
return None
ls = self.lines
# last valid offset for given line's text
lvo = max(0, len(ls[line].text) - 1)
# only one line marked
if (line == marked[0]) and (marked[0] == marked[1]):
c1 = min(self.mark.column, self.column)
c2 = max(self.mark.column, self.column)
# line is between end lines, so totally marked
elif (line > marked[0]) and (line < marked[1]):
c1 = 0
c2 = lvo
# line is first line marked
elif line == marked[0]:
if line == self.line:
c1 = self.column
else:
c1 = self.mark.column
c2 = lvo
# line is last line marked
elif line == marked[1]:
if line == self.line:
c2 = self.column
else:
c2 = self.mark.column
c1 = 0
# should't happen
else:
return None
c1 = util.clamp(c1, 0, lvo)
c2 = util.clamp(c2, 0, lvo)
return (c1, c2)
# checks if a line is marked. 'marked' is the value returned from
# getMarkedLines.
def isLineMarked(self, line, marked):
return (line >= marked[0]) and (line <= marked[1])
# get selected text as a ClipData object, optionally deleting it from
# the script. if nothing is selected, returns None.
def getSelectedAsCD(self, doDelete):
marked = self.getMarkedLines()
if not marked:
return None
ls = self.lines
cd = ClipData()
for i in range(marked[0], marked[1] + 1):
c1, c2 = self.getMarkedColumns(i, marked)
ln = ls[i]
cd.lines.append(Line(ln.lb, ln.lt, ln.text[c1:c2 + 1]))
cd.lines[-1].lb = LB_LAST
if not doDelete:
return cd
u = undo.AnyDifference(self)
# range of lines, inclusive, that we need to totally delete
del1 = sys.maxsize
del2 = -1
# delete selected text from the lines
for i in range(marked[0], marked[1] + 1):
c1, c2 = self.getMarkedColumns(i, marked)
ln = ls[i]
ln.text = ln.text[0:c1] + ln.text[c2 + 1:]
if i == marked[0]:
endCol = c1
# if we removed all text, mark this line to be deleted
if len(ln.text) == 0:
del1 = min(del1, i)
del2 = max(del2, i)
# adjust linebreaks
if marked[0] == marked[1]:
# user has selected text from a single line only
ln = ls[marked[0]]
# if it is a single-line element, we never need to modify
# its linebreak
if not self.isOnlyLineOfElem(marked[0]):
# if we're totally deleting the line and it's the last
# line of a multi-line element, mark the preceding
# line as the new last line of the element.
if not ln.text and self.isLastLineOfElem(marked[0]):
ls[marked[0] - 1].lb = LB_LAST
else:
# now find the line whose linebreak we need to adjust. if
# the starting line is not completely removed, it is that,
# otherwise it is the preceding line, unless we delete the
# first line of the element, in which case there's nothing
# to adjust.
if ls[marked[0]].text:
ln = ls[marked[0]]
else:
if not self.isFirstLineOfElem(marked[0]):
ln = ls[marked[0] - 1]
else:
ln = None
if ln:
# if the selection ends by removing completely the
# last line of an element, we need to mark the
# element's new end, otherwise we must set it to
# LB_NONE so that the new element is reformatted
# properly.
if self.isLastLineOfElem(marked[1]) and \
not ls[marked[1]].text:
ln.lb = LB_LAST
else:
ln.lb = LB_NONE
# if we're joining two elements we have to change the line
# types for the latter element (starting from the last marked
# line, because everything before that will get deleted
# anyway) to that of the first element.
self.setLineTypes(marked[1], ls[marked[0]].lt)
del ls[del1:del2 + 1]
self.clearMark()
if len(ls) == 0:
ls.append(Line(LB_LAST, SCENE))
self.line = min(marked[0], len(ls) - 1)
self.column = min(endCol, len(ls[self.line].text))
self.rewrapElem()
self.markChanged()
u.setAfter(self)
self.addUndo(u)
return cd
# paste data into script. clines is a list of Line objects.
def paste(self, clines):
if len(clines) == 0:
return
u = undo.AnyDifference(self)
inLines = []
i = 0
# wrap all paragraphs into single lines
while 1:
if i >= len(clines):
break
ln = clines[i]
newLine = Line(LB_LAST, ln.lt)
while 1:
ln = clines[i]
i += 1
newLine.text += ln.text
if ln.lb in (LB_LAST, LB_FORCED):
break
newLine.text += config.lb2str(ln.lb)
newLine.lb = ln.lb
inLines.append(newLine)
# shouldn't happen, but...
if len(inLines) == 0:
return
ls = self.lines
# where we need to start wrapping
wrap1 = self.getParaFirstIndexFromLine(self.line)
ln = ls[self.line]
atEnd = self.column == len(ln.text)
if (len(ln.text) == 0) and self.isOnlyLineOfElem(self.line):
ln.lt = inLines[0].lt
ln.text = ln.text[:self.column] + inLines[0].text + \
ln.text[self.column:]
self.column += len(inLines[0].text)
if len(inLines) != 1:
if not atEnd:
self.splitLine()
ls[self.line - 1].lb = inLines[0].lb
ls[self.line:self.line] = inLines[1:]
self.line += len(inLines) - 2
# FIXME: pasting a multi-paragraph ACTION where first line
# has FORCED lb, in middle of a CHARACTER block, breaks
# things
else:
ls[self.line + 1:self.line + 1] = inLines[1:]
self.line += len(inLines) - 1
# FIXME: this doesn't modify .lb, and pasting a
# multi-paragraph ACTION at end of line in CHARACTER block
# where that line ends in forced linebreak breaks things.
self.column = len(ls[self.line].text)
# FIXME: copy/paste, when copying elements containing forced
# linebreaks, converts them to end of element? this seems like a
# bug...
self.reformatRange(wrap1, self.getParaFirstIndexFromLine(self.line))
u.setAfter(self)
self.addUndo(u)
self.clearMark()
self.clearAutoComp()
self.markChanged()
# returns true if a character, inserted at current position, would
# need to be capitalized as a start of a sentence.
def capitalizeNeeded(self):
if not self.cfgGl.capitalize:
return False
ls = self.lines
line = self.line
column = self.column
text = ls[line].text
if (column < len(text)) and (text[column] != " "):
return False
# go backwards at most 4 characters, looking for "!?.", and
# breaking on anything other than space or ".
cnt = 1
while 1:
column -= 1
char = None
if column < 0:
line -= 1
if line < 0:
return True
lb = ls[line].lb
if lb == LB_LAST:
return True
elif lb == LB_SPACE:
char = " "
column = len(ls[line].text)
else:
text = ls[line].text
column = len(text) - 1
if column < 0:
return True
else:
text = ls[line].text
if not char:
char = text[column]
if cnt == 1:
# must be preceded by a space
if char != " ":
return False
else:
if char in (".", "?", "!"):
return True
elif char not in (" ", "\""):
return False
cnt += 1
if cnt > 4:
break
return False
# find next error in screenplay, starting at given line. returns
# (line, msg) tuple, where line is -1 if no error was found and the
# line number otherwise where the error is, and msg is a description
# of the error
def findError(self, line):
ls = self.lines
cfg = self.cfg
# type of previous line, or None when a new element starts
prevType = None
msg = None
while 1:
if line >= len(ls):
break
ln = ls[line]
tcfg = cfg.getType(ln.lt)
isFirst = self.isFirstLineOfElem(line)
isLast = self.isLastLineOfElem(line)
isOnly = isFirst and isLast
prev = self.getTypeOfPrevElem(line)
next = self.getTypeOfNextElem(line)
# notes are allowed to contain empty lines, because a) they do
# not appear in the final product b) they're basically
# free-format text anyway, and people may want to format them
# however they want
if (len(ln.text) == 0) and (ln.lt != NOTE):
msg = "Empty line."
break
if (len(ln.text.strip("  ")) == 0) and (ln.lt != NOTE):
msg = "Empty line (contains only spaces)."
break
if (ln.lt == PAREN) and isOnly and (ln.text == "()"):
msg = "Empty parenthetical."
break
if ln.text != util.toInputStr(ln.text):
msg = "Line contains invalid characters (BUG)."
break
if len(ln.text.rstrip(" ")) > tcfg.width:
msg = "Line is too long (BUG)."
break
if ln.lt == CHARACTER:
if isLast and next and next not in (PAREN, DIALOGUE):
msg = "Element type '%s' can not follow type '%s'." %\
(cfg.getType(next).ti.name, tcfg.ti.name)
break
if ln.lt == PAREN:
if isFirst and prev and prev not in (CHARACTER, DIALOGUE):
msg = "Element type '%s' can not follow type '%s'." %\
(tcfg.ti.name, cfg.getType(prev).ti.name)
break
if ln.lt == DIALOGUE:
if isFirst and prev and prev not in (CHARACTER, PAREN):
msg = "Element type '%s' can not follow type '%s'." %\
(tcfg.ti.name, cfg.getType(prev).ti.name)
break
if prevType:
if ln.lt != prevType:
msg = "Element contains lines with different line"\
" types (BUG)."
break
if ln.lb == LB_LAST:
prevType = None
else:
prevType = ln.lt
line += 1
if not msg:
line = -1
return (line, msg)
# compare this script to sp2 (Screenplay), return a PDF file (as a
# string) of the differences, or None if the scripts are identical.
def compareScripts(self, sp2):
s1 = self.generateText(False).split("\n")
s2 = sp2.generateText(False).split("\n")
dltTmp = difflib.unified_diff(s1, s2, lineterm = "")
# get rid of stupid delta generator object that doesn't allow
# subscription or anything else really. also expands hunk
# separators into three lines.
dlt = []
i = 0
for s in dltTmp:
if i >= 3:
if s[0] == "@":
dlt.extend(["1", "2", "3"])
else:
dlt.append(s)
i += 1
if len(dlt) == 0:
return None
dltTmp = dlt
# now, generate changed-lines for single-line diffs
dlt = []
for i in range(len(dltTmp)):
s = dltTmp[i]
dlt.append(s)
# this checks that we've just added a sequence of lines whose
# first characters are " -+", where " " means '"not -" or
# missing line', and that we're either at end of list or next
# line does not start with "+".
if (s[0] == "+") and \
(i != 0) and (dltTmp[i - 1][0] == "-") and (
(i == 1) or (dltTmp[i - 2][0] != "-")) and (
(i == (len(dltTmp) - 1)) or (dltTmp[i + 1][0] != "+")):
# generate line with "^" character at every position that
# the lines differ
s1 = dltTmp[i - 1]
s2 = dltTmp[i]
minCnt = min(len(s1), len(s2))
maxCnt = max(len(s1), len(s2))
res = "^"
for i in range(1, minCnt):
if s1[i] != s2[i]:
res += "^"
else:
res += " "
res += "^" * (maxCnt - minCnt)
dlt.append(res)
tmp = [" Color information:", "1", "- Deleted lines",
"+ Added lines",
"^ Positions of single-line changes (marked with ^)", "1",
"2", "2", "3"]
tmp.extend(dlt)
dlt = tmp
cfg = self.cfg
chY = util.getTextHeight(cfg.fontSize)
doc = pml.Document(cfg.paperWidth, cfg.paperHeight)
# how many lines put on current page
y = 0
pg = pml.Page(doc)
# we need to gather text ops for each page into a separate list
# and add that list to the page only after all other ops are
# added, otherwise the colored bars will be drawn partially over
# some characters.
textOps = []
for s in dlt:
if y >= cfg.linesOnPage:
pg.ops.extend(textOps)
doc.add(pg)
pg = pml.Page(doc)
textOps = []
y = 0
if s[0] == "1":
pass
elif s[0] == "3":
pass
elif s[0] == "2":
pg.add(pml.PDFOp("0.75 g"))
w = 50.0
pg.add(pml.RectOp(doc.w / 2.0 - w / 2.0, cfg.marginTop +
y * chY + chY / 4, w, chY / 2.0))
pg.add(pml.PDFOp("0.0 g"))
else:
color = ""
if s[0] == "-":
color = "1.0 0.667 0.667"
elif s[0] == "+":
color = "0.667 1.0 0.667"
elif s[0] == "^":
color = "1.0 1.0 0.467"
if color:
pg.add(pml.PDFOp("%s rg" % color))
pg.add(pml.RectOp(cfg.marginLeft, cfg.marginTop + y * chY,
doc.w - cfg.marginLeft - 5.0, chY))
pg.add(pml.PDFOp("0.0 g"))
textOps.append(pml.TextOp(s[1:], cfg.marginLeft,
cfg.marginTop + y * chY, cfg.fontSize))
y += 1
pg.ops.extend(textOps)
doc.add(pg)
return pdf.generate(doc)
# move to line,col, and if mark is True, set mark there
def gotoPos(self, line, col, mark = False):
self.clearAutoComp()
self.line = line
self.column = col
if mark and not self.mark:
self.setMark(line, col)
# remove all lines whose element types are in tdict as keys.
def removeElementTypes(self, tdict, saveUndo):
self.clearAutoComp()
if saveUndo:
u = undo.FullCopy(self)
lsNew = []
lsOld = self.lines
sl = self.line
# how many lines were removed from above the current line
# (inclusive)
cnt = 0
for i in range(len(lsOld)):
l = lsOld[i]
if l.lt not in tdict:
lsNew.append(l)
else:
if i <= sl:
cnt += 1
self.line -= cnt
if len(lsNew) == 0:
lsNew.append(Line(LB_LAST, SCENE))
self.lines = lsNew
self.validatePos()
self.clearMark()
self.markChanged()
if saveUndo:
u.setAfter(self)
self.addUndo(u)
# set mark at given position
def setMark(self, line, column):
self.mark = Mark(line, column)
# clear mark
def clearMark(self):
self.mark = None
# if doIt is True and mark is not yet set, set it at current position.
def maybeMark(self, doIt):
if doIt and not self.mark:
self.setMark(self.line, self.column)
# make sure current line and column are within the valid bounds.
def validatePos(self):
self.line = util.clamp(self.line, 0, len(self.lines) - 1)
self.column = util.clamp(self.column, 0,
len(self.lines[self.line].text))
# this must be called after each command (all functions named fooCmd
# are commands)
def cmdPost(self, cs):
# TODO: is this needed?
self.column = min(self.column, len(self.lines[self.line].text))
if cs.doAutoComp == cs.AC_DEL:
self.clearAutoComp()
elif cs.doAutoComp == cs.AC_REDO:
self.fillAutoComp()
# helper function for calling commands. name is the name of the
# command, e.g. "moveLeft".
def cmd(self, name, char = None, count = 1):
for i in range(count):
cs = CommandState()
if char:
cs.char = char
getattr(self, name + "Cmd")(cs)
self.cmdPost(cs)
# call addCharCmd for each character in s. ONLY MEANT TO BE USED IN
# TEST CODE.
def cmdChars(self, s):
for char in s:
self.cmd("addChar", char = char)
def moveLeftCmd(self, cs):
self.maybeMark(cs.mark)
if self.column > 0:
self.column -= 1
else:
if self.line > 0:
self.line -= 1
self.column = len(self.lines[self.line].text)
def moveRightCmd(self, cs):
self.maybeMark(cs.mark)
if self.column != len(self.lines[self.line].text):
self.column += 1
else:
if self.line < (len(self.lines) - 1):
self.line += 1
self.column = 0
def moveUpCmd(self, cs):
if not self.acItems:
self.maybeMark(cs.mark)
if self.line > 0:
self.line -= 1
else:
self.acSel -= 1
if self.acSel < 0:
self.acSel = len(self.acItems) - 1
cs.doAutoComp = cs.AC_KEEP
def moveDownCmd(self, cs):
if not self.acItems:
self.maybeMark(cs.mark)
if self.line < (len(self.lines) - 1):
self.line += 1
else:
self.acSel = (self.acSel + 1) % len(self.acItems)
cs.doAutoComp = cs.AC_KEEP
def moveLineEndCmd(self, cs):
if self.acItems:
self.lines[self.line].text = self.acItems[self.acSel]
else:
self.maybeMark(cs.mark)
self.column = len(self.lines[self.line].text)
def moveLineStartCmd(self, cs):
self.maybeMark(cs.mark)
self.column = 0
def moveStartCmd(self, cs):
self.maybeMark(cs.mark)
self.line = 0
self.setTopLine(0)
self.column = 0
def moveEndCmd(self, cs):
self.maybeMark(cs.mark)
self.line = len(self.lines) - 1
self.column = len(self.lines[self.line].text)
def moveSceneUpCmd(self, cs):
self.maybeMark(cs.mark)
tmpUp = self.getSceneIndexes()[0]
if self.line != tmpUp:
self.line = tmpUp
else:
tmpUp -= 1
if tmpUp >= 0:
self.line = self.getSceneIndexesFromLine(tmpUp)[0]
self.column = 0
def moveSceneDownCmd(self, cs):
self.maybeMark(cs.mark)
tmpBottom = self.getSceneIndexes()[1]
self.line = min(len(self.lines) - 1, tmpBottom + 1)
self.column = 0
def deleteBackwardCmd(self, cs):
u = None
mergeUndo = False
# only merge with the previous item in undo history if:
# -we are not in middle of undo/redo
# -previous item is "delete backward"
# -cursor is exactly where it was left off by the previous item
if (not self.currentUndo and self.lastUndo and
(self.lastUndo.getType() == undo.CMD_DEL_BACKWARD) and
(self.lastUndo.endPos == self.cursorAsMark())):
u = self.lastUndo
mergeUndo = True
if self.column != 0:
if not mergeUndo:
u = undo.ManyElems(self, undo.CMD_DEL_BACKWARD, self.line, 1, 1)
self.deleteChar(self.line, self.column - 1)
self.markChanged()
cs.doAutoComp = cs.AC_REDO
else:
if self.line != 0:
ln = self.lines[self.line - 1]
# delete at start of the line of the first line of the
# element means "join up with previous element", so is a
# 2->1 change. otherwise we just delete a character from
# current element so no element count change.
if ln.lb == LB_LAST:
u = undo.ManyElems(self, undo.CMD_MISC, self.line - 1, 2, 1)
mergeUndo = False
else:
if not mergeUndo:
u = undo.ManyElems(self, undo.CMD_DEL_BACKWARD, self.line, 1, 1)
if ln.lb == LB_NONE:
self.deleteChar(self.line - 1, len(ln.text) - 1,
False)
self.joinLines(self.line - 1)
self.markChanged()
self.rewrapElem()
if u:
if mergeUndo:
self.addMergedUndo(u)
else:
u.setAfter(self)
self.addUndo(u)
def deleteForwardCmd(self, cs):
u = None
mergeUndo = False
# only merge with the previous item in undo history if:
# -we are not in middle of undo/redo
# -previous item is "delete forward"
# -cursor is exactly where it was left off by the previous item
if (not self.currentUndo and self.lastUndo and
(self.lastUndo.getType() == undo.CMD_DEL_FORWARD) and
(self.lastUndo.endPos == self.cursorAsMark())):
u = self.lastUndo
mergeUndo = True
if self.column != len(self.lines[self.line].text):
if not mergeUndo:
u = undo.ManyElems(self, undo.CMD_DEL_FORWARD, self.line, 1, 1)
self.deleteChar(self.line, self.column)
self.markChanged()
cs.doAutoComp = cs.AC_REDO
else:
if self.line != (len(self.lines) - 1):
ln = self.lines[self.line]
# delete at end of the line of the last line of the
# element means "join up with next element", so is a 2->1
# change. otherwise we just delete a character from
# current element so no element count change.
if ln.lb == LB_LAST:
u = undo.ManyElems(self, undo.CMD_MISC, self.line, 2, 1)
mergeUndo = False
else:
if not mergeUndo:
u = undo.ManyElems(self, undo.CMD_DEL_FORWARD, self.line, 1, 1)
if ln.lb == LB_NONE:
self.deleteChar(self.line + 1, 0, False)
self.joinLines(self.line)
self.markChanged()
self.rewrapElem()
if u:
if mergeUndo:
self.addMergedUndo(u)
else:
u.setAfter(self)
self.addUndo(u)
# aborts stuff, like selection, auto-completion, etc
def abortCmd(self, cs):
self.clearMark()
# select all text of current scene
def selectSceneCmd(self, cs):
l1, l2 = self.getSceneIndexes()
self.setMark(l1, 0)
self.line = l2
self.column = len(self.lines[l2].text)
# select all text of the screenplay. sets mark at beginning and moves
# cursor to the end.
def selectAllCmd(self, cs):
self.setMark(0, 0)
self.line = len(self.lines) - 1
self.column = len(self.lines[self.line].text)
def insertForcedLineBreakCmd(self, cs):
u = undo.ManyElems(self, undo.CMD_MISC, self.line, 1, 1)
self.splitLine()
self.rewrapPara()
self.rewrapPrevPara()
u.setAfter(self)
self.addUndo(u)
def splitElementCmd(self, cs):
tcfg = self.cfgGl.getType(self.lines[self.line].lt)
self.splitElement(tcfg.newTypeEnter)
def setMarkCmd(self, cs):
self.setMark(self.line, self.column)
# either creates a new element or converts the current one to
# nextTypeTab, depending on circumstances.
def tabCmd(self, cs):
if self.mark:
self.clearMark()
return
tcfg = self.cfgGl.getType(self.lines[self.line].lt)
if self.tabMakesNew():
self.splitElement(tcfg.newTypeTab)
else:
self.convertTypeTo(tcfg.nextTypeTab, True)
# switch current element to prevTypeTab.
def toPrevTypeTabCmd(self, cs):
if self.mark:
self.clearMark()
return
tcfg = self.cfgGl.getType(self.lines[self.line].lt)
self.convertTypeTo(tcfg.prevTypeTab, True)
# add character cs.char if it's a valid one.
def addCharCmd(self, cs):
char = cs.char
if len(char) != 1:
return
kc = ord(char)
if not util.isValidInputChar(kc):
return
isSpace = char == " "
# only merge with the previous item in undo history if:
# -we are not in middle of undo/redo
# -previous item is "add character"
# -cursor is exactly where it was left off by the previous item
#
# in addition, to get word-level undo, not element-level undo, we
# want to merge all spaces with the word preceding them, but stop
# merging when a new word begins. this is implemented by the
# following algorith:
#
# lastUndo char merge
# -------- ------- -----
# non-space non-space Y
# non-space space Y <- change type of lastUndo to space
# space space Y
# space non-space N
if (not self.currentUndo and self.lastUndo and
(self.lastUndo.getType() in (undo.CMD_ADD_CHAR, undo.CMD_ADD_CHAR_SPACE)) and
(self.lastUndo.endPos == self.cursorAsMark()) and
not ((self.lastUndo.getType() == undo.CMD_ADD_CHAR_SPACE) and not isSpace)):
u = self.lastUndo
mergeUndo = True
if isSpace:
u.cmdType = undo.CMD_ADD_CHAR_SPACE
else:
mergeUndo = False
if isSpace:
u = undo.SinglePara(self, undo.CMD_ADD_CHAR_SPACE, self.line)
else:
u = undo.SinglePara(self, undo.CMD_ADD_CHAR, self.line)
if self.capitalizeNeeded():
char = util.upper(char)
ls = self.lines
s = ls[self.line].text
if self.cfgGl.capitalizeI and (self.column > 0):
s = ls[self.line].text
if s[self.column - 1] == "i":
if not util.isAlnum(char):
doIt = False
if self.column > 1:
if not util.isAlnum(s[self.column - 2]):
doIt = True
else:
if (self.line == 0) or \
(ls[self.line - 1].lb != LB_NONE):
doIt = True
if doIt:
s = util.replace(s, "I", self.column - 1, 1)
s = s[:self.column] + char + s[self.column:]
ls[self.line].text = s
self.column += 1
tmp = s.upper()
if (tmp == "EXT.") or (tmp == "INT."):
if self.isOnlyLineOfElem(self.line):
ls[self.line].lt = SCENE
elif (tmp == "(") and\
ls[self.line].lt in (DIALOGUE, CHARACTER) and\
self.isOnlyLineOfElem(self.line):
ls[self.line].lt = PAREN
ls[self.line].text = "()"
self.rewrapPara()
self.markChanged()
cs.doAutoComp = cs.AC_REDO
if mergeUndo:
self.addMergedUndo(u)
else:
u.setAfter(self)
self.addUndo(u)
def toSceneCmd(self, cs):
self.convertTypeTo(SCENE, True)
def toActionCmd(self, cs):
self.convertTypeTo(ACTION, True)
def toCharacterCmd(self, cs):
self.convertTypeTo(CHARACTER, True)
def toDialogueCmd(self, cs):
self.convertTypeTo(DIALOGUE, True)
def toParenCmd(self, cs):
self.convertTypeTo(PAREN, True)
def toTransitionCmd(self, cs):
self.convertTypeTo(TRANSITION, True)
def toShotCmd(self, cs):
self.convertTypeTo(SHOT, True)
def toActBreakCmd(self, cs):
self.convertTypeTo(ACTBREAK, True)
def toNoteCmd(self, cs):
self.convertTypeTo(NOTE, True)
# return True if we can undo
def canUndo(self):
return bool(
# undo history exists
self.lastUndo
# and we either:
and (
# are not in the middle of undo/redo
not self.currentUndo or
# or are, but can still undo more
self.currentUndo.prev))
# return True if we can redo
def canRedo(self):
return bool(self.currentUndo)
def addUndo(self, u):
if self.currentUndo:
# new edit action while navigating undo history; throw away
# any undo history after current point
if self.currentUndo.prev:
# not at beginning of undo history; cut off the rest
self.currentUndo.prev.next = None
self.lastUndo = self.currentUndo.prev
else:
# beginning of undo history; throw everything away
self.firstUndo = None
self.lastUndo = None
self.currentUndo = None
# we threw away an unknown number of undo items, so we must go
# through all of the remaining ones and recalculate how much
# memory is used
self.undoMemoryUsed = 0
tmp = self.firstUndo
while tmp:
self.undoMemoryUsed += tmp.memoryUsed()
tmp = tmp.next
if not self.lastUndo:
# no undo history at all yet
self.firstUndo = u
self.lastUndo = u
else:
self.lastUndo.next = u
u.prev = self.lastUndo
self.lastUndo = u
self.undoMemoryUsed += u.memoryUsed()
# trim undo history until the estimated memory usage is small
# enough
while ((self.firstUndo is not self.lastUndo) and
(self.undoMemoryUsed >= 5000000)):
tmp = self.firstUndo
tmp.next.prev = None
self.firstUndo = tmp.next
# it shouldn't be technically necessary to reset this, but it
# might make the GC's job easier, and helps detecting bugs if
# somebody somehow tries to access this later on
tmp.next = None
self.undoMemoryUsed -= tmp.memoryUsed()
self.currentUndo = None
def addMergedUndo(self, u):
assert u is self.lastUndo
memoryUsedBefore = u.memoryUsed()
u.setAfter(self)
memoryUsedAfter = u.memoryUsed()
memoryUsedDiff = memoryUsedAfter - memoryUsedBefore
self.undoMemoryUsed += memoryUsedDiff
def undoCmd(self, cs):
if not self.canUndo():
return
# the action to undo
if self.currentUndo:
u = self.currentUndo.prev
else:
u = self.lastUndo
u.undo(self)
self.currentUndo = u
self.clearMark()
self.markChanged()
def redoCmd(self, cs):
if not self.canRedo():
return
self.currentUndo.redo(self)
self.currentUndo = self.currentUndo.next
self.clearMark()
self.markChanged()
# check script for internal consistency. raises an AssertionError on
# errors. ONLY MEANT TO BE USED IN TEST CODE.
def _validate(self):
# type of previous line, or None when a new element starts
prevType = None
# there must be at least one line
assert len(self.lines) > 0
# cursor position must be valid
assert self.line >= 0
assert self.line < len(self.lines)
assert self.column >= 0
assert self.column <= len(self.lines[self.line].text)
for ln in self.lines:
tcfg = self.cfg.getType(ln.lt)
# lines should not contain invalid characters
assert ln.text == util.toInputStr(ln.text)
# lines shouldn't be longer than the type's maximum width,
# unless the extra characters are all spaces
assert len(ln.text.rstrip(" ")) <= tcfg.width
# lines with LB_NONE linebreaks that end in a space should be
# LB_SPACE instead
if ln.lb == LB_NONE:
assert not ln.text.endswith(" ")
if prevType:
assert ln.lt == prevType
if ln.lb == LB_LAST:
prevType = None
else:
prevType = ln.lt
# one line in a screenplay
class Line:
def __init__(self, lb = LB_LAST, lt = ACTION, text = ""):
# line break type
self.lb = lb
# line type
self.lt = lt
# text
self.text = text
def __str__(self):
return config.lb2char(self.lb) + config.lt2char(self.lt)\
+ self.text
def __ne__(self, other):
return ((self.lt != other.lt) or (self.lb != other.lb) or
(self.text != other.text))
# opposite of __str__. NOTE: only meant for storing data internally by
# the program! NOT USABLE WITH EXTERNAL INPUT DUE TO COMPLETE LACK OF
# ERROR CHECKING!
@staticmethod
def fromStr(s):
return Line(config.char2lb(s[0]), config.char2lt(s[1]), s[2:])
# used to keep track of selected area. this marks one of the end-points,
# while the other one is the current position.
class Mark:
def __init__(self, line, column):
self.line = line
self.column = column
def __eq__(self, other):
return (self.line == other.line) and (self.column == other.column)
# data held in internal clipboard.
class ClipData:
def __init__(self):
# list of Line objects
self.lines = []
# stuff we need when handling commands in Screenplay.
class CommandState:
# what to do about auto-completion
AC_DEL, AC_REDO, AC_KEEP = list(range(3))
def __init__(self):
self.doAutoComp = self.AC_DEL
# only used for inserting characters, in which case this is the
# character to insert in a string form.
self.char = None
# True if this is a movement command and we should set mark at the
# current position before moving (note that currently this is just
# set if shift is down)
self.mark = False
# True if we need to make current line visible
self.needsVisifying = True
# keeps a collection of page numbers from a given screenplay, and allows
# formatting of the list intelligently, e.g. "4-7, 9, 11-16".
class PageList:
def __init__(self, allPages):
# list of all pages in the screenplay, in the format returned by
# Screenplay.getPageNumbers().
self.allPages = allPages
# key = page number (str), value = unused
self.pages = {}
# add page to page list if it's not already there
def addPage(self, page):
self.pages[str(page)] = True
def __len__(self):
return len(self.pages)
# merge two PageLists
def __iadd__(self, other):
for pg in list(other.pages.keys()):
self.addPage(pg)
return self
# return textual representation of pages where consecutive pages are
# formatted as "x-y". example: "3, 5-8, 11".
def __str__(self):
# one entry for each page from above, containing True if that page
# is contained in this PageList object
hasPage = []
for p in self.allPages:
hasPage.append(p in list(self.pages.keys()))
# finished string
s = ""
# start index of current range, or -1 if no range in progress
rangeStart = -1
for i in range(len(self.allPages)):
if rangeStart != -1:
if not hasPage[i]:
# range ends
if i != (rangeStart + 1):
s += "-%s" % self.allPages[i - 1]
rangeStart = -1
else:
if hasPage[i]:
if s:
s += ", "
s += self.allPages[i]
rangeStart = i
last = len(self.allPages) - 1
# finish last range if needed
if (rangeStart != -1) and (rangeStart != last):
s += "-%s" % self.allPages[last]
return s