ponysay/src/backend.py

650 lines
24 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
'''
ponysay - Ponysay, cowsay reimplementation for ponies
Copyright (C) 2012, 2013 Erkin Batu Altunbaş et al.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
If you intend to redistribute ponysay or a fork of it commercially,
it contains aggregated images, some of which may not be commercially
redistribute, you would be required to remove those. To determine
whether or not you may commercially redistribute an image make use
that line FREE: yes, is included inside the image between two $$$
lines and the FREE is and upper case and directly followed by
the colon.
'''
from common import *
from balloon import *
from colourstack import *
from ucs import *
'''
Super-ultra-extreme-awesomazing replacement for cowsay
'''
class Backend():
'''
Constructor
@param message:str The message spoken by the pony
@param ponyfile:str The pony file
@param wrapcolumn:int The column at where to wrap the message, `None` for no wrapping
@param width:int The width of the screen, `None` if truncation should not be applied
@param balloon:Balloon The balloon style object, `None` if only the pony should be printed
@param hyphen:str How hyphens added by the wordwrapper should be printed
@param linkcolour:str How to colour the link character, empty string if none
@param ballooncolour:str How to colour the balloon, empty string if none
@param mode:str Mode string for the pony
@parma infolevel:int 2 if ++info is used, 1 if --info is used and 0 otherwise
'''
def __init__(self, message, ponyfile, wrapcolumn, width, balloon, hyphen, linkcolour, ballooncolour, mode, infolevel):
self.message = message
self.ponyfile = ponyfile
self.wrapcolumn = None if wrapcolumn is None else wrapcolumn - (0 if balloon is None else balloon.minwidth)
self.width = width
self.balloon = balloon
self.hyphen = hyphen
self.ballooncolour = ballooncolour
self.mode = mode
self.balloontop = 0
self.balloonbottom = 0
self.infolevel = infolevel
if self.balloon is not None:
self.link = {'\\' : linkcolour + self.balloon.link,
'/' : linkcolour + self.balloon.linkmirror}
else:
self.link = {}
self.output = ''
self.pony = None
'''
Process all data
'''
def parse(self):
self.__loadFile()
if self.pony.startswith('$$$\n'):
self.pony = self.pony[4:]
if self.pony.startswith('$$$\n'):
infoend = 4
info = ''
else:
infoend = self.pony.index('\n$$$\n')
info = self.pony[:infoend]
infoend += 5
if self.infolevel == 2:
self.message = Backend.formatInfo(info)
elif self.infolevel == 1:
self.pony = Backend.formatInfo(info).replace('$', '$$')
else:
info = info.split('\n')
for line in info:
sep = line.find(':')
if sep > 0:
key = line[:sep].strip()
if key == 'BALLOON TOP':
value = line[sep + 1:].strip()
if len(value) > 0:
self.balloontop = int(value)
if key == 'BALLOON BOTTOM':
value = line[sep + 1:].strip()
if len(value) > 0:
self.balloonbottom = int(value)
printinfo(info)
self.pony = self.pony[infoend:]
elif self.infolevel == 2:
self.message = '\033[01;31mI am the mysterious mare...\033[21;39m'
elif self.infolevel == 1:
self.pony = 'There is not metadata for this pony file'
self.pony = self.mode + self.pony
self.__expandMessage()
self.__unpadMessage()
self.__processPony()
self.__truncate()
'''
Format metadata to be nicely printed, this include bold keys
@param info:str The metadata
@return :str The metadata nicely formated
'''
@staticmethod
def formatInfo(info):
info = info.split('\n')
tags = ''
comment = ''
for line in info:
sep = line.find(':')
if sep > 0:
key = line[:sep]
test = key
for c in 'ABCDEFGHIJKLMN OPQRSTUVWXYZ':
test = test.replace(c, '')
if (len(test) == 0) and (len(key.replace(' ', '')) > 0):
value = line[sep + 1:].strip()
line = '\033[1m%s\033[21m: %s\n' % (key.strip(), value)
tags += line
continue
comment += '\n' + line
comment = comment.lstrip('\n')
if len(comment) > 0:
comment = '\n' + comment
return tags + comment
'''
Remove padding spaces fortune cookies are padded with whitespace (damn featherbrains)
'''
def __unpadMessage(self):
lines = self.message.split('\n')
for spaces in (8, 4, 2, 1):
padded = True
for line in lines:
if not line.startswith(' ' * spaces):
padded = False
break
if padded:
for i in range(0, len(lines)):
line = lines[i]
while line.startswith(' ' * spaces):
line = line[spaces:]
lines[i] = line
lines = [line.rstrip(' ') for line in lines]
self.message = '\n'.join(lines)
'''
Converts all tabs in the message to spaces by expanding
'''
def __expandMessage(self):
lines = self.message.split('\n')
buf = ''
for line in lines:
(i, n, x) = (0, len(line), 0)
while i < n:
c = line[i]
i += 1
if c == '\033':
colour = Backend.getcolour(line, i - 1)
i += len(colour) - 1
buf += colour
elif c == '\t':
nx = 8 - (x & 7)
buf += ' ' * nx
x += nx
else:
buf += c
if not UCS.isCombining(c):
x += 1
buf += '\n'
self.message = buf[:-1]
'''
Loads the pony file
'''
def __loadFile(self):
with open(self.ponyfile, 'rb') as ponystream:
self.pony = ponystream.read().decode('utf8', 'replace')
'''
Truncate output to the width of the screen
'''
def __truncate(self):
if self.width is None:
return
lines = self.output.split('\n')
self.output = ''
for line in lines:
(i, n, x) = (0, len(line), 0)
while i < n:
c = line[i]
i += 1
if c == '\033':
colour = Backend.getcolour(line, i - 1)
i += len(colour) - 1
self.output += colour
else:
if x < self.width:
self.output += c
if not UCS.isCombining(c):
x += 1
self.output += '\n'
self.output = self.output[:-1]
'''
Process the pony file and generate output to self.output
'''
def __processPony(self):
self.output = ''
AUTO_PUSH = '\033[01010~'
AUTO_POP = '\033[10101~'
variables = {'' : '$'}
for key in self.link:
variables[key] = AUTO_PUSH + self.link[key] + AUTO_POP
indent = 0
dollar = None
balloonLines = None
colourstack = ColourStack(AUTO_PUSH, AUTO_POP)
(i, n, lineindex, skip, nonskip) = (0, len(self.pony), 0, 0, 0)
while i < n:
c = self.pony[i]
if c == '\t':
n += 7 - (indent & 7)
ed = ' ' * (8 - (indent & 7))
c = ' '
self.pony = self.pony[:i] + ed + self.pony[i + 1:]
i += 1
if c == '$':
if dollar is not None:
if '=' in dollar:
name = dollar[:dollar.find('=')]
value = dollar[dollar.find('=') + 1:]
variables[name] = value
elif not dollar.startswith('balloon'):
data = variables[dollar].replace('$', '$$')
if data == '$$': # if not handled specially we will get an infinity loop
if (skip == 0) or (nonskip > 0):
if nonskip > 0:
nonskip -= 1
self.output += '$'
indent += 1
else:
skip -= 1
else:
n += len(data)
self.pony = self.pony[:i] + data + self.pony[i:]
elif self.balloon is not None:
(w, h, x, justify) = ('0', 0, 0, None)
props = dollar[7:]
if len(props) > 0:
if ',' in props:
if props[0] is not ',':
w = props[:props.index(',')]
h = int(props[props.index(',') + 1:])
else:
w = props
if 'l' in w:
(x, w) = (int(w[:w.find('l')]), int(w[w.find('l') + 1:]))
justify = 'l'
w -= x;
elif 'c' in w:
(x, w) = (int(w[:w.find('c')]), int(w[w.find('c') + 1:]))
justify = 'c'
w -= x;
elif 'r' in w:
(x, w) = (int(w[:w.find('r')]), int(w[w.find('r') + 1:]))
justify = 'r'
w -= x;
else:
w = int(w)
balloon = self.__getballoon(w, h, x, justify, indent)
balloon = balloon.split('\n')
balloon = [AUTO_PUSH + self.ballooncolour + item + AUTO_POP for item in balloon]
for b in balloon[0]:
self.output += b + colourstack.feed(b)
if lineindex == 0:
balloonpre = '\n' + (' ' * indent)
for line in balloon[1:]:
self.output += balloonpre;
for b in line:
self.output += b + colourstack.feed(b);
indent = 0
elif len(balloon) > 1:
balloonLines = balloon
balloonLine = 0
balloonIndent = indent
indent += Backend.len(balloonLines[0])
balloonLines[0] = None
dollar = None
else:
dollar = ''
elif dollar is not None:
if c == '\033':
c = self.pony[i]
i += 1
dollar += c
elif c == '\033':
colour = Backend.getcolour(self.pony, i - 1)
for b in colour:
self.output += b + colourstack.feed(b);
i += len(colour) - 1
elif c == '\n':
self.output += c
indent = 0
(skip, nonskip) = (0, 0)
lineindex += 1
if balloonLines is not None:
balloonLine += 1
if balloonLine == len(balloonLines):
balloonLines = None
else:
if (balloonLines is not None) and (balloonLines[balloonLine] is not None) and (balloonIndent == indent):
data = balloonLines[balloonLine]
datalen = Backend.len(data)
skip += datalen
nonskip += datalen
data = data.replace('$', '$$')
n += len(data)
self.pony = self.pony[:i] + data + self.pony[i:]
balloonLines[balloonLine] = None
else:
if (skip == 0) or (nonskip > 0):
if nonskip > 0:
nonskip -= 1
self.output += c + colourstack.feed(c);
if not UCS.isCombining(c):
indent += 1
else:
skip -= 1
if balloonLines is not None:
for line in balloonLines[balloonLine:]:
data = ' ' * (balloonIndent - indent) + line + '\n'
for b in data:
self.output += b + colourstack.feed(b);
indent = 0
self.output = self.output.replace(AUTO_PUSH, '').replace(AUTO_POP, '')
if self.balloon is None:
if (self.balloontop > 0) or (self.balloonbottom > 0):
self.output = self.output.split('\n')
self.output = self.output[self.balloontop : ~(self.balloonbottom)]
self.output = '\n'.join(self.output)
'''
Gets colour code att the currect offset in a buffer
@param input:str The input buffer
@param offset:int The offset at where to start reading, a escape must begin here
@return :str The escape sequence
'''
@staticmethod
def getcolour(input, offset):
(i, n) = (offset, len(input))
rc = input[i]
i += 1
if i == n: return rc
c = input[i]
i += 1
rc += c
if c == ']':
if i == n: return rc
c = input[i]
i += 1
rc += c
if c == 'P':
di = 0
while (di < 7) and (i < n):
c = input[i]
i += 1
di += 1
rc += c
while c == '0':
c = input[i]
i += 1
rc += c
if c == '4':
c = input[i]
i += 1
rc += c
if c == ';':
c = input[i]
i += 1
rc += c
while c != '\\':
c = input[i]
i += 1
rc += c
elif c == '[':
while i < n:
c = input[i]
i += 1
rc += c
if (c == '~') or (('a' <= c) and (c <= 'z')) or (('A' <= c) and (c <= 'Z')):
break
return rc
'''
Calculates the number of visible characters in a text
@param input:str The input buffer
@return :int The number of visible characters
'''
@staticmethod
def len(input):
(rc, i, n) = (0, 0, len(input))
while i < n:
c = input[i]
if c == '\033':
i += len(Backend.getcolour(input, i))
else:
i += 1
if not UCS.isCombining(c):
rc += 1
return rc
'''
Generates a balloon with the message
@param width:int The minimum width of the balloon
@param height:int The minimum height of the balloon
@param innerleft:int The left column of the required span, excluding that of `left`
@param justify:str Balloon placement justification, 'c' centered,
'l' left (expand to right), 'r' right (expand to left)
@param left:int The column where the balloon starts
@return :str The balloon the the message as a string
'''
def __getballoon(self, width, height, innerleft, justify, left):
wrap = None
if self.wrapcolumn is not None:
wrap = self.wrapcolumn - left
if wrap < 8:
wrap = 8
msg = self.message
if wrap is not None:
msg = self.__wrapMessage(msg, wrap)
msg = msg.replace('\n', '\033[0m%s\n' % (self.ballooncolour)) + '\033[0m' + self.ballooncolour
msg = msg.split('\n')
extraleft = 0
if justify is not None:
msgwidth = self.len(max(msg, key = self.len)) + self.balloon.minwidth
extraleft = innerleft
if msgwidth > width:
if (justify == 'l') and (wrap is not None):
if innerleft + msgwidth > wrap:
extraleft -= msgwidth - wrap
elif justify == 'r':
extraleft -= msgwidth - width
elif justify == 'c':
extraleft -= (msgwidth - width) >> 1
if extraleft < 0:
extraleft = 0
if wrap is not None:
if extraleft + msgwidth > wrap:
extraleft -= msgwidth - wrap
rc = self.balloon.get(width, height, msg, Backend.len);
if extraleft > 0:
rc = ' ' * extraleft + rc.replace('\n', '\n' + ' ' * extraleft)
return rc
'''
Wraps the message
@param message:str The message to wrap
@param wrap:int The width at where to force wrapping
@return :str The message wrapped
'''
def __wrapMessage(self, message, wrap):
wraplimit = os.environ['PONYSAY_WRAP_LIMIT'] if 'PONYSAY_WRAP_LIMIT' in os.environ else ''
wraplimit = 8 if len(wraplimit) == 0 else int(wraplimit)
wrapexceed = os.environ['PONYSAY_WRAP_EXCEED'] if 'PONYSAY_WRAP_EXCEED' in os.environ else ''
wrapexceed = 5 if len(wrapexceed) == 0 else int(wrapexceed)
buf = ''
try:
AUTO_PUSH = '\033[01010~'
AUTO_POP = '\033[10101~'
msg = message.replace('\n', AUTO_PUSH + '\n' + AUTO_POP);
cstack = ColourStack(AUTO_PUSH, AUTO_POP)
for c in msg:
buf += c + cstack.feed(c)
lines = buf.replace(AUTO_PUSH, '').replace(AUTO_POP, '').split('\n')
buf = ''
for line in lines:
b = [None] * len(line)
map = {0 : 0}
(bi, cols, w) = (0, 0, wrap)
(indent, indentc) = (-1, 0)
(i, n) = (0, len(line))
while i <= n:
d = None
if i < n:
d = line[i]
i += 1
if d == '\033':
## Invisible stuff
i -= 1
colourseq = Backend.getcolour(line, i)
b[bi : bi + len(colourseq)] = colourseq
i += len(colourseq)
bi += len(colourseq)
elif (d is not None) and (d != ' '):
## Fetch word
if indent == -1:
indent = i - 1
for j in range(0, indent):
if line[j] == ' ':
indentc += 1
b[bi] = d
bi += 1
if (not UCS.isCombining(d)) and (d != '­'):
cols += 1
map[cols] = bi
else:
## Wrap?
mm = 0
bisub = 0
iwrap = wrap - (0 if indent == 1 else indentc)
while ((w > wraplimit) and (cols > w + wrapexceed)) or (cols > iwrap):
## wrap
x = w;
if mm + x not in map: # Too much whitespace?
cols = 0
break
nbsp = b[map[mm + x]] == ' ' # nbsp
m = map[mm + x]
if ('­' in b[bisub : m]) and not nbsp: # sort hyphen
hyphen = m - 1
while b[hyphen] != '­': # sort hyphen
hyphen -= 1
while map[mm + x] > hyphen: ## Only looking backward, if foreward is required the word is probabily not hyphenated correctly
x -= 1
x += 1
m = map[mm + x]
mm += x - (0 if nbsp else 1) ## 1 so we have space for a hythen
for bb in b[bisub : m]:
buf += bb
buf += '\n' if nbsp else '\0\n'
cols -= x - (0 if nbsp else 1)
bisub = m
w = iwrap
if indent != -1:
buf += line[:indent]
for j in range(bisub, bi):
b[j - bisub] = b[j]
bi -= bisub
if cols > w:
buf += '\n'
w = wrap
if indent != -1:
buf += line[:indent]
w -= indentc
for bb in b[:bi]:
if bb is not None:
buf += bb
w -= cols
cols = 0
bi = 0
if d is None:
i += 1
else:
if w > 0:
buf += ' '
w -= 1
else:
buf += '\n'
w = wrap
if indent != -1:
buf + line[:indent]
w -= indentc
buf += '\n'
rc = '\n'.join(line.rstrip() for line in buf[:-1].split('\n'));
rc = rc.replace('­', ''); # remove soft hyphens
rc = rc.replace('\0', '%s%s%s' % (AUTO_PUSH, self.hyphen, AUTO_POP))
return rc
except Exception as err:
import traceback
errormessage = ''.join(traceback.format_exception(type(err), err, None))
rc = '\n'.join(line.rstrip() for line in buf.split('\n'));
rc = rc.replace('\0', '%s%s%s' % (AUTO_PUSH, self.hyphen, AUTO_POP))
errormessage += '\n---- WRAPPING BUFFER ----\n\n' + rc
try:
if os.readlink('/proc/self/fd/2') != os.readlink('/proc/self/fd/1'):
printerr(errormessage, end='')
return message
except:
pass
return message + '\n\n\033[0;1;31m---- EXCEPTION IN PONYSAY WHILE WRAPPING ----\033[0m\n\n' + errormessage