3357 lines
100 KiB
Python
3357 lines
100 KiB
Python
# -*- 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
|